@hailer/mcp 1.3.14 → 1.3.23
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-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 +50 -19
- package/dist/bot/bot.d.ts.map +1 -1
- package/dist/bot/bot.js +26 -34
- 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 +62 -82
- package/dist/bot/services/helper-prompt.js.map +1 -1
- package/dist/bot/services/system-prompt.js +4 -4
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/mcp/hailer-rpc.d.ts +1 -0
- package/dist/mcp/hailer-rpc.d.ts.map +1 -1
- package/dist/mcp/hailer-rpc.js +4 -0
- package/dist/mcp/hailer-rpc.js.map +1 -1
- package/dist/mcp/tools/activity.d.ts.map +1 -1
- package/dist/mcp/tools/activity.js +8 -2
- package/dist/mcp/tools/activity.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 +5 -1
- package/dist/mcp/tools/app-marketplace.js.map +1 -1
- package/dist/mcp/webhook-handler.d.ts.map +1 -1
- package/dist/mcp/webhook-handler.js +27 -0
- package/dist/mcp/webhook-handler.js.map +1 -1
- package/dist/public-chat/graduate.d.ts.map +1 -1
- package/dist/public-chat/graduate.js +153 -53
- package/dist/public-chat/graduate.js.map +1 -1
- package/dist/public-chat/rate-limit.js +1 -1
- package/dist/public-chat/rate-limit.js.map +1 -1
- package/dist/public-chat/studio-prewarm.js +2 -2
- package/dist/public-chat/studio-prewarm.js.map +1 -1
- package/dist/public-chat/system-prompt.d.ts.map +1 -1
- package/dist/public-chat/system-prompt.js +41 -25
- 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
- package/.opencode/package-lock.json +0 -117
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hailer-app-primitives
|
|
3
|
+
description: Canonical UI primitives for Hailer apps — load when building confirm dialogs, loading/empty/error states, searchable dropdowns, date pickers, or REST client wrappers; full source included
|
|
4
|
+
version: 0.5.0
|
|
5
|
+
triggers:
|
|
6
|
+
- ConfirmDialog
|
|
7
|
+
- AlertDialog
|
|
8
|
+
- LoadingState
|
|
9
|
+
- EmptyState
|
|
10
|
+
- ErrorState
|
|
11
|
+
- SearchableSelect
|
|
12
|
+
- DatePicker
|
|
13
|
+
- DateRangePicker
|
|
14
|
+
- hailerRest
|
|
15
|
+
- REST from app
|
|
16
|
+
- confirm dialog
|
|
17
|
+
- delete dialog
|
|
18
|
+
- loading state
|
|
19
|
+
- empty state
|
|
20
|
+
- searchable dropdown
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
# Hailer App Primitives
|
|
24
|
+
|
|
25
|
+
Canonical UI components extracted from production apps. Copy and adapt props — do not re-derive the look. All components use Hailer Design System (Chakra UI v2 + Hailer theme). SDK-free; works in any Hailer app.
|
|
26
|
+
|
|
27
|
+
**Dependency note:** `SearchableSelect` and `SearchableSelectMulti` import icons from `react-icons/fi`, which is **not** shipped by `@hailer/create-app` (neither `react-icons` nor `@chakra-ui/icons` is a template dependency — `@chakra-ui/react` does not bundle icons). Either run `npm install react-icons`, or swap in the template's bundled Hailer icons from `src/hailer/theme/icons/`: `FiChevronDown` → `HailerLargeChevronDown`, `FiX` → `HailerXSmall`, `FiCheck` → `HailerTickSmall`.
|
|
28
|
+
|
|
29
|
+
**Important decision for Studio:** The hub framework auto-injects individual primitive source skills via file-pattern matching. Studio has no such injection mechanism, so **all source is inlined here**. Load this skill whenever you need any primitive.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Built primitives
|
|
34
|
+
|
|
35
|
+
| Primitive | When to use |
|
|
36
|
+
|-----------|-------------|
|
|
37
|
+
| **ConfirmDialog** | Destructive actions (delete, archive) and non-destructive confirmations (submit, publish) |
|
|
38
|
+
| **LoadingState / EmptyState / ErrorState** | Every data-fetching view needs all three |
|
|
39
|
+
| **SearchableSelect** (single) | Picking one option from a long list (> ~8 items) with keyboard nav |
|
|
40
|
+
| **SearchableSelectMulti** | Filtering/tagging where the user picks several display options |
|
|
41
|
+
| **DatePicker** | Single date field in forms |
|
|
42
|
+
| **DateRangePicker** | Start + end date filter |
|
|
43
|
+
| **dateInputToMs / toDateInputValue** | Convert between `YYYY-MM-DD` strings and Hailer's Unix-milliseconds date storage |
|
|
44
|
+
| **hailerRest** | Thin authenticated REST client over `hailer.http.fetch` (v2 only) |
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## ConfirmDialog
|
|
49
|
+
|
|
50
|
+
A general-purpose confirm/alert dialog. Defaults to destructive-delete intent (`colorScheme="red"`) but fully configurable.
|
|
51
|
+
|
|
52
|
+
**When to use:** Destructive actions (delete, archive, remove) and non-destructive confirmations (submit, publish — override `confirmColorScheme` and `confirmText`).
|
|
53
|
+
|
|
54
|
+
**When NOT to use:** Form submission (use a `<form>` with a submit button), multi-step flows (use a `<Modal>` with stepper), informational alerts with no action (use `<Alert>` inline).
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
import {
|
|
58
|
+
AlertDialog, AlertDialogBody, AlertDialogContent, AlertDialogFooter,
|
|
59
|
+
AlertDialogHeader, AlertDialogOverlay, Button,
|
|
60
|
+
} from '@chakra-ui/react';
|
|
61
|
+
import React, { useRef } from 'react';
|
|
62
|
+
|
|
63
|
+
interface ConfirmDialogProps {
|
|
64
|
+
isOpen: boolean;
|
|
65
|
+
onClose: () => void;
|
|
66
|
+
onConfirm: () => void;
|
|
67
|
+
title: string;
|
|
68
|
+
bodyText?: string;
|
|
69
|
+
cancelText?: string;
|
|
70
|
+
confirmText?: string;
|
|
71
|
+
/** Chakra colorScheme for the confirm button. Default: 'red' (destructive). */
|
|
72
|
+
confirmColorScheme?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|
76
|
+
isOpen, onClose, onConfirm, title,
|
|
77
|
+
bodyText = 'Are you sure?',
|
|
78
|
+
cancelText = 'Cancel',
|
|
79
|
+
confirmText = 'Delete',
|
|
80
|
+
confirmColorScheme = 'red',
|
|
81
|
+
}) => {
|
|
82
|
+
const cancelRef = useRef<HTMLButtonElement | null>(null);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<AlertDialog isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose} isCentered>
|
|
86
|
+
<AlertDialogOverlay>
|
|
87
|
+
<AlertDialogContent>
|
|
88
|
+
<AlertDialogHeader>{title}</AlertDialogHeader>
|
|
89
|
+
<AlertDialogBody>{bodyText}</AlertDialogBody>
|
|
90
|
+
<AlertDialogFooter>
|
|
91
|
+
<Button ref={cancelRef} onClick={onClose}>{cancelText}</Button>
|
|
92
|
+
<Button colorScheme={confirmColorScheme} onClick={onConfirm} ml={3}>
|
|
93
|
+
{confirmText}
|
|
94
|
+
</Button>
|
|
95
|
+
</AlertDialogFooter>
|
|
96
|
+
</AlertDialogContent>
|
|
97
|
+
</AlertDialogOverlay>
|
|
98
|
+
</AlertDialog>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export default ConfirmDialog;
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Props:**
|
|
106
|
+
|
|
107
|
+
| Prop | Type | Default | Notes |
|
|
108
|
+
|------|------|---------|-------|
|
|
109
|
+
| `isOpen` | `boolean` | — | Required |
|
|
110
|
+
| `onClose` | `() => void` | — | Required |
|
|
111
|
+
| `onConfirm` | `() => void` | — | Required |
|
|
112
|
+
| `title` | `string` | — | Required |
|
|
113
|
+
| `bodyText` | `string` | `'Are you sure?'` | |
|
|
114
|
+
| `cancelText` | `string` | `'Cancel'` | |
|
|
115
|
+
| `confirmText` | `string` | `'Delete'` | |
|
|
116
|
+
| `confirmColorScheme` | `string` | `'red'` | Use `'green'` for non-destructive |
|
|
117
|
+
|
|
118
|
+
**Usage:**
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
// Destructive delete (defaults)
|
|
122
|
+
<ConfirmDialog
|
|
123
|
+
isOpen={isDeleteOpen}
|
|
124
|
+
onClose={onDeleteClose}
|
|
125
|
+
onConfirm={handleDelete}
|
|
126
|
+
title="Poista rivi"
|
|
127
|
+
bodyText="Haluatko varmasti poistaa tämän rivin? Toimintoa ei voi peruuttaa."
|
|
128
|
+
confirmText="Poista"
|
|
129
|
+
/>
|
|
130
|
+
|
|
131
|
+
// Non-destructive confirm
|
|
132
|
+
<ConfirmDialog
|
|
133
|
+
isOpen={isSubmitOpen}
|
|
134
|
+
onClose={onSubmitClose}
|
|
135
|
+
onConfirm={handleSubmit}
|
|
136
|
+
title="Lähetä lomake"
|
|
137
|
+
bodyText="Haluatko lähettää lomakkeen?"
|
|
138
|
+
confirmText="Lähetä"
|
|
139
|
+
confirmColorScheme="green"
|
|
140
|
+
/>
|
|
141
|
+
|
|
142
|
+
// State pattern (pair with useDisclosure):
|
|
143
|
+
const { isOpen, onOpen, onClose } = useDisclosure();
|
|
144
|
+
<Button colorScheme="red" onClick={onOpen}>Poista</Button>
|
|
145
|
+
<ConfirmDialog
|
|
146
|
+
isOpen={isOpen}
|
|
147
|
+
onClose={onClose}
|
|
148
|
+
onConfirm={() => { handleDelete(); onClose(); }}
|
|
149
|
+
title="Poista kohde"
|
|
150
|
+
/>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## LoadingState / EmptyState / ErrorState
|
|
156
|
+
|
|
157
|
+
Three composable state-feedback components. Every data-fetching view needs all three.
|
|
158
|
+
|
|
159
|
+
```tsx
|
|
160
|
+
import { Box, Button, Spinner, Text, VStack } from '@chakra-ui/react';
|
|
161
|
+
import React from 'react';
|
|
162
|
+
|
|
163
|
+
// ── LoadingState ──────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
interface LoadingStateProps {
|
|
166
|
+
/** Label shown below the spinner. Default: 'Ladataan…' */
|
|
167
|
+
label?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function LoadingState({ label = 'Ladataan…' }: LoadingStateProps) {
|
|
171
|
+
return (
|
|
172
|
+
<Box py={12} textAlign="center">
|
|
173
|
+
<VStack spacing={3}>
|
|
174
|
+
<Spinner size="xl" color="green.500" thickness="3px" />
|
|
175
|
+
<Text color="subtleText" fontSize="sm">{label}</Text>
|
|
176
|
+
</VStack>
|
|
177
|
+
</Box>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── EmptyState ────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
interface EmptyStateProps {
|
|
184
|
+
/** Main message. Default: 'Ei tietoja näytettäväksi' */
|
|
185
|
+
label?: string;
|
|
186
|
+
/** Optional icon or illustration above the label. */
|
|
187
|
+
icon?: React.ReactNode;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function EmptyState({ label = 'Ei tietoja näytettäväksi', icon }: EmptyStateProps) {
|
|
191
|
+
return (
|
|
192
|
+
<Box py={12} textAlign="center">
|
|
193
|
+
<VStack spacing={3}>
|
|
194
|
+
{icon}
|
|
195
|
+
<Text color="subtleText" fontSize="md">{label}</Text>
|
|
196
|
+
</VStack>
|
|
197
|
+
</Box>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── ErrorState ────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
interface ErrorStateProps {
|
|
204
|
+
/** Error message to display. */
|
|
205
|
+
message: string;
|
|
206
|
+
/** Optional retry callback. When provided, a retry button is rendered. */
|
|
207
|
+
onRetry?: () => void;
|
|
208
|
+
/** Retry button label. Default: 'Yritä uudelleen' */
|
|
209
|
+
retryLabel?: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function ErrorState({ message, onRetry, retryLabel = 'Yritä uudelleen' }: ErrorStateProps) {
|
|
213
|
+
return (
|
|
214
|
+
<Box p={8} textAlign="center">
|
|
215
|
+
<Text color="red.500" mb={3}>{message}</Text>
|
|
216
|
+
{onRetry && (
|
|
217
|
+
<Button colorScheme="gray" size="sm" onClick={onRetry}>{retryLabel}</Button>
|
|
218
|
+
)}
|
|
219
|
+
</Box>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Usage:**
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
if (isLoading) return <LoadingState />;
|
|
228
|
+
if (error) return <ErrorState message={error.message} onRetry={refetch} />;
|
|
229
|
+
if (!items.length) return <EmptyState label="Ei tilauksia löytynyt" />;
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**Note:** `color="green.500"` matches the Hailer brand. Do not use `color="blue.500"` for the spinner.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## SearchableSelect (single)
|
|
237
|
+
|
|
238
|
+
Combobox-style dropdown with inline search, keyboard navigation (↑↓ Enter Esc), optional badge, and optional clear button.
|
|
239
|
+
|
|
240
|
+
**When to use:** Picking one option from a long list (> ~8 items).
|
|
241
|
+
**When NOT to use:** Short lists (≤ 8 items) — use Chakra `<Select>` or `<RadioGroup>`.
|
|
242
|
+
|
|
243
|
+
```tsx
|
|
244
|
+
import { useState, useRef, useEffect, useMemo, useCallback, KeyboardEvent } from 'react';
|
|
245
|
+
import {
|
|
246
|
+
Box, HStack, IconButton, Input, InputGroup, InputRightElement,
|
|
247
|
+
Popover, PopoverBody, PopoverContent, PopoverTrigger, Text, useColorModeValue,
|
|
248
|
+
} from '@chakra-ui/react';
|
|
249
|
+
import { FiChevronDown, FiX } from 'react-icons/fi';
|
|
250
|
+
|
|
251
|
+
export interface SelectOption {
|
|
252
|
+
_id: string;
|
|
253
|
+
name: string;
|
|
254
|
+
badge?: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
interface SearchableSelectProps {
|
|
258
|
+
value: string | null;
|
|
259
|
+
onChange: (id: string) => void;
|
|
260
|
+
options: SelectOption[];
|
|
261
|
+
placeholder: string;
|
|
262
|
+
isDisabled?: boolean;
|
|
263
|
+
isInvalid?: boolean;
|
|
264
|
+
allowClear?: boolean;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export default function SearchableSelect({
|
|
268
|
+
value, onChange, options, placeholder,
|
|
269
|
+
isDisabled = false, isInvalid = false, allowClear = false,
|
|
270
|
+
}: SearchableSelectProps) {
|
|
271
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
272
|
+
const [search, setSearch] = useState('');
|
|
273
|
+
const [highlightIndex, setHighlightIndex] = useState(0);
|
|
274
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
275
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
276
|
+
|
|
277
|
+
const selectedOption = useMemo(() => options.find((o) => o._id === value) ?? null, [options, value]);
|
|
278
|
+
const filtered = useMemo(() => {
|
|
279
|
+
if (!search.trim()) return options;
|
|
280
|
+
const q = search.toLowerCase();
|
|
281
|
+
return options.filter((o) => o.name.toLowerCase().includes(q));
|
|
282
|
+
}, [options, search]);
|
|
283
|
+
|
|
284
|
+
useEffect(() => { setHighlightIndex(0); }, [search, isOpen]);
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
if (!isOpen || !listRef.current) return;
|
|
287
|
+
const el = listRef.current.querySelector<HTMLElement>(`[data-option-index="${highlightIndex}"]`);
|
|
288
|
+
if (el) el.scrollIntoView({ block: 'nearest' });
|
|
289
|
+
}, [highlightIndex, isOpen]);
|
|
290
|
+
|
|
291
|
+
const open = useCallback(() => {
|
|
292
|
+
if (isDisabled) return;
|
|
293
|
+
setIsOpen(true); setSearch('');
|
|
294
|
+
setTimeout(() => inputRef.current?.focus(), 0);
|
|
295
|
+
}, [isDisabled]);
|
|
296
|
+
const close = useCallback(() => { setIsOpen(false); setSearch(''); }, []);
|
|
297
|
+
const select = useCallback((id: string) => { onChange(id); close(); }, [onChange, close]);
|
|
298
|
+
|
|
299
|
+
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
|
|
300
|
+
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
|
|
301
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightIndex((i) => Math.min(i + 1, Math.max(0, filtered.length - 1))); return; }
|
|
302
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightIndex((i) => Math.max(0, i - 1)); return; }
|
|
303
|
+
if (e.key === 'Enter') { e.preventDefault(); const opt = filtered[highlightIndex]; if (opt) select(opt._id); return; }
|
|
304
|
+
}, [filtered, highlightIndex, select, close]);
|
|
305
|
+
|
|
306
|
+
const triggerBg = useColorModeValue('white', 'gray.700');
|
|
307
|
+
const triggerBorder = useColorModeValue(isInvalid ? 'red.500' : 'gray.200', isInvalid ? 'red.300' : 'gray.600');
|
|
308
|
+
const hoverBg = useColorModeValue('gray.100', 'gray.600');
|
|
309
|
+
const highlightBg = useColorModeValue('green.50', 'green.900');
|
|
310
|
+
const placeholderColor = useColorModeValue('gray.500', 'gray.400');
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<Popover isOpen={isOpen} onClose={close} placement="bottom-start" autoFocus={false} matchWidth isLazy>
|
|
314
|
+
<PopoverTrigger>
|
|
315
|
+
<HStack
|
|
316
|
+
as="button" type="button" onClick={() => (isOpen ? close() : open())}
|
|
317
|
+
spacing={0} w="100%" h="40px" px={3}
|
|
318
|
+
bg={triggerBg} border="1px solid" borderColor={triggerBorder} borderRadius="md"
|
|
319
|
+
cursor={isDisabled ? 'not-allowed' : 'pointer'} opacity={isDisabled ? 0.6 : 1}
|
|
320
|
+
_hover={{ borderColor: isDisabled ? triggerBorder : 'gray.300' }} textAlign="left"
|
|
321
|
+
>
|
|
322
|
+
<Text flex={1} noOfLines={1} color={selectedOption ? undefined : placeholderColor} fontSize="md">
|
|
323
|
+
{selectedOption ? selectedOption.name : placeholder}
|
|
324
|
+
</Text>
|
|
325
|
+
{selectedOption?.badge && <Text fontSize="sm" color="subtleText" mr={2}>{selectedOption.badge}</Text>}
|
|
326
|
+
{allowClear && selectedOption && !isDisabled && (
|
|
327
|
+
<IconButton icon={<FiX />} aria-label="Tyhjennä valinta" size="xs" variant="ghost"
|
|
328
|
+
onClick={(e) => { e.stopPropagation(); onChange(''); }} mr={1} />
|
|
329
|
+
)}
|
|
330
|
+
<Box color="gray.500"><FiChevronDown /></Box>
|
|
331
|
+
</HStack>
|
|
332
|
+
</PopoverTrigger>
|
|
333
|
+
<PopoverContent w="100%">
|
|
334
|
+
<PopoverBody p={2}>
|
|
335
|
+
<InputGroup size="sm" mb={2}>
|
|
336
|
+
<Input ref={inputRef} placeholder="Hae..." value={search}
|
|
337
|
+
onChange={(e) => setSearch(e.target.value)} onKeyDown={handleKeyDown} autoFocus />
|
|
338
|
+
{search && (
|
|
339
|
+
<InputRightElement>
|
|
340
|
+
<IconButton icon={<FiX />} aria-label="Tyhjennä haku" size="xs" variant="ghost" onClick={() => setSearch('')} />
|
|
341
|
+
</InputRightElement>
|
|
342
|
+
)}
|
|
343
|
+
</InputGroup>
|
|
344
|
+
<Box ref={listRef} maxH="240px" overflowY="auto">
|
|
345
|
+
{filtered.length === 0 ? (
|
|
346
|
+
<Text fontSize="sm" color="subtleText" p={2}>Ei tuloksia</Text>
|
|
347
|
+
) : (
|
|
348
|
+
filtered.map((opt, idx) => (
|
|
349
|
+
<HStack key={opt._id} data-option-index={idx} px={3} py={2} borderRadius="md" cursor="pointer"
|
|
350
|
+
bg={idx === highlightIndex ? highlightBg : 'transparent'} _hover={{ bg: hoverBg }}
|
|
351
|
+
onClick={() => select(opt._id)} spacing={2}>
|
|
352
|
+
<Text flex={1} fontSize="sm" noOfLines={1}>{opt.name}</Text>
|
|
353
|
+
{opt.badge && <Text fontSize="xs" color="subtleText">{opt.badge}</Text>}
|
|
354
|
+
</HStack>
|
|
355
|
+
))
|
|
356
|
+
)}
|
|
357
|
+
</Box>
|
|
358
|
+
</PopoverBody>
|
|
359
|
+
</PopoverContent>
|
|
360
|
+
</Popover>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
**Usage:**
|
|
366
|
+
|
|
367
|
+
```tsx
|
|
368
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
369
|
+
const options = activities.map(a => ({ _id: a._id, name: a.name }));
|
|
370
|
+
|
|
371
|
+
<SearchableSelect
|
|
372
|
+
value={selectedId}
|
|
373
|
+
onChange={setSelectedId}
|
|
374
|
+
options={options}
|
|
375
|
+
placeholder="Valitse projekti..."
|
|
376
|
+
allowClear
|
|
377
|
+
/>
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## SearchableSelectMulti
|
|
383
|
+
|
|
384
|
+
Multi-select combobox. Shows tags for 1–2 selected, count label for 3+. Stays open for multi-pick.
|
|
385
|
+
|
|
386
|
+
**Important:** Hailer has no multi-select field type. This is a pure UI affordance — map each selected value to separate Hailer field writes when saving.
|
|
387
|
+
|
|
388
|
+
```tsx
|
|
389
|
+
import { useState, useRef, useMemo } from 'react';
|
|
390
|
+
import {
|
|
391
|
+
Box, HStack, IconButton, Input, InputGroup, InputRightElement,
|
|
392
|
+
List, ListItem, Popover, PopoverAnchor, PopoverContent,
|
|
393
|
+
Tag, TagCloseButton, TagLabel, Text, useColorModeValue, useDisclosure, Wrap, WrapItem,
|
|
394
|
+
} from '@chakra-ui/react';
|
|
395
|
+
import { FiCheck, FiChevronDown, FiX } from 'react-icons/fi';
|
|
396
|
+
|
|
397
|
+
interface SearchableSelectMultiProps {
|
|
398
|
+
placeholder: string;
|
|
399
|
+
options: string[];
|
|
400
|
+
value: string[];
|
|
401
|
+
onChange: (value: string[]) => void;
|
|
402
|
+
maxW?: string;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export default function SearchableSelectMulti({
|
|
406
|
+
placeholder, options, value, onChange, maxW = '280px',
|
|
407
|
+
}: SearchableSelectMultiProps) {
|
|
408
|
+
const { isOpen, onOpen, onClose } = useDisclosure();
|
|
409
|
+
const [search, setSearch] = useState('');
|
|
410
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
411
|
+
const selectedSet = useMemo(() => new Set(value), [value]);
|
|
412
|
+
const filtered = useMemo(() => {
|
|
413
|
+
if (!search) return options;
|
|
414
|
+
const lower = search.toLowerCase();
|
|
415
|
+
return options.filter((opt) => opt.toLowerCase().includes(lower));
|
|
416
|
+
}, [options, search]);
|
|
417
|
+
|
|
418
|
+
const hoverBg = useColorModeValue('gray.100', 'gray.600');
|
|
419
|
+
const selectedBg = useColorModeValue('green.50', 'rgba(72, 187, 120, 0.12)');
|
|
420
|
+
const popBg = useColorModeValue('white', 'gray.700');
|
|
421
|
+
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
|
422
|
+
const tickColor = useColorModeValue('green.500', 'green.300');
|
|
423
|
+
|
|
424
|
+
const handleToggle = (opt: string) => {
|
|
425
|
+
onChange(selectedSet.has(opt) ? value.filter((v) => v !== opt) : [...value, opt]);
|
|
426
|
+
};
|
|
427
|
+
const handleClear = () => { onChange([]); setSearch(''); onClose(); };
|
|
428
|
+
const handleRemoveTag = (opt: string) => onChange(value.filter((v) => v !== opt));
|
|
429
|
+
|
|
430
|
+
const triggerContent = () => {
|
|
431
|
+
if (value.length === 0) return null;
|
|
432
|
+
if (value.length <= 2) {
|
|
433
|
+
return (
|
|
434
|
+
<Wrap spacing={1} overflow="hidden" maxW="calc(100% - 28px)">
|
|
435
|
+
{value.map((v) => (
|
|
436
|
+
<WrapItem key={v}>
|
|
437
|
+
<Tag size="sm" colorScheme="green" variant="subtle" borderRadius="full">
|
|
438
|
+
<TagLabel fontSize="xs" maxW="100px" isTruncated>{v}</TagLabel>
|
|
439
|
+
<TagCloseButton onClick={(e) => { e.stopPropagation(); handleRemoveTag(v); }} />
|
|
440
|
+
</Tag>
|
|
441
|
+
</WrapItem>
|
|
442
|
+
))}
|
|
443
|
+
</Wrap>
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
return <Text fontSize="sm" color="bodyText" isTruncated>{value.length} valittu</Text>;
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
return (
|
|
450
|
+
<Popover isOpen={isOpen} onClose={onClose} autoFocus={false} matchWidth placement="bottom-start">
|
|
451
|
+
<PopoverAnchor>
|
|
452
|
+
<Box maxW={maxW} minW="180px">
|
|
453
|
+
{isOpen ? (
|
|
454
|
+
<InputGroup size="sm">
|
|
455
|
+
<Input ref={inputRef} placeholder="Hae..." value={search}
|
|
456
|
+
onChange={(e) => { setSearch(e.target.value); if (!isOpen) onOpen(); }}
|
|
457
|
+
onFocus={() => { setSearch(''); onOpen(); }}
|
|
458
|
+
onBlur={() => setTimeout(() => { setSearch(''); onClose(); }, 150)}
|
|
459
|
+
autoComplete="off" />
|
|
460
|
+
<InputRightElement w="auto" pr={1}>
|
|
461
|
+
{value.length > 0 ? (
|
|
462
|
+
<IconButton aria-label="Tyhjennä" icon={<FiX />} size="xs" variant="ghost" onClick={handleClear} tabIndex={-1} />
|
|
463
|
+
) : (
|
|
464
|
+
<Box as={FiChevronDown} boxSize={4} color="gray.400" mr={1} />
|
|
465
|
+
)}
|
|
466
|
+
</InputRightElement>
|
|
467
|
+
</InputGroup>
|
|
468
|
+
) : (
|
|
469
|
+
<HStack as="button" type="button"
|
|
470
|
+
onClick={() => { onOpen(); setTimeout(() => inputRef.current?.focus(), 0); }}
|
|
471
|
+
h="32px" px={3} w="100%" borderWidth="1px" borderColor={borderColor} borderRadius="md"
|
|
472
|
+
bg="transparent" cursor="pointer" spacing={1}
|
|
473
|
+
_hover={{ borderColor: 'gray.300' }} justify="space-between" align="center" overflow="hidden">
|
|
474
|
+
{value.length === 0
|
|
475
|
+
? <Text fontSize="sm" color="subtleText" isTruncated>{placeholder}</Text>
|
|
476
|
+
: triggerContent()}
|
|
477
|
+
{value.length > 0 ? (
|
|
478
|
+
<IconButton aria-label="Tyhjennä" icon={<FiX />} size="xs" variant="ghost"
|
|
479
|
+
onClick={(e) => { e.stopPropagation(); handleClear(); }} tabIndex={-1} flexShrink={0} />
|
|
480
|
+
) : (
|
|
481
|
+
<Box as={FiChevronDown} boxSize={4} color="gray.400" flexShrink={0} />
|
|
482
|
+
)}
|
|
483
|
+
</HStack>
|
|
484
|
+
)}
|
|
485
|
+
</Box>
|
|
486
|
+
</PopoverAnchor>
|
|
487
|
+
<PopoverContent w="100%" maxH="280px" overflowY="auto" border="1px solid" borderColor={borderColor}
|
|
488
|
+
bg={popBg} shadow="md" borderRadius="md" p={0} _focus={{ outline: 'none' }}>
|
|
489
|
+
<List spacing={0}>
|
|
490
|
+
{value.length > 0 && (
|
|
491
|
+
<ListItem px={3} py={1.5} fontSize="sm" cursor="pointer" color="red.500" fontWeight="medium"
|
|
492
|
+
_hover={{ bg: hoverBg }} onMouseDown={(e) => { e.preventDefault(); handleClear(); }}>
|
|
493
|
+
Tyhjennä valinnat
|
|
494
|
+
</ListItem>
|
|
495
|
+
)}
|
|
496
|
+
{filtered.length === 0 && (
|
|
497
|
+
<ListItem px={3} py={2}><Text fontSize="sm" color="subtleText">Ei tuloksia</Text></ListItem>
|
|
498
|
+
)}
|
|
499
|
+
{filtered.map((opt) => {
|
|
500
|
+
const isSelected = selectedSet.has(opt);
|
|
501
|
+
return (
|
|
502
|
+
<ListItem key={opt} px={3} py={1.5} fontSize="sm" cursor="pointer"
|
|
503
|
+
bg={isSelected ? selectedBg : undefined} _hover={{ bg: isSelected ? selectedBg : hoverBg }}
|
|
504
|
+
display="flex" alignItems="center" justifyContent="space-between"
|
|
505
|
+
onMouseDown={(e) => { e.preventDefault(); handleToggle(opt); }}>
|
|
506
|
+
<Text isTruncated>{opt}</Text>
|
|
507
|
+
{isSelected && <Box as={FiCheck} boxSize={4} color={tickColor} flexShrink={0} ml={2} />}
|
|
508
|
+
</ListItem>
|
|
509
|
+
);
|
|
510
|
+
})}
|
|
511
|
+
</List>
|
|
512
|
+
</PopoverContent>
|
|
513
|
+
</Popover>
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
**Usage:**
|
|
519
|
+
|
|
520
|
+
```tsx
|
|
521
|
+
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
|
|
522
|
+
|
|
523
|
+
<SearchableSelectMulti
|
|
524
|
+
value={selectedTypes}
|
|
525
|
+
onChange={setSelectedTypes}
|
|
526
|
+
options={['Toimitus', 'Nouto', 'Palautus', 'Varastointi']}
|
|
527
|
+
placeholder="Kaikki tyypit"
|
|
528
|
+
/>
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
---
|
|
532
|
+
|
|
533
|
+
## DatePicker / DateRangePicker
|
|
534
|
+
|
|
535
|
+
Native browser `<input type="date">` — no external calendar library needed.
|
|
536
|
+
|
|
537
|
+
**When to use:** Date fields in forms (single date), date range filters.
|
|
538
|
+
**When NOT to use:** Monthly/weekly calendar grid with events (use FullCalendar instead).
|
|
539
|
+
|
|
540
|
+
```tsx
|
|
541
|
+
import { Box, FormControl, FormLabel, HStack, Input, Text } from '@chakra-ui/react';
|
|
542
|
+
import React from 'react';
|
|
543
|
+
|
|
544
|
+
// ── DatePicker ────────────────────────────────────────────────────────────────
|
|
545
|
+
|
|
546
|
+
interface DatePickerProps {
|
|
547
|
+
label?: string;
|
|
548
|
+
value: string; // YYYY-MM-DD
|
|
549
|
+
onChange: (value: string) => void;
|
|
550
|
+
min?: string; // YYYY-MM-DD
|
|
551
|
+
max?: string; // YYYY-MM-DD
|
|
552
|
+
isDisabled?: boolean;
|
|
553
|
+
isRequired?: boolean;
|
|
554
|
+
isInvalid?: boolean;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function DatePicker({ label, value, onChange, min, max, isDisabled = false, isRequired = false, isInvalid = false }: DatePickerProps) {
|
|
558
|
+
return (
|
|
559
|
+
<FormControl isDisabled={isDisabled} isRequired={isRequired} isInvalid={isInvalid}>
|
|
560
|
+
{label && <FormLabel>{label}</FormLabel>}
|
|
561
|
+
<Input type="date" value={value} onChange={(e) => onChange(e.target.value)} min={min} max={max} />
|
|
562
|
+
</FormControl>
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ── DateRangePicker ───────────────────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
interface DateRangePickerProps {
|
|
569
|
+
startLabel?: string;
|
|
570
|
+
endLabel?: string;
|
|
571
|
+
startValue: string; // YYYY-MM-DD
|
|
572
|
+
endValue: string; // YYYY-MM-DD
|
|
573
|
+
onStartChange: (value: string) => void;
|
|
574
|
+
onEndChange: (value: string) => void;
|
|
575
|
+
isDisabled?: boolean;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function DateRangePicker({
|
|
579
|
+
startLabel = 'Alkaa', endLabel = 'Päättyy',
|
|
580
|
+
startValue, endValue, onStartChange, onEndChange, isDisabled = false,
|
|
581
|
+
}: DateRangePickerProps) {
|
|
582
|
+
return (
|
|
583
|
+
<HStack spacing={3} align="flex-end" wrap="wrap">
|
|
584
|
+
<DatePicker label={startLabel} value={startValue}
|
|
585
|
+
onChange={(v) => { onStartChange(v); if (endValue && v && endValue < v) onEndChange(v); }}
|
|
586
|
+
max={endValue || undefined} isDisabled={isDisabled} />
|
|
587
|
+
<Text pb={2} color="subtleText">–</Text>
|
|
588
|
+
<DatePicker label={endLabel} value={endValue} onChange={onEndChange}
|
|
589
|
+
min={startValue || undefined} isDisabled={isDisabled} />
|
|
590
|
+
</HStack>
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
## Date ↔ ms conversion helpers
|
|
596
|
+
|
|
597
|
+
Hailer date fields store **Unix milliseconds** (`number`) — confirmed by the API validation schema (`hailer-api/src/validation/sharedSchemas.ts`: `JoiDate` = "milliseconds since 1970-01-01 UTC"). Convert when reading/writing:
|
|
598
|
+
|
|
599
|
+
```ts
|
|
600
|
+
/** 'YYYY-MM-DD' → Unix milliseconds */
|
|
601
|
+
export const dateInputToMs = (v: string): number =>
|
|
602
|
+
v ? new Date(v).getTime() : 0;
|
|
603
|
+
|
|
604
|
+
/** Unix milliseconds → 'YYYY-MM-DD' */
|
|
605
|
+
export const toDateInputValue = (ms: number): string =>
|
|
606
|
+
ms ? new Date(ms).toISOString().slice(0, 10) : '';
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
**Usage:**
|
|
610
|
+
|
|
611
|
+
```tsx
|
|
612
|
+
const [date, setDate] = useState('');
|
|
613
|
+
const [startDate, setStartDate] = useState('');
|
|
614
|
+
const [endDate, setEndDate] = useState('');
|
|
615
|
+
|
|
616
|
+
<DatePicker
|
|
617
|
+
label="Toimituspäivä"
|
|
618
|
+
value={date}
|
|
619
|
+
onChange={setDate}
|
|
620
|
+
min={toDateInputValue(Date.now())}
|
|
621
|
+
/>
|
|
622
|
+
|
|
623
|
+
<DateRangePicker
|
|
624
|
+
startValue={startDate} endValue={endDate}
|
|
625
|
+
onStartChange={setStartDate} onEndChange={setEndDate}
|
|
626
|
+
/>
|
|
627
|
+
|
|
628
|
+
// Save to Hailer — update takes an ARRAY of {_id, fields}, then options
|
|
629
|
+
hailer.activity.update([
|
|
630
|
+
{ _id: activityId, fields: { [FIELDS.deliveryDate]: dateInputToMs(date) } }
|
|
631
|
+
], {});
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
---
|
|
635
|
+
|
|
636
|
+
## hailerRest — REST client primitive
|
|
637
|
+
|
|
638
|
+
Thin wrapper over `hailer.http.fetch` (app-sdk v2) that handles URL building, JSON body parsing, and error normalisation.
|
|
639
|
+
|
|
640
|
+
**v2 only.** `hailer.http` does not exist on app-sdk v1. Check `package.json` before using.
|
|
641
|
+
|
|
642
|
+
### Verified signature (from `@hailer/app-sdk` v2 `.d.ts`)
|
|
643
|
+
|
|
644
|
+
```ts
|
|
645
|
+
// HTTPFetchOptions
|
|
646
|
+
interface HTTPFetchOptions {
|
|
647
|
+
method?: string;
|
|
648
|
+
headers?: Record<string, string>;
|
|
649
|
+
body?: string; // always a string — stringify before passing
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// HTTPFetchResponse — body is a RAW STRING, not parsed JSON
|
|
653
|
+
interface HTTPFetchResponse {
|
|
654
|
+
status: number;
|
|
655
|
+
statusText: string;
|
|
656
|
+
headers: Record<string, string>;
|
|
657
|
+
body: string;
|
|
658
|
+
}
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
### Source
|
|
662
|
+
|
|
663
|
+
```ts
|
|
664
|
+
const API_BASE = 'https://api.hailer.com';
|
|
665
|
+
|
|
666
|
+
export interface HailerRestError extends Error {
|
|
667
|
+
status?: number;
|
|
668
|
+
body?: unknown;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export async function hailerRest<T = unknown>(
|
|
672
|
+
hailer: HailerApi, // the app-sdk v2 instance
|
|
673
|
+
path: string, // '/api/v3/activity.list' or a full api.hailer.com URL
|
|
674
|
+
opts: {
|
|
675
|
+
method?: string;
|
|
676
|
+
body?: unknown; // object → JSON.stringify'd
|
|
677
|
+
headers?: Record<string, string>;
|
|
678
|
+
} = {},
|
|
679
|
+
): Promise<T> {
|
|
680
|
+
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
|
|
681
|
+
|
|
682
|
+
const res = await hailer.http.fetch(url, {
|
|
683
|
+
method: opts.method ?? (opts.body !== undefined ? 'POST' : 'GET'),
|
|
684
|
+
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
|
685
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
let parsed: unknown;
|
|
689
|
+
try { parsed = res.body ? JSON.parse(res.body) : null; }
|
|
690
|
+
catch { parsed = res.body; } // non-JSON (e.g. an nginx 403 HTML page)
|
|
691
|
+
|
|
692
|
+
if (res.status < 200 || res.status >= 300) {
|
|
693
|
+
const err = new Error(
|
|
694
|
+
`hailerRest ${res.status} ${res.statusText} — ${path}`
|
|
695
|
+
) as HailerRestError;
|
|
696
|
+
err.status = res.status;
|
|
697
|
+
err.body = parsed;
|
|
698
|
+
throw err;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return parsed as T;
|
|
702
|
+
}
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
**Usage:**
|
|
706
|
+
|
|
707
|
+
```ts
|
|
708
|
+
// Fetch activities from a workflow
|
|
709
|
+
const result = await hailerRest(hailer, '/api/v3/activity.list', {
|
|
710
|
+
body: { processId, filter: { phaseId } },
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
// Explicit method
|
|
714
|
+
const updated = await hailerRest(hailer, `/api/v3/activity.update`, {
|
|
715
|
+
method: 'POST',
|
|
716
|
+
body: { activityId, fields: { [fieldId]: value } },
|
|
717
|
+
});
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
**Platform requirements:**
|
|
721
|
+
- **Web:** Works out-of-the-box. `api.hailer.com` is on the global allowlist.
|
|
722
|
+
- **iOS:** Requires `https://api.hailer.com` in the app's `allowedUrls` config. Default is empty → 400 "URL not allowed".
|
|
723
|
+
|
|
724
|
+
**Security note:** `hailer.http.fetch` runs as the **current user** — app-side filtering is not an isolation boundary. Elevated privileges or cross-user access belongs in a server-side microservice.
|
|
725
|
+
|
|
726
|
+
---
|
|
727
|
+
|
|
728
|
+
## Planned primitives (not yet extracted)
|
|
729
|
+
|
|
730
|
+
Candidates from the app fleet. Use existing app implementations as reference until a primitive lands:
|
|
731
|
+
|
|
732
|
+
- **Stepper / wizard** — 3 independent variants (StepperBar, PhasePipeline, StepIndicator)
|
|
733
|
+
- **Toolbar / FilterBar** — pairs with calendar and list views
|
|
734
|
+
- **Stat / KPI card** — 4 dashboard variants (SummaryCard, KPICards, KpiTile, CountWidget)
|
|
735
|
+
- **StatusBadge** — phase → colored badge
|
|
736
|
+
- **Drawer** (detail / side panel) — generic Chakra wrappers
|
|
737
|
+
- **TimeInput** — auto-format HH:MM
|
|
738
|
+
- **DataTable + states / skeleton** — table-with-loading/empty + configurable skeleton
|
|
739
|
+
|
|
740
|
+
## Calendar — deferred
|
|
741
|
+
|
|
742
|
+
Both production CalendarToolbar variants wrap FullCalendar + Bootstrap, not Chakra. Use [FullCalendar](https://fullcalendar.io/) (`@fullcalendar/react`) for calendar grid views. Use `DateRangePicker` for date range inputs that don't need a grid view.
|