@shipfox/react-ui 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/dist/components/dashboard/pages/analytics-page.js +9 -15
  2. package/dist/components/date-picker/date-picker.js +1 -0
  3. package/dist/components/index.d.ts +1 -0
  4. package/dist/components/index.js +1 -0
  5. package/dist/components/shipql-editor/index.d.ts +3 -0
  6. package/dist/components/shipql-editor/index.js +3 -0
  7. package/dist/components/shipql-editor/lexical/handle-arrow-right-from-leaf.d.ts +9 -0
  8. package/dist/components/shipql-editor/lexical/handle-arrow-right-from-leaf.js +46 -0
  9. package/dist/components/shipql-editor/lexical/leaf-close-overlay.d.ts +2 -0
  10. package/dist/components/shipql-editor/lexical/leaf-close-overlay.js +110 -0
  11. package/dist/components/shipql-editor/lexical/on-blur-plugin.d.ts +7 -0
  12. package/dist/components/shipql-editor/lexical/on-blur-plugin.js +27 -0
  13. package/dist/components/shipql-editor/lexical/on-text-change-plugin.d.ts +6 -0
  14. package/dist/components/shipql-editor/lexical/on-text-change-plugin.js +21 -0
  15. package/dist/components/shipql-editor/lexical/shipql-leaf-node.d.ts +31 -0
  16. package/dist/components/shipql-editor/lexical/shipql-leaf-node.js +124 -0
  17. package/dist/components/shipql-editor/lexical/shipql-plugin.d.ts +9 -0
  18. package/dist/components/shipql-editor/lexical/shipql-plugin.js +285 -0
  19. package/dist/components/shipql-editor/shipql-editor-inner.d.ts +3 -0
  20. package/dist/components/shipql-editor/shipql-editor-inner.js +103 -0
  21. package/dist/components/shipql-editor/shipql-editor.d.ts +21 -0
  22. package/dist/components/shipql-editor/shipql-editor.js +67 -0
  23. package/dist/components/table/table.stories.data.js +10 -5
  24. package/dist/styles.css +1 -1
  25. package/index.css +1051 -0
  26. package/package.json +10 -7
@@ -0,0 +1,285 @@
1
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
2
+ import { parse, removeBySource, stringify } from '@shipfox/shipql-parser';
3
+ import { $createRangeSelection, $createTextNode, $getRoot, $getSelection, $isRangeSelection, $isTextNode, $setSelection, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_NORMAL, createCommand, INSERT_PARAGRAPH_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ENTER_COMMAND } from 'lexical';
4
+ import { useEffect, useRef } from 'react';
5
+ import { handleArrowRightFromLeaf } from './handle-arrow-right-from-leaf.js';
6
+ import { $createShipQLLeafNode, $isShipQLLeafNode, isGroupedCompound, isSimpleLeaf, leafSource } from './shipql-leaf-node.js';
7
+ function collectLeaves(ast) {
8
+ // Simple terminals are always a single leaf chip.
9
+ if (isSimpleLeaf(ast)) {
10
+ return [
11
+ {
12
+ source: leafSource(ast),
13
+ node: ast
14
+ }
15
+ ];
16
+ }
17
+ // NOT: if the inner expression is a simple leaf or grouped compound,
18
+ // treat the whole NOT as a single chip. Otherwise recurse into expr.
19
+ if (ast.type === 'not') {
20
+ if (isSimpleLeaf(ast.expr) || isGroupedCompound(ast.expr)) {
21
+ return [
22
+ {
23
+ source: ast.source,
24
+ node: ast
25
+ }
26
+ ];
27
+ }
28
+ return collectLeaves(ast.expr);
29
+ }
30
+ // AND/OR: if the node is a grouped compound like `env:(prod OR staging)`,
31
+ // treat the whole thing as a single chip. Otherwise recurse both sides.
32
+ if (ast.type === 'and' || ast.type === 'or') {
33
+ if (isGroupedCompound(ast)) {
34
+ return [
35
+ {
36
+ source: ast.source,
37
+ node: ast
38
+ }
39
+ ];
40
+ }
41
+ return [
42
+ ...collectLeaves(ast.left),
43
+ ...collectLeaves(ast.right)
44
+ ];
45
+ }
46
+ return [];
47
+ }
48
+ function tokenize(fullText, leaves) {
49
+ const segments = [];
50
+ let remaining = fullText;
51
+ for (const { source, node } of leaves){
52
+ const idx = remaining.indexOf(source);
53
+ if (idx === -1) {
54
+ // Source not found in remaining text — bail and treat all as operator
55
+ return [
56
+ {
57
+ kind: 'op',
58
+ text: fullText
59
+ }
60
+ ];
61
+ }
62
+ if (idx > 0) segments.push({
63
+ kind: 'op',
64
+ text: remaining.slice(0, idx)
65
+ });
66
+ segments.push({
67
+ kind: 'leaf',
68
+ text: source,
69
+ node
70
+ });
71
+ remaining = remaining.slice(idx + source.length);
72
+ }
73
+ if (remaining.length > 0) segments.push({
74
+ kind: 'op',
75
+ text: remaining
76
+ });
77
+ return segments.filter((s)=>s.text.length > 0);
78
+ }
79
+ function needsRebuild(children, segments) {
80
+ if (children.length !== segments.length) return true;
81
+ for(let i = 0; i < children.length; i++){
82
+ const child = children[i];
83
+ const seg = segments[i];
84
+ if ($isShipQLLeafNode(child) !== (seg.kind === 'leaf')) return true;
85
+ if ($isTextNode(child) && child.getTextContent() !== seg.text) return true;
86
+ }
87
+ return false;
88
+ }
89
+ function getAbsoluteOffset(para, point) {
90
+ let offset = 0;
91
+ for (const child of para.getChildren()){
92
+ if (child.getKey() === point.key) return offset + point.offset;
93
+ if ($isTextNode(child)) offset += child.getTextContentSize();
94
+ }
95
+ return offset + point.offset;
96
+ }
97
+ // ─── Commands ─────────────────────────────────────────────────────────────────
98
+ /** Payload: the Lexical node key of the leaf to remove. */ export const REMOVE_LEAF_COMMAND = createCommand('REMOVE_LEAF_COMMAND');
99
+ // ─── Plugin ───────────────────────────────────────────────────────────────────
100
+ const REBUILD_TAG = 'shipql-rebuild';
101
+ export function ShipQLPlugin({ onLeafFocus }) {
102
+ const [editor] = useLexicalComposerContext();
103
+ // Keep latest callback accessible inside Lexical listeners without re-registering.
104
+ const onLeafFocusRef = useRef(onLeafFocus);
105
+ onLeafFocusRef.current = onLeafFocus;
106
+ // Track the key of the last focused leaf to avoid redundant callbacks.
107
+ const lastFocusedKeyRef = useRef(null);
108
+ useEffect(()=>{
109
+ // Block newlines — ShipQL is a single-line language.
110
+ const unregisterEnter = editor.registerCommand(KEY_ENTER_COMMAND, (event)=>{
111
+ event?.preventDefault();
112
+ return true;
113
+ }, COMMAND_PRIORITY_HIGH);
114
+ const unregisterParagraph = editor.registerCommand(INSERT_PARAGRAPH_COMMAND, ()=>true, COMMAND_PRIORITY_HIGH);
115
+ const unregisterArrowRight = editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, (event)=>handleArrowRightFromLeaf(event, editor), COMMAND_PRIORITY_NORMAL);
116
+ const unregisterRemoveLeaf = editor.registerCommand(REMOVE_LEAF_COMMAND, (nodeKey)=>{
117
+ editor.update(()=>{
118
+ const para = $getRoot().getFirstChild();
119
+ if (!para) return;
120
+ // Find the leaf node's source text from its key.
121
+ let targetSource = null;
122
+ for (const child of para.getChildren()){
123
+ if ($isShipQLLeafNode(child) && child.getKey() === nodeKey) {
124
+ targetSource = child.getTextContent();
125
+ break;
126
+ }
127
+ }
128
+ if (!targetSource) return;
129
+ // Parse, remove, re-stringify.
130
+ const fullText = para.getTextContent();
131
+ let ast = null;
132
+ try {
133
+ ast = parse(fullText);
134
+ } catch {
135
+ return;
136
+ }
137
+ if (!ast) return;
138
+ const newAst = removeBySource(ast, targetSource);
139
+ const newText = stringify(newAst);
140
+ // Replace paragraph content — the update listener will re-tokenize.
141
+ para.clear();
142
+ if (newText) {
143
+ para.append($createTextNode(newText));
144
+ }
145
+ });
146
+ return true;
147
+ }, COMMAND_PRIORITY_HIGH);
148
+ const unregisterUpdate = editor.registerUpdateListener(({ editorState, tags })=>{
149
+ // After our own rebuild we only need to refresh the leaf-focus callback.
150
+ if (tags.has(REBUILD_TAG)) {
151
+ editorState.read(()=>{
152
+ const sel = $getSelection();
153
+ if (!$isRangeSelection(sel)) return;
154
+ const anchor = sel.anchor.getNode();
155
+ const key = $isShipQLLeafNode(anchor) ? anchor.getKey() : null;
156
+ if (key !== lastFocusedKeyRef.current) {
157
+ lastFocusedKeyRef.current = key;
158
+ onLeafFocusRef.current?.(key !== null && $isShipQLLeafNode(anchor) ? anchor.getShipQLNode() : null);
159
+ }
160
+ });
161
+ return;
162
+ }
163
+ // ── Read current editor state ──────────────────────────────────────────
164
+ let shouldRebuild = false;
165
+ let nextSegments = [];
166
+ let savedOffset = -1;
167
+ editorState.read(()=>{
168
+ const para = $getRoot().getFirstChild();
169
+ if (!para) return;
170
+ const text = para.getTextContent();
171
+ const children = para.getChildren();
172
+ let ast = null;
173
+ try {
174
+ ast = parse(text);
175
+ } catch {
176
+ // parse error — fall through with ast = null
177
+ }
178
+ if (!ast) {
179
+ // Clear all leaf highlights if any exist.
180
+ if (children.some($isShipQLLeafNode)) {
181
+ shouldRebuild = true;
182
+ nextSegments = text ? [
183
+ {
184
+ kind: 'op',
185
+ text
186
+ }
187
+ ] : [];
188
+ const sel = $getSelection();
189
+ if ($isRangeSelection(sel) && sel.anchor.type === 'text') {
190
+ savedOffset = getAbsoluteOffset(para, sel.anchor);
191
+ }
192
+ }
193
+ // Cursor left any leaf.
194
+ if (lastFocusedKeyRef.current !== null) {
195
+ lastFocusedKeyRef.current = null;
196
+ onLeafFocusRef.current?.(null);
197
+ }
198
+ return;
199
+ }
200
+ const segments = tokenize(text, collectLeaves(ast));
201
+ if (needsRebuild(children, segments)) {
202
+ shouldRebuild = true;
203
+ nextSegments = segments;
204
+ const sel = $getSelection();
205
+ if ($isRangeSelection(sel) && sel.anchor.type === 'text') {
206
+ savedOffset = getAbsoluteOffset(para, sel.anchor);
207
+ }
208
+ } else {
209
+ // No structural change — just update leaf-focus.
210
+ const sel = $getSelection();
211
+ if ($isRangeSelection(sel)) {
212
+ const anchor = sel.anchor.getNode();
213
+ const key = $isShipQLLeafNode(anchor) ? anchor.getKey() : null;
214
+ if (key !== lastFocusedKeyRef.current) {
215
+ lastFocusedKeyRef.current = key;
216
+ onLeafFocusRef.current?.(key !== null && $isShipQLLeafNode(anchor) ? anchor.getShipQLNode() : null);
217
+ }
218
+ }
219
+ }
220
+ });
221
+ if (!shouldRebuild) return;
222
+ // ── Rebuild node structure ─────────────────────────────────────────────
223
+ editor.update(()=>{
224
+ const para = $getRoot().getFirstChild();
225
+ if (!para) return;
226
+ para.clear();
227
+ const newNodes = nextSegments.map((seg)=>seg.kind === 'leaf' ? $createShipQLLeafNode(seg.text, seg.node) : $createTextNode(seg.text));
228
+ for (const node of newNodes)para.append(node);
229
+ // Restore cursor to the same absolute character position.
230
+ if (savedOffset >= 0 && newNodes.length > 0) {
231
+ let remaining = savedOffset;
232
+ for (const node of newNodes){
233
+ const len = $isTextNode(node) ? node.getTextContentSize() : 0;
234
+ if (remaining <= len) {
235
+ const sel = $createRangeSelection();
236
+ sel.anchor.set(node.getKey(), remaining, 'text');
237
+ sel.focus.set(node.getKey(), remaining, 'text');
238
+ $setSelection(sel);
239
+ break;
240
+ }
241
+ remaining -= len;
242
+ }
243
+ }
244
+ }, {
245
+ tag: REBUILD_TAG
246
+ });
247
+ });
248
+ // Perform the initial tokenization pass directly. The update listener is
249
+ // registered after Lexical has already committed its initialConfig editorState
250
+ // (useEffect runs post-paint), so the listener never fires for the first render.
251
+ // We do the full rebuild here instead of relying on the listener being triggered.
252
+ editor.update(()=>{
253
+ const para = $getRoot().getFirstChild();
254
+ if (!para) return;
255
+ const text = para.getTextContent();
256
+ if (!text) return;
257
+ const children = para.getChildren();
258
+ let ast = null;
259
+ try {
260
+ ast = parse(text);
261
+ } catch {
262
+ return;
263
+ }
264
+ if (!ast) return;
265
+ const segments = tokenize(text, collectLeaves(ast));
266
+ if (!needsRebuild(children, segments)) return;
267
+ para.clear();
268
+ for (const seg of segments){
269
+ para.append(seg.kind === 'leaf' ? $createShipQLLeafNode(seg.text, seg.node) : $createTextNode(seg.text));
270
+ }
271
+ });
272
+ return ()=>{
273
+ unregisterEnter();
274
+ unregisterParagraph();
275
+ unregisterArrowRight();
276
+ unregisterRemoveLeaf();
277
+ unregisterUpdate();
278
+ };
279
+ }, [
280
+ editor
281
+ ]);
282
+ return null;
283
+ }
284
+
285
+ //# sourceMappingURL=shipql-plugin.js.map
@@ -0,0 +1,3 @@
1
+ import type { ShipQLEditorInnerProps } from './shipql-editor';
2
+ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder, className, disabled, mode, text, editorKey, isError, onTextChange, onClear, onToggleMode, }: ShipQLEditorInnerProps): import("react/jsx-runtime").JSX.Element;
3
+ //# sourceMappingURL=shipql-editor-inner.d.ts.map
@@ -0,0 +1,103 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { LexicalComposer } from '@lexical/react/LexicalComposer';
3
+ import { ContentEditable } from '@lexical/react/LexicalContentEditable';
4
+ import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
5
+ import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
6
+ import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin';
7
+ import { Input } from '../../components/input/index.js';
8
+ import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical';
9
+ import { cn } from '../../utils/cn.js';
10
+ import { Icon } from '../icon/icon.js';
11
+ import { LeafCloseOverlay } from './lexical/leaf-close-overlay.js';
12
+ import { OnBlurPlugin } from './lexical/on-blur-plugin.js';
13
+ import { OnTextChangePlugin } from './lexical/on-text-change-plugin.js';
14
+ import { ShipQLLeafNode } from './lexical/shipql-leaf-node.js';
15
+ import { ShipQLPlugin } from './lexical/shipql-plugin.js';
16
+ const INPUT_CLASSES = 'block w-full rounded-6 bg-background-field-base py-2 pl-7 pr-58 sm:pr-64 text-md text-foreground-neutral-base caret-foreground-neutral-base outline-none focus:border-border-highlights-interactive shadow-button-neutral';
17
+ const INPUT_ERROR_CLASSES = 'shadow-border-error';
18
+ const BUTTON_CLASSES = 'shrink-0 text-foreground-neutral-subtle hover:text-foreground-neutral-base transition-all duration-150 flex justify-center items-center cursor-pointer w-28 sm:w-32 h-full';
19
+ export default function ShipQLEditorInner({ onChange, onLeafFocus, placeholder, className, disabled, mode, text, editorKey, isError, onTextChange, onClear, onToggleMode }) {
20
+ return /*#__PURE__*/ _jsxs("div", {
21
+ "data-shipql-editor": true,
22
+ className: cn('relative', className),
23
+ children: [
24
+ mode === 'editor' ? /*#__PURE__*/ _jsxs(LexicalComposer, {
25
+ initialConfig: {
26
+ namespace: 'ShipQLEditor',
27
+ nodes: [
28
+ ShipQLLeafNode
29
+ ],
30
+ onError: (error)=>{
31
+ throw error;
32
+ },
33
+ editorState: text ? ()=>{
34
+ const para = $createParagraphNode();
35
+ para.append($createTextNode(text));
36
+ $getRoot().append(para);
37
+ } : undefined
38
+ },
39
+ children: [
40
+ /*#__PURE__*/ _jsx(PlainTextPlugin, {
41
+ contentEditable: /*#__PURE__*/ _jsx(ContentEditable, {
42
+ id: "shipql-editor",
43
+ "aria-label": "ShipQL query editor",
44
+ className: cn(INPUT_CLASSES, isError && INPUT_ERROR_CLASSES, disabled && 'pointer-events-none opacity-50')
45
+ }),
46
+ placeholder: placeholder ? /*#__PURE__*/ _jsx("div", {
47
+ className: "pointer-events-none absolute left-0 top-0 select-none px-7 py-2 text-md text-foreground-neutral-muted",
48
+ children: placeholder
49
+ }) : null,
50
+ ErrorBoundary: LexicalErrorBoundary
51
+ }),
52
+ /*#__PURE__*/ _jsx(ShipQLPlugin, {
53
+ onLeafFocus: onLeafFocus
54
+ }),
55
+ /*#__PURE__*/ _jsx(OnBlurPlugin, {
56
+ onChange: onChange
57
+ }),
58
+ /*#__PURE__*/ _jsx(OnTextChangePlugin, {
59
+ onTextChange: onTextChange
60
+ }),
61
+ /*#__PURE__*/ _jsx(HistoryPlugin, {}),
62
+ !disabled && /*#__PURE__*/ _jsx(LeafCloseOverlay, {})
63
+ ]
64
+ }, editorKey) : /*#__PURE__*/ _jsx(Input, {
65
+ ref: (el)=>el?.focus(),
66
+ "aria-label": "ShipQL query editor",
67
+ className: cn(INPUT_CLASSES, disabled && 'pointer-events-none opacity-50'),
68
+ "aria-invalid": isError,
69
+ value: text,
70
+ onChange: (e)=>onTextChange(e.target.value),
71
+ placeholder: placeholder,
72
+ disabled: disabled
73
+ }),
74
+ !disabled && /*#__PURE__*/ _jsxs("div", {
75
+ className: "absolute right-0 top-0 flex h-28 items-center",
76
+ children: [
77
+ /*#__PURE__*/ _jsx("button", {
78
+ type: "button",
79
+ "aria-label": "Clear query",
80
+ className: cn(BUTTON_CLASSES, text.trim().length > 0 ? 'opacity-100' : 'opacity-0 pointer-events-none'),
81
+ onClick: onClear,
82
+ children: /*#__PURE__*/ _jsx(Icon, {
83
+ name: "closeLine",
84
+ size: 16
85
+ })
86
+ }),
87
+ /*#__PURE__*/ _jsx("button", {
88
+ type: "button",
89
+ "aria-label": mode === 'editor' ? 'Switch to free text mode' : 'Switch to editor mode',
90
+ className: cn(BUTTON_CLASSES, 'sm:border-l border-border-neutral-strong'),
91
+ onClick: onToggleMode,
92
+ children: /*#__PURE__*/ _jsx(Icon, {
93
+ name: mode === 'editor' ? 'edit2Line' : 'codeLine',
94
+ size: 16
95
+ })
96
+ })
97
+ ]
98
+ })
99
+ ]
100
+ });
101
+ }
102
+
103
+ //# sourceMappingURL=shipql-editor-inner.js.map
@@ -0,0 +1,21 @@
1
+ import type { AstNode } from '@shipfox/shipql-parser';
2
+ import type { LeafAstNode } from './lexical/shipql-leaf-node';
3
+ export interface ShipQLEditorProps {
4
+ defaultValue?: string;
5
+ onChange?: (ast: AstNode) => void;
6
+ onLeafFocus?: (node: LeafAstNode | null) => void;
7
+ placeholder?: string;
8
+ className?: string;
9
+ disabled?: boolean;
10
+ }
11
+ export interface ShipQLEditorInnerProps extends ShipQLEditorProps {
12
+ mode: 'editor' | 'text';
13
+ text: string;
14
+ editorKey: number;
15
+ isError: boolean;
16
+ onTextChange: (text: string) => void;
17
+ onClear: () => void;
18
+ onToggleMode: () => void;
19
+ }
20
+ export declare function ShipQLEditor({ disabled, className, ...props }: ShipQLEditorProps): import("react/jsx-runtime").JSX.Element;
21
+ //# sourceMappingURL=shipql-editor.d.ts.map
@@ -0,0 +1,67 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { parse } from '@shipfox/shipql-parser';
3
+ import { lazy, Suspense, useCallback, useRef, useState } from 'react';
4
+ import { cn } from '../../utils/cn.js';
5
+ function isParseError(text) {
6
+ if (!text.trim()) return false;
7
+ try {
8
+ return parse(text) === null;
9
+ } catch {
10
+ return true;
11
+ }
12
+ }
13
+ const ShipQLEditorInner = /*#__PURE__*/ lazy(()=>import('./shipql-editor-inner.js'));
14
+ export function ShipQLEditor({ disabled, className, ...props }) {
15
+ const [mode, setMode] = useState('editor');
16
+ const [text, setText] = useState(props.defaultValue ?? '');
17
+ const [editorKey, setEditorKey] = useState(0);
18
+ const [isError, setIsError] = useState(()=>isParseError(props.defaultValue ?? ''));
19
+ const textRef = useRef(text);
20
+ textRef.current = text;
21
+ const clearingRef = useRef(false);
22
+ const handleTextChange = useCallback((newText)=>{
23
+ if (clearingRef.current) {
24
+ if (newText.trim() === '') clearingRef.current = false;
25
+ return;
26
+ }
27
+ textRef.current = newText;
28
+ setText(newText);
29
+ setIsError(isParseError(newText));
30
+ }, []);
31
+ const handleClear = useCallback(()=>{
32
+ clearingRef.current = true;
33
+ setText('');
34
+ textRef.current = '';
35
+ setIsError(false);
36
+ setEditorKey((k)=>k + 1);
37
+ }, []);
38
+ const handleToggleMode = useCallback(()=>{
39
+ setMode((m)=>{
40
+ if (m === 'editor') {
41
+ setText(textRef.current);
42
+ return 'text';
43
+ }
44
+ setEditorKey((k)=>k + 1);
45
+ return 'editor';
46
+ });
47
+ }, []);
48
+ return /*#__PURE__*/ _jsx(Suspense, {
49
+ fallback: /*#__PURE__*/ _jsx("div", {
50
+ className: cn('h-8 w-full animate-pulse rounded-6 bg-background-components-base', className)
51
+ }),
52
+ children: /*#__PURE__*/ _jsx(ShipQLEditorInner, {
53
+ ...props,
54
+ className: className,
55
+ disabled: disabled,
56
+ mode: mode,
57
+ text: text,
58
+ editorKey: editorKey,
59
+ isError: isError,
60
+ onTextChange: handleTextChange,
61
+ onClear: handleClear,
62
+ onToggleMode: handleToggleMode
63
+ })
64
+ });
65
+ }
66
+
67
+ //# sourceMappingURL=shipql-editor.js.map
@@ -62,16 +62,21 @@
62
62
  ];
63
63
  return Array.from({
64
64
  length: count
65
- }, (_, i)=>({
65
+ }, (_, i)=>{
66
+ const total = 40 + (i * 7 + 3) % 80;
67
+ const failed = (i * 3 + 1) % 20;
68
+ const success = total - failed;
69
+ return {
66
70
  id: `job-${i + 1}`,
67
71
  name: jobNames[i % jobNames.length],
68
- total: Math.floor(Math.random() * 100) + 20,
69
- success: Math.floor(Math.random() * 80) + 10,
70
- failed: Math.floor(Math.random() * 20),
72
+ total,
73
+ success,
74
+ failed,
71
75
  status: statuses[i % statuses.length],
72
76
  repository: repositories[i % repositories.length],
73
77
  branch: branches[i % branches.length]
74
- }));
78
+ };
79
+ });
75
80
  };
76
81
  /**
77
82
  * Sample job data