@djangocfg/ui-tools 2.1.411 → 2.1.412
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/package.json +6 -6
- package/src/tools/Chat/composer/AttachContext.tsx +22 -0
- package/src/tools/Chat/composer/Composer.tsx +108 -6
- package/src/tools/Chat/composer/ComposerMenuButton.tsx +39 -2
- package/src/tools/Chat/composer/fileToAttachment.ts +53 -0
- package/src/tools/Chat/composer/index.ts +16 -1
- package/src/tools/Chat/composer/types.ts +71 -0
- package/src/tools/Chat/composer/useComposerAttach.tsx +218 -0
- package/src/tools/Chat/hooks/useChat.ts +32 -0
- package/src/tools/Chat/hooks/useChatComposer.ts +13 -0
- package/src/tools/Chat/messages/MessageBubble.tsx +1 -1
- package/src/tools/Chat/public.ts +1 -0
- package/src/tools/Chat/types/events.ts +50 -0
- package/src/tools/Chat/types/index.ts +1 -1
- package/src/tools/Chat/types/message.ts +5 -0
- package/src/tools/CronScheduler/CronScheduler.client.tsx +42 -15
- package/src/tools/CronScheduler/components/CustomInput.tsx +26 -7
- package/src/tools/CronScheduler/components/DayChips.tsx +20 -7
- package/src/tools/CronScheduler/components/MonthDayGrid.tsx +35 -10
- package/src/tools/CronScheduler/components/SchedulePreview.tsx +8 -5
- package/src/tools/CronScheduler/components/ScheduleTypeSelector.tsx +12 -3
- package/src/tools/CronScheduler/components/TimeSelector.tsx +36 -13
- package/src/tools/CronScheduler/context/CronSchedulerContext.tsx +4 -0
- package/src/tools/CronScheduler/context/hooks.ts +8 -0
- package/src/tools/CronScheduler/context/index.ts +1 -0
- package/src/tools/CronScheduler/index.tsx +2 -0
- package/src/tools/CronScheduler/lazy.tsx +1 -0
- package/src/tools/CronScheduler/types/index.ts +18 -1
- package/src/tools/Map/lazy.tsx +11 -4
- package/src/tools/Uploader/hooks/useClipboardPaste.ts +3 -1
- package/src/tools/index.ts +2 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react';
|
|
4
|
+
|
|
5
|
+
import { buildAcceptString, useClipboardPaste } from '../../Uploader';
|
|
6
|
+
import type { UseChatComposerReturn } from '../hooks/useChatComposer';
|
|
7
|
+
import { fileToAttachment, revokeAttachmentUrl } from './fileToAttachment';
|
|
8
|
+
import type {
|
|
9
|
+
ComposerAcceptType,
|
|
10
|
+
ComposerAttachConfig,
|
|
11
|
+
ComposerAttachHandle,
|
|
12
|
+
} from './types';
|
|
13
|
+
|
|
14
|
+
const ALL_ACCEPT: ComposerAcceptType[] = ['image', 'audio', 'video', 'document'];
|
|
15
|
+
|
|
16
|
+
/** True when a file's MIME is allowed by the accepted asset categories. */
|
|
17
|
+
function isAcceptedType(file: File, accept: ComposerAcceptType[]): boolean {
|
|
18
|
+
const mime = file.type;
|
|
19
|
+
if (mime.startsWith('image/')) return accept.includes('image');
|
|
20
|
+
if (mime.startsWith('audio/')) return accept.includes('audio');
|
|
21
|
+
if (mime.startsWith('video/')) return accept.includes('video');
|
|
22
|
+
// No / other MIME — treat as a document.
|
|
23
|
+
return accept.includes('document');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UseComposerAttachParams {
|
|
27
|
+
composer: UseChatComposerReturn;
|
|
28
|
+
config: ComposerAttachConfig;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
/** Paste listener scope. Defaults to `document` when omitted. */
|
|
31
|
+
pasteScopeRef?: RefObject<HTMLElement | null>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The unified attach pipeline. One validated path that the paperclip
|
|
36
|
+
* button, the `+` menu, drag-drop and Ctrl+V paste all funnel into:
|
|
37
|
+
*
|
|
38
|
+
* openPicker() / attachFiles(File[])
|
|
39
|
+
* → validate (size / type / count)
|
|
40
|
+
* → fileToAttachment()
|
|
41
|
+
* → composer.addAttachment()
|
|
42
|
+
*
|
|
43
|
+
* Phase 2 stops at `status:'ready'` (object-URL). The `uploadFn`
|
|
44
|
+
* lifecycle lands in phase 5 — `config.uploadFn` is accepted here but
|
|
45
|
+
* not yet consumed.
|
|
46
|
+
*
|
|
47
|
+
* Reuses `useClipboardPaste` + `buildAcceptString` from the Uploader
|
|
48
|
+
* tool rather than re-implementing clipboard / mime handling.
|
|
49
|
+
*/
|
|
50
|
+
export function useComposerAttach({
|
|
51
|
+
composer,
|
|
52
|
+
config,
|
|
53
|
+
disabled = false,
|
|
54
|
+
pasteScopeRef,
|
|
55
|
+
}: UseComposerAttachParams): ComposerAttachHandle {
|
|
56
|
+
const {
|
|
57
|
+
accept = ALL_ACCEPT,
|
|
58
|
+
maxFiles,
|
|
59
|
+
maxSizeBytes,
|
|
60
|
+
multiple = true,
|
|
61
|
+
pasteEnabled = true,
|
|
62
|
+
uploadFn,
|
|
63
|
+
onReject,
|
|
64
|
+
} = config;
|
|
65
|
+
|
|
66
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
67
|
+
|
|
68
|
+
// Stable refs so the paste listener / handlers never go stale without
|
|
69
|
+
// forcing the consumer to memoize anything.
|
|
70
|
+
const composerRef = useRef(composer);
|
|
71
|
+
composerRef.current = composer;
|
|
72
|
+
const onRejectRef = useRef(onReject);
|
|
73
|
+
onRejectRef.current = onReject;
|
|
74
|
+
const uploadFnRef = useRef(uploadFn);
|
|
75
|
+
uploadFnRef.current = uploadFn;
|
|
76
|
+
|
|
77
|
+
const acceptString = useMemo(() => {
|
|
78
|
+
// buildAcceptString expects the Uploader's AssetType — same string
|
|
79
|
+
// union as ComposerAcceptType, so the cast is safe.
|
|
80
|
+
return buildAcceptString(accept as Parameters<typeof buildAcceptString>[0]);
|
|
81
|
+
}, [accept]);
|
|
82
|
+
|
|
83
|
+
// Drive one file through the host `uploadFn`: flip the attachment to
|
|
84
|
+
// `uploading`, stream progress, then settle on `ready` (url rewritten
|
|
85
|
+
// to the remote location) or `error`. Fire-and-forget — failures are
|
|
86
|
+
// surfaced on the attachment, never thrown.
|
|
87
|
+
const runUpload = useCallback(
|
|
88
|
+
(file: File, attachmentId: string, localUrl: string) => {
|
|
89
|
+
const fn = uploadFnRef.current;
|
|
90
|
+
if (!fn) return;
|
|
91
|
+
const c = composerRef.current;
|
|
92
|
+
c.updateAttachment(attachmentId, { status: 'uploading', progress: 0 });
|
|
93
|
+
fn(file, (fraction) => {
|
|
94
|
+
composerRef.current.updateAttachment(attachmentId, {
|
|
95
|
+
progress: Math.max(0, Math.min(1, fraction)),
|
|
96
|
+
});
|
|
97
|
+
})
|
|
98
|
+
.then((result) => {
|
|
99
|
+
composerRef.current.updateAttachment(attachmentId, {
|
|
100
|
+
status: 'ready',
|
|
101
|
+
progress: 1,
|
|
102
|
+
url: result.url,
|
|
103
|
+
thumbnailUrl: result.thumbnailUrl,
|
|
104
|
+
});
|
|
105
|
+
// The remote URL replaced the object-URL — free it.
|
|
106
|
+
revokeAttachmentUrl({ url: localUrl });
|
|
107
|
+
})
|
|
108
|
+
.catch(() => {
|
|
109
|
+
composerRef.current.updateAttachment(attachmentId, { status: 'error' });
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
[],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// The single validated entry point. Drop / paste / picker all land here.
|
|
116
|
+
const attachFiles = useCallback(
|
|
117
|
+
(files: File[]) => {
|
|
118
|
+
if (disabled || files.length === 0) return;
|
|
119
|
+
const c = composerRef.current;
|
|
120
|
+
const cap = maxFiles ?? Number.POSITIVE_INFINITY;
|
|
121
|
+
|
|
122
|
+
let slots = cap - c.attachments.length;
|
|
123
|
+
for (const file of files) {
|
|
124
|
+
if (slots <= 0) {
|
|
125
|
+
onRejectRef.current?.(file, 'count');
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (maxSizeBytes != null && file.size > maxSizeBytes) {
|
|
129
|
+
onRejectRef.current?.(file, 'size');
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!isAcceptedType(file, accept)) {
|
|
133
|
+
onRejectRef.current?.(file, 'type');
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const attachment = fileToAttachment(file);
|
|
137
|
+
c.addAttachment(attachment);
|
|
138
|
+
// With an uploadFn the attachment starts local then uploads in
|
|
139
|
+
// the background; without one it stays `ready` immediately.
|
|
140
|
+
if (uploadFnRef.current) {
|
|
141
|
+
runUpload(file, attachment.id, attachment.url);
|
|
142
|
+
}
|
|
143
|
+
slots -= 1;
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[disabled, maxFiles, maxSizeBytes, accept, runUpload],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const openPicker = useCallback(() => {
|
|
150
|
+
if (disabled) return;
|
|
151
|
+
inputRef.current?.click();
|
|
152
|
+
}, [disabled]);
|
|
153
|
+
|
|
154
|
+
// Remove a staged attachment, freeing its object-URL first so a
|
|
155
|
+
// discarded blob does not leak.
|
|
156
|
+
const removeAttachment = useCallback((id: string) => {
|
|
157
|
+
const c = composerRef.current;
|
|
158
|
+
const found = c.attachments.find((a) => a.id === id);
|
|
159
|
+
if (found) revokeAttachmentUrl(found);
|
|
160
|
+
c.removeAttachment(id);
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
const onInputChange = useCallback(
|
|
164
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
165
|
+
const picked = e.target.files;
|
|
166
|
+
if (picked && picked.length > 0) attachFiles(Array.from(picked));
|
|
167
|
+
// Reset so picking the same file twice still fires `change`.
|
|
168
|
+
e.target.value = '';
|
|
169
|
+
},
|
|
170
|
+
[attachFiles],
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// Ctrl+V / Cmd+V — reuse the Uploader's clipboard resolver. It already
|
|
174
|
+
// skips text fields when the clipboard carries text, so pasting an
|
|
175
|
+
// image into the composer textarea attaches without hijacking typing.
|
|
176
|
+
const pasteAccept = useMemo(
|
|
177
|
+
() => accept.filter((a) => a !== 'document'),
|
|
178
|
+
[accept],
|
|
179
|
+
);
|
|
180
|
+
useClipboardPaste(
|
|
181
|
+
{
|
|
182
|
+
enabled: pasteEnabled && !disabled,
|
|
183
|
+
acceptTypes: pasteAccept.length > 0 ? pasteAccept : undefined,
|
|
184
|
+
maxBytes: maxSizeBytes,
|
|
185
|
+
onFiles: attachFiles,
|
|
186
|
+
},
|
|
187
|
+
pasteScopeRef,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// On unmount, free any object-URLs still held by staged attachments
|
|
191
|
+
// (drafts abandoned without sending). `composerRef` gives the latest
|
|
192
|
+
// list without re-running the effect on every attach.
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
return () => {
|
|
195
|
+
for (const a of composerRef.current.attachments) {
|
|
196
|
+
revokeAttachmentUrl(a);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}, []);
|
|
200
|
+
|
|
201
|
+
const atCap =
|
|
202
|
+
maxFiles != null && composer.attachments.length >= maxFiles;
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
openPicker,
|
|
206
|
+
attachFiles,
|
|
207
|
+
removeAttachment,
|
|
208
|
+
disabled: disabled || atCap,
|
|
209
|
+
inputProps: {
|
|
210
|
+
ref: inputRef,
|
|
211
|
+
type: 'file',
|
|
212
|
+
accept: acceptString,
|
|
213
|
+
multiple,
|
|
214
|
+
hidden: true,
|
|
215
|
+
onChange: onInputChange,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -407,6 +407,38 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
407
407
|
sources: ev.sources?.length ?? 0,
|
|
408
408
|
});
|
|
409
409
|
return;
|
|
410
|
+
case 'message_metrics':
|
|
411
|
+
// Non-terminal: attach per-turn metrics to the streaming
|
|
412
|
+
// message. The stream stays open until message_end / error.
|
|
413
|
+
dispatch({
|
|
414
|
+
type: 'MESSAGE_PATCH',
|
|
415
|
+
id: targetId,
|
|
416
|
+
patch: {
|
|
417
|
+
metrics: ev.metrics,
|
|
418
|
+
...(ev.metrics.resolvedModel
|
|
419
|
+
? { resolvedModel: ev.metrics.resolvedModel }
|
|
420
|
+
: {}),
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
log.stream.debug('message_metrics', {
|
|
424
|
+
turns: ev.metrics.turns,
|
|
425
|
+
toolCallCount: ev.metrics.toolCallCount,
|
|
426
|
+
resolvedModel: ev.metrics.resolvedModel,
|
|
427
|
+
});
|
|
428
|
+
return;
|
|
429
|
+
case 'resolved_model':
|
|
430
|
+
// Non-terminal: model alias was resolved mid-run.
|
|
431
|
+
dispatch({
|
|
432
|
+
type: 'MESSAGE_PATCH',
|
|
433
|
+
id: targetId,
|
|
434
|
+
patch: { resolvedModel: ev.resolvedModel },
|
|
435
|
+
});
|
|
436
|
+
log.stream.debug('resolved_model', {
|
|
437
|
+
originalAlias: ev.originalAlias,
|
|
438
|
+
resolvedModel: ev.resolvedModel,
|
|
439
|
+
upgraded: ev.upgraded,
|
|
440
|
+
});
|
|
441
|
+
return;
|
|
410
442
|
case 'error':
|
|
411
443
|
tokenBuffer.flush();
|
|
412
444
|
dispatch({
|
|
@@ -50,6 +50,9 @@ export interface UseChatComposerReturn {
|
|
|
50
50
|
setValue: (next: string) => void;
|
|
51
51
|
attachments: ChatAttachment[];
|
|
52
52
|
addAttachment: (a: ChatAttachment) => void;
|
|
53
|
+
/** Patch an existing attachment in place — used by the upload lifecycle
|
|
54
|
+
* to flip `status` / `progress` / `url` without losing list order. */
|
|
55
|
+
updateAttachment: (id: string, patch: Partial<ChatAttachment>) => void;
|
|
53
56
|
removeAttachment: (id: string) => void;
|
|
54
57
|
isSubmitting: boolean;
|
|
55
58
|
canSubmit: boolean;
|
|
@@ -192,6 +195,15 @@ export function useChatComposer(options: UseChatComposerOptions): UseChatCompose
|
|
|
192
195
|
[maxAttachments],
|
|
193
196
|
);
|
|
194
197
|
|
|
198
|
+
const updateAttachment = useCallback(
|
|
199
|
+
(id: string, patch: Partial<ChatAttachment>) => {
|
|
200
|
+
setAttachments((prev) =>
|
|
201
|
+
prev.map((a) => (a.id === id ? { ...a, ...patch } : a)),
|
|
202
|
+
);
|
|
203
|
+
},
|
|
204
|
+
[],
|
|
205
|
+
);
|
|
206
|
+
|
|
195
207
|
const removeAttachment = useCallback((id: string) => {
|
|
196
208
|
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
|
197
209
|
}, []);
|
|
@@ -276,6 +288,7 @@ export function useChatComposer(options: UseChatComposerOptions): UseChatCompose
|
|
|
276
288
|
setValue,
|
|
277
289
|
attachments,
|
|
278
290
|
addAttachment,
|
|
291
|
+
updateAttachment,
|
|
279
292
|
removeAttachment,
|
|
280
293
|
isSubmitting,
|
|
281
294
|
canSubmit,
|
package/src/tools/Chat/public.ts
CHANGED
|
@@ -7,6 +7,32 @@
|
|
|
7
7
|
|
|
8
8
|
import type { ChatSource } from './attachment';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Per-run metrics block. Emitted once per assistant turn alongside the
|
|
12
|
+
* terminal `message_end` event (a separate `message_metrics` event so
|
|
13
|
+
* `message_end` keeps its lean `tokensIn`/`tokensOut`/`sources` shape).
|
|
14
|
+
*
|
|
15
|
+
* All fields optional — the backend omits anything it could not measure.
|
|
16
|
+
*/
|
|
17
|
+
export interface ChatMessageMetrics {
|
|
18
|
+
inputTokens?: number;
|
|
19
|
+
outputTokens?: number;
|
|
20
|
+
totalTokens?: number;
|
|
21
|
+
cacheReadTokens?: number;
|
|
22
|
+
cacheWriteTokens?: number;
|
|
23
|
+
processingTimeMs?: number;
|
|
24
|
+
firstTokenMs?: number;
|
|
25
|
+
turns?: number;
|
|
26
|
+
toolCallCount?: number;
|
|
27
|
+
toolNames?: string[];
|
|
28
|
+
/** Model the request was sent with (may be an alias). */
|
|
29
|
+
model?: string;
|
|
30
|
+
/** Concrete model the alias resolved to. */
|
|
31
|
+
resolvedModel?: string;
|
|
32
|
+
/** Short summary of the model's thinking, if available. */
|
|
33
|
+
thinkingSummary?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
export type ChatStreamEvent =
|
|
11
37
|
| { type: 'message_start'; messageId: string; sessionId: string }
|
|
12
38
|
| { type: 'resume_start' }
|
|
@@ -32,4 +58,28 @@ export type ChatStreamEvent =
|
|
|
32
58
|
tokensOut?: number;
|
|
33
59
|
sources?: ChatSource[];
|
|
34
60
|
}
|
|
61
|
+
| {
|
|
62
|
+
/**
|
|
63
|
+
* Per-turn metrics for the assistant message currently streaming.
|
|
64
|
+
* Non-terminal — arrives near the end of the run but does not
|
|
65
|
+
* close the stream (`message_end` / `error` do that).
|
|
66
|
+
*/
|
|
67
|
+
type: 'message_metrics';
|
|
68
|
+
metrics: ChatMessageMetrics;
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
/**
|
|
72
|
+
* One-shot model-alias resolution (e.g. `@code` → `glm-5.1`).
|
|
73
|
+
* Non-terminal — lets the UI update the model chip mid-run.
|
|
74
|
+
*/
|
|
75
|
+
type: 'resolved_model';
|
|
76
|
+
/** Alias the user/request originally specified. */
|
|
77
|
+
originalAlias: string;
|
|
78
|
+
/** Concrete model id the alias resolved to. */
|
|
79
|
+
resolvedModel: string;
|
|
80
|
+
/** True when routing upgraded the model (e.g. for a larger context). */
|
|
81
|
+
upgraded?: boolean;
|
|
82
|
+
/** Human-readable reason the router picked this model. */
|
|
83
|
+
routingReason?: string;
|
|
84
|
+
}
|
|
35
85
|
| { type: 'error'; code: string; message: string };
|
|
@@ -40,7 +40,7 @@ export type { ChatMessage } from './message';
|
|
|
40
40
|
export { DEFAULT_LABELS } from './labels';
|
|
41
41
|
export type { ChatLabels } from './labels';
|
|
42
42
|
export type { ChatConfig, ChatPrefs, ChatDisplayMode } from './config';
|
|
43
|
-
export type { ChatStreamEvent } from './events';
|
|
43
|
+
export type { ChatStreamEvent, ChatMessageMetrics } from './events';
|
|
44
44
|
export type {
|
|
45
45
|
CreateSessionOptions,
|
|
46
46
|
SessionInfo,
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { ChatAttachment, ChatSource } from './attachment';
|
|
9
9
|
import type { MessageBlock } from './block';
|
|
10
|
+
import type { ChatMessageMetrics } from './events';
|
|
10
11
|
import type { ChatPersona, ChatRole } from './persona';
|
|
11
12
|
import type { ChatToolCall } from './tool-call';
|
|
12
13
|
|
|
@@ -32,4 +33,8 @@ export interface ChatMessage {
|
|
|
32
33
|
sources?: ChatSource[];
|
|
33
34
|
tokensIn?: number;
|
|
34
35
|
tokensOut?: number;
|
|
36
|
+
/** Per-turn metrics (from a `message_metrics` stream event). */
|
|
37
|
+
metrics?: ChatMessageMetrics;
|
|
38
|
+
/** Concrete model the request resolved to (from a `resolved_model` event). */
|
|
39
|
+
resolvedModel?: string;
|
|
35
40
|
}
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from '@djangocfg/ui-core/components';
|
|
19
19
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
20
20
|
import { CronSchedulerProvider } from './context/CronSchedulerContext';
|
|
21
|
-
import { useCronType, useCronPreview } from './context/hooks';
|
|
21
|
+
import { useCronType, useCronPreview, useCronSize } from './context/hooks';
|
|
22
22
|
import {
|
|
23
23
|
ScheduleTypeSelector,
|
|
24
24
|
TimeSelector,
|
|
@@ -39,9 +39,11 @@ interface ScheduleEditorProps {
|
|
|
39
39
|
|
|
40
40
|
function ScheduleEditor({ timeFormat, disabled }: ScheduleEditorProps) {
|
|
41
41
|
const { type } = useCronType();
|
|
42
|
+
const size = useCronSize();
|
|
43
|
+
const isSm = size === 'sm';
|
|
42
44
|
|
|
43
45
|
return (
|
|
44
|
-
<div className=
|
|
46
|
+
<div className={isSm ? 'space-y-1.5' : 'space-y-3'}>
|
|
45
47
|
<ScheduleTypeSelector disabled={disabled} />
|
|
46
48
|
|
|
47
49
|
{type !== 'custom' && (
|
|
@@ -67,6 +69,7 @@ function CronExpressionLine({
|
|
|
67
69
|
}) {
|
|
68
70
|
const [copied, setCopied] = useState(false);
|
|
69
71
|
const { cronExpression, isValid } = useCronPreview();
|
|
72
|
+
const isSm = useCronSize() === 'sm';
|
|
70
73
|
|
|
71
74
|
const handleCopy = async (e: React.MouseEvent) => {
|
|
72
75
|
e.stopPropagation();
|
|
@@ -80,8 +83,13 @@ function CronExpressionLine({
|
|
|
80
83
|
};
|
|
81
84
|
|
|
82
85
|
return (
|
|
83
|
-
<div className={cn('flex items-center gap-1.5', className)}>
|
|
84
|
-
<code
|
|
86
|
+
<div className={cn('flex items-center', isSm ? 'gap-1' : 'gap-1.5', className)}>
|
|
87
|
+
<code
|
|
88
|
+
className={cn(
|
|
89
|
+
'font-mono text-muted-foreground',
|
|
90
|
+
isSm ? 'text-[11px]' : 'text-xs'
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
85
93
|
{cronExpression}
|
|
86
94
|
</code>
|
|
87
95
|
{allowCopy && (
|
|
@@ -131,9 +139,10 @@ function CompactTrigger({
|
|
|
131
139
|
}: CompactTriggerProps) {
|
|
132
140
|
const [open, setOpen] = useState(false);
|
|
133
141
|
const { humanDescription, isValid } = useCronPreview();
|
|
142
|
+
const isSm = useCronSize() === 'sm';
|
|
134
143
|
|
|
135
144
|
return (
|
|
136
|
-
<div className={cn('space-y-1.5', className)}>
|
|
145
|
+
<div className={cn(isSm ? 'space-y-1' : 'space-y-1.5', className)}>
|
|
137
146
|
<Popover open={open} onOpenChange={setOpen}>
|
|
138
147
|
<PopoverTrigger asChild>
|
|
139
148
|
<button
|
|
@@ -142,17 +151,19 @@ function CompactTrigger({
|
|
|
142
151
|
aria-expanded={open}
|
|
143
152
|
disabled={disabled}
|
|
144
153
|
className={cn(
|
|
145
|
-
'flex
|
|
146
|
-
'border-input bg-transparent
|
|
154
|
+
'flex w-full items-center rounded-md border',
|
|
155
|
+
'border-input bg-transparent shadow-xs transition-colors',
|
|
147
156
|
'hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
|
|
148
157
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
158
|
+
isSm ? 'h-8 gap-1.5 px-2 text-xs' : 'h-9 gap-2 px-3 text-sm',
|
|
149
159
|
!isValid && 'border-destructive/50'
|
|
150
160
|
)}
|
|
151
161
|
>
|
|
152
162
|
<Calendar
|
|
153
163
|
aria-hidden="true"
|
|
154
164
|
className={cn(
|
|
155
|
-
'
|
|
165
|
+
'shrink-0',
|
|
166
|
+
isSm ? 'h-3.5 w-3.5' : 'h-4 w-4',
|
|
156
167
|
isValid ? 'text-muted-foreground' : 'text-destructive'
|
|
157
168
|
)}
|
|
158
169
|
/>
|
|
@@ -166,12 +177,15 @@ function CompactTrigger({
|
|
|
166
177
|
</span>
|
|
167
178
|
<ChevronsUpDown
|
|
168
179
|
aria-hidden="true"
|
|
169
|
-
className=
|
|
180
|
+
className={cn('shrink-0 opacity-50', isSm ? 'h-3.5 w-3.5' : 'h-4 w-4')}
|
|
170
181
|
/>
|
|
171
182
|
</button>
|
|
172
183
|
</PopoverTrigger>
|
|
173
184
|
<PopoverContent
|
|
174
|
-
className=
|
|
185
|
+
className={cn(
|
|
186
|
+
'w-[var(--radix-popover-trigger-width)]',
|
|
187
|
+
isSm ? 'min-w-[280px] p-2' : 'min-w-[320px] p-3'
|
|
188
|
+
)}
|
|
175
189
|
align="start"
|
|
176
190
|
>
|
|
177
191
|
<ScheduleEditor timeFormat={timeFormat} disabled={disabled} />
|
|
@@ -202,8 +216,10 @@ function InlineScheduler({
|
|
|
202
216
|
allowCopy,
|
|
203
217
|
className,
|
|
204
218
|
}: InlineSchedulerProps) {
|
|
219
|
+
const isSm = useCronSize() === 'sm';
|
|
220
|
+
|
|
205
221
|
return (
|
|
206
|
-
<div className={cn('space-y-3', className)}>
|
|
222
|
+
<div className={cn(isSm ? 'space-y-1.5' : 'space-y-3', className)}>
|
|
207
223
|
<ScheduleEditor timeFormat={timeFormat} disabled={disabled} />
|
|
208
224
|
<InlinePreview showCronExpression={showCronExpression} allowCopy={allowCopy} />
|
|
209
225
|
</div>
|
|
@@ -218,26 +234,35 @@ function InlinePreview({
|
|
|
218
234
|
allowCopy: boolean;
|
|
219
235
|
}) {
|
|
220
236
|
const { humanDescription, isValid } = useCronPreview();
|
|
237
|
+
const isSm = useCronSize() === 'sm';
|
|
221
238
|
|
|
222
239
|
return (
|
|
223
240
|
<div
|
|
224
241
|
role="status"
|
|
225
242
|
aria-live="polite"
|
|
226
243
|
className={cn(
|
|
227
|
-
'flex items-center justify-between gap-2 rounded-md border
|
|
244
|
+
'flex items-center justify-between gap-2 rounded-md border',
|
|
228
245
|
'bg-muted/30 border-border/50',
|
|
246
|
+
isSm ? 'px-2 py-1' : 'px-3 py-2',
|
|
229
247
|
!isValid && 'border-destructive/30 bg-destructive/5'
|
|
230
248
|
)}
|
|
231
249
|
>
|
|
232
|
-
<div className=
|
|
250
|
+
<div className={cn('flex min-w-0 items-center', isSm ? 'gap-1.5' : 'gap-2')}>
|
|
233
251
|
<Calendar
|
|
234
252
|
aria-hidden="true"
|
|
235
253
|
className={cn(
|
|
236
|
-
'
|
|
254
|
+
'shrink-0',
|
|
255
|
+
isSm ? 'h-3 w-3' : 'h-4 w-4',
|
|
237
256
|
isValid ? 'text-primary' : 'text-destructive'
|
|
238
257
|
)}
|
|
239
258
|
/>
|
|
240
|
-
<span
|
|
259
|
+
<span
|
|
260
|
+
className={cn(
|
|
261
|
+
'truncate',
|
|
262
|
+
isSm ? 'text-[11px]' : 'text-sm',
|
|
263
|
+
!isValid && 'text-destructive'
|
|
264
|
+
)}
|
|
265
|
+
>
|
|
241
266
|
{humanDescription}
|
|
242
267
|
</span>
|
|
243
268
|
</div>
|
|
@@ -276,6 +301,7 @@ export function CronScheduler({
|
|
|
276
301
|
disabled = false,
|
|
277
302
|
inline = false,
|
|
278
303
|
placeholder = 'Set a schedule',
|
|
304
|
+
size = 'default',
|
|
279
305
|
className,
|
|
280
306
|
}: CronSchedulerProps) {
|
|
281
307
|
return (
|
|
@@ -283,6 +309,7 @@ export function CronScheduler({
|
|
|
283
309
|
value={value}
|
|
284
310
|
onChange={onChange}
|
|
285
311
|
defaultType={defaultType}
|
|
312
|
+
size={size}
|
|
286
313
|
>
|
|
287
314
|
{inline ? (
|
|
288
315
|
<InlineScheduler
|
|
@@ -10,7 +10,7 @@ import { useState, useEffect } from 'react';
|
|
|
10
10
|
import { Input } from '@djangocfg/ui-core/components';
|
|
11
11
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
12
12
|
import { AlertCircle, CheckCircle2 } from 'lucide-react';
|
|
13
|
-
import { useCronCustom } from '../context/hooks';
|
|
13
|
+
import { useCronCustom, useCronSize } from '../context/hooks';
|
|
14
14
|
import { isValidCron } from '../utils/cron-parser';
|
|
15
15
|
|
|
16
16
|
export interface CustomInputProps {
|
|
@@ -20,6 +20,7 @@ export interface CustomInputProps {
|
|
|
20
20
|
|
|
21
21
|
export function CustomInput({ disabled, className }: CustomInputProps) {
|
|
22
22
|
const { customCron, isValid, setCustomCron } = useCronCustom();
|
|
23
|
+
const isSm = useCronSize() === 'sm';
|
|
23
24
|
const [localValue, setLocalValue] = useState(customCron);
|
|
24
25
|
const [localValid, setLocalValid] = useState(isValid);
|
|
25
26
|
|
|
@@ -39,8 +40,14 @@ export function CustomInput({ disabled, className }: CustomInputProps) {
|
|
|
39
40
|
const showError = !localValid && localValue.trim().length > 0;
|
|
40
41
|
|
|
41
42
|
return (
|
|
42
|
-
<div className={cn('space-y-2', className)}>
|
|
43
|
-
<label
|
|
43
|
+
<div className={cn(isSm ? 'space-y-1.5' : 'space-y-2', className)}>
|
|
44
|
+
<label
|
|
45
|
+
htmlFor="cron-custom-input"
|
|
46
|
+
className={cn(
|
|
47
|
+
'text-muted-foreground',
|
|
48
|
+
isSm ? 'text-xs' : 'text-sm'
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
44
51
|
Cron expression
|
|
45
52
|
</label>
|
|
46
53
|
|
|
@@ -59,16 +66,28 @@ export function CustomInput({ disabled, className }: CustomInputProps) {
|
|
|
59
66
|
aria-invalid={showError}
|
|
60
67
|
aria-describedby={showError ? 'cron-custom-error' : undefined}
|
|
61
68
|
className={cn(
|
|
62
|
-
'font-mono
|
|
69
|
+
'font-mono',
|
|
70
|
+
isSm ? 'text-xs h-8 pr-8' : 'text-base h-11 pr-10',
|
|
63
71
|
showError && 'border-destructive focus-visible:ring-destructive/50'
|
|
64
72
|
)}
|
|
65
73
|
/>
|
|
66
|
-
<div
|
|
74
|
+
<div
|
|
75
|
+
className={cn(
|
|
76
|
+
'absolute top-1/2 -translate-y-1/2',
|
|
77
|
+
isSm ? 'right-2' : 'right-3'
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
67
80
|
{localValue.trim() && (
|
|
68
81
|
localValid ? (
|
|
69
|
-
<CheckCircle2
|
|
82
|
+
<CheckCircle2
|
|
83
|
+
aria-hidden="true"
|
|
84
|
+
className={cn('text-success', isSm ? 'h-4 w-4' : 'h-5 w-5')}
|
|
85
|
+
/>
|
|
70
86
|
) : (
|
|
71
|
-
<AlertCircle
|
|
87
|
+
<AlertCircle
|
|
88
|
+
aria-hidden="true"
|
|
89
|
+
className={cn('text-destructive', isSm ? 'h-4 w-4' : 'h-5 w-5')}
|
|
90
|
+
/>
|
|
72
91
|
)
|
|
73
92
|
)}
|
|
74
93
|
</div>
|