@yargram/react 1.0.2 → 1.0.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yargram/react",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "main": "./src/index.ts",
5
5
  "types": "./src/index.ts",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
- import React from 'react';
2
- import { Info, AlertTriangle, CircleAlert } from 'lucide-react';
3
- import type { LogEntry as LogEntryType, LogLevel } from './types';
1
+ import React, { useState } from 'react';
2
+ import { Info, AlertTriangle, CircleAlert, ChevronRight, ChevronDown } from 'lucide-react';
3
+ import type { LogEntry as LogEntryType, LogLevel, LogMessage } from './types';
4
4
  import './LogWindow.css';
5
5
 
6
6
  type LogEntryRowProps = {
@@ -13,6 +13,52 @@ const levelIcons: Record<LogLevel, React.ComponentType<{ size?: number | string;
13
13
  error: CircleAlert,
14
14
  };
15
15
 
16
+ function isExpandableMessage(msg: LogMessage): msg is Record<string, unknown> | unknown[] {
17
+ return typeof msg === 'object' && msg !== null;
18
+ }
19
+
20
+ function getMessageSummary(msg: Record<string, unknown> | unknown[]): string {
21
+ if (Array.isArray(msg)) return `Array (${msg.length})`;
22
+ return `Object (${Object.keys(msg).length} keys)`;
23
+ }
24
+
25
+ type LogEntryMessageProps = {
26
+ message: LogMessage;
27
+ };
28
+
29
+ function LogEntryMessage({ message }: LogEntryMessageProps) {
30
+ const [expanded, setExpanded] = useState(false);
31
+
32
+ if (isExpandableMessage(message)) {
33
+ const summary = getMessageSummary(message);
34
+ return (
35
+ <div className="logWindowEntryAccordion">
36
+ <button
37
+ type="button"
38
+ className="logWindowEntryAccordionHeader"
39
+ onClick={() => setExpanded((e) => !e)}
40
+ aria-expanded={expanded}
41
+ >
42
+ <span className="logWindowEntryAccordionChevron">
43
+ {expanded ? <ChevronDown size={14} aria-hidden /> : <ChevronRight size={14} aria-hidden />}
44
+ </span>
45
+ <span className="logWindowEntryAccordionSummary">{summary}</span>
46
+ </button>
47
+ <div
48
+ className={`logWindowEntryAccordionBody ${expanded ? 'logWindowEntryAccordionBodyExpanded' : ''}`}
49
+ aria-hidden={!expanded}
50
+ >
51
+ <div className="logWindowEntryAccordionBodyInner">
52
+ <pre className="logWindowEntryAccordionPre">{JSON.stringify(message, null, 2)}</pre>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ const text = typeof message === 'string' ? message : String(message);
60
+ return <span className="logWindowEntryMessageText">{text}</span>;
61
+ }
16
62
 
17
63
  export function LogEntryRow({ entry }: LogEntryRowProps) {
18
64
  const levelClass = `logWindowEntry${entry.level.charAt(0).toUpperCase() + entry.level.slice(1)}` as
@@ -31,7 +77,9 @@ export function LogEntryRow({ entry }: LogEntryRowProps) {
31
77
  <span className={`logWindowEntryIcon ${iconClass}`}>
32
78
  <IconComponent size={12} />
33
79
  </span>
34
- <span className="logWindowEntryMessage">{entry.message}</span>
80
+ <span className="logWindowEntryMessage">
81
+ <LogEntryMessage message={entry.message} />
82
+ </span>
35
83
  <span className="logWindowEntrySource">{entry.source}</span>
36
84
  </div>
37
85
  );
@@ -396,10 +396,10 @@
396
396
 
397
397
  .logWindowEntry {
398
398
  display: flex;
399
- align-items: center;
399
+ align-items: flex-start;
400
400
  gap: 10px;
401
401
  padding: 6px 14px;
402
- min-height: 28px;
402
+ min-height: 13px;
403
403
  color: var(--logw-text);
404
404
  }
405
405
 
@@ -457,9 +457,89 @@
457
457
 
458
458
  .logWindowEntryMessage {
459
459
  flex: 1;
460
- white-space: nowrap;
460
+ min-width: 0;
461
+ margin-top: 3px;
462
+ }
463
+
464
+ .logWindowEntryMessageText {
465
+ white-space: pre-wrap;
466
+ word-break: break-word;
467
+ }
468
+
469
+ /* Log entry accordion (object / array) */
470
+ .logWindowEntryAccordion {
471
+ width: 100%;
472
+ min-width: 0;
473
+ }
474
+
475
+ .logWindowEntryAccordionHeader {
476
+ display: flex;
477
+ align-items: center;
478
+ gap: 6px;
479
+ width: 100%;
480
+ padding: 0;
481
+ margin: 0;
482
+ border: none;
483
+ background: transparent;
484
+ color: inherit;
485
+ font: inherit;
486
+ cursor: pointer;
487
+ text-align: left;
488
+ }
489
+
490
+ .logWindowEntryAccordionHeader:hover {
491
+ background: rgba(255, 255, 255, 0.04);
492
+ border-radius: 4px;
493
+ }
494
+
495
+ .logWindowEntryAccordionChevron {
496
+ display: inline-flex;
497
+ align-items: center;
498
+ justify-content: center;
499
+ flex-shrink: 0;
500
+ color: var(--logw-text-muted);
501
+ }
502
+
503
+ .logWindowEntryAccordionChevron svg {
504
+ width: 14px;
505
+ height: 14px;
506
+ }
507
+
508
+ .logWindowEntryAccordionSummary {
509
+ color: #ffffff;
510
+ font-size: 11px;
511
+ }
512
+
513
+ .logWindowEntryAccordionBody {
514
+ display: grid;
515
+ grid-template-rows: 0fr;
516
+ transition: grid-template-rows 0.2s ease-out;
517
+ }
518
+
519
+ .logWindowEntryAccordionBodyExpanded {
520
+ grid-template-rows: 1fr;
521
+ }
522
+
523
+ .logWindowEntryAccordionBodyInner {
461
524
  overflow: hidden;
462
- text-overflow: ellipsis;
525
+ min-height: 0;
526
+ }
527
+
528
+ .logWindowEntryAccordionPre {
529
+ margin: 4px 0 0 20px;
530
+ padding: 8px 10px;
531
+ font-size: 11px;
532
+ line-height: 1.4;
533
+ color: var(--logw-text);
534
+ background: rgba(0, 0, 0, 0.25);
535
+ border-radius: 4px;
536
+ white-space: pre-wrap;
537
+ word-break: break-word;
538
+ font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, monospace;
539
+ }
540
+
541
+ .logWindowEntryAccordionBody:not(.logWindowEntryAccordionBodyExpanded) .logWindowEntryAccordionPre {
542
+ opacity: 0;
463
543
  }
464
544
 
465
545
  .logWindowEntrySource {
@@ -137,12 +137,69 @@ export const ManyEntries: Story = {
137
137
  },
138
138
  };
139
139
 
140
+ /** 表示行数を 2 行に固定。スクロールで続きを表示 */
141
+ export const VisibleRows2: Story = {
142
+ args: {
143
+ entries: [
144
+ ...sampleEntries,
145
+ { id: '4', level: 'info', message: 'Fetching data...', source: 'useApi.ts:18' },
146
+ { id: '5', level: 'warn', message: 'Deprecated API used', source: 'legacy.ts:3' },
147
+ ],
148
+ visibleRows: 2,
149
+ },
150
+ };
151
+
152
+ /** 表示行数を 3 行に固定 */
153
+ export const VisibleRows3: Story = {
154
+ args: {
155
+ entries: [
156
+ ...sampleEntries,
157
+ { id: '4', level: 'info', message: 'Fetching data...', source: 'useApi.ts:18' },
158
+ ],
159
+ visibleRows: 3,
160
+ },
161
+ };
162
+
163
+ /** 表示行数を任意の数(例: 5 行)に指定 */
164
+ export const VisibleRows5: Story = {
165
+ args: {
166
+ entries: [
167
+ ...sampleEntries,
168
+ { id: '4', level: 'info', message: 'Fetching data...', source: 'useApi.ts:18' },
169
+ { id: '5', level: 'warn', message: 'Deprecated API used', source: 'legacy.ts:3' },
170
+ { id: '6', level: 'error', message: 'Network request failed', source: 'api.ts:92' },
171
+ ],
172
+ visibleRows: 5,
173
+ },
174
+ };
175
+
140
176
  export const Empty: Story = {
141
177
  args: {
142
178
  entries: [],
143
179
  },
144
180
  };
145
181
 
182
+ /** 連想配列・配列はアコーディオンで展開・縮小表示 */
183
+ export const ObjectAndArrayMessages: Story = {
184
+ args: {
185
+ entries: [
186
+ { id: '1', level: 'info', message: 'Plain string message', source: 'App.tsx:1' },
187
+ {
188
+ id: '2',
189
+ level: 'info',
190
+ message: { name: 'Alice', age: 30, tags: ['admin', 'user'] },
191
+ source: 'App.tsx:2',
192
+ },
193
+ {
194
+ id: '3',
195
+ level: 'warn',
196
+ message: [{ id: 1, title: 'First' }, { id: 2, title: 'Second' }],
197
+ source: 'App.tsx:3',
198
+ },
199
+ ],
200
+ },
201
+ };
202
+
146
203
  export const NetworksTab: Story = {
147
204
  args: {
148
205
  entries: sampleEntries,
@@ -32,6 +32,8 @@ export type LogWindowProps = {
32
32
  onTabChange?: (tab: LogWindowTab) => void;
33
33
  /** 高さ(CSS 値)。未指定時は max-height: 320px */
34
34
  height?: string | number;
35
+ /** 表示する行数(2, 3 など)。指定時はボディ高さを行数に合わせる。height より優先 */
36
+ visibleRows?: number;
35
37
  className?: string;
36
38
  /** true のときヘッダーをドラッグしてウィンドウを移動できる */
37
39
  draggable?: boolean;
@@ -56,6 +58,9 @@ export type LogWindowProps = {
56
58
  };
57
59
 
58
60
  const CLOSE_ANIMATION_MS = 200;
61
+ /** 1行あたりの高さ(.logWindowEntry の min-height 28px + padding 6*2) */
62
+ const LOG_ROW_HEIGHT_PX = 40;
63
+ const LOG_PANEL_PADDING_V = 16;
59
64
 
60
65
  export function LogWindow({
61
66
  entries = [],
@@ -63,6 +68,7 @@ export function LogWindow({
63
68
  defaultTab = 'logs',
64
69
  onTabChange,
65
70
  height,
71
+ visibleRows,
66
72
  className = '',
67
73
  draggable = false,
68
74
  defaultPosition,
@@ -83,6 +89,8 @@ export function LogWindow({
83
89
  const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
84
90
  const prevEntriesLengthRef = useRef(entries.length);
85
91
  const prevNetworkEntriesLengthRef = useRef(networkEntries.length);
92
+ const logsPanelRef = useRef<HTMLDivElement>(null);
93
+ const networksPanelRef = useRef<HTMLDivElement>(null);
86
94
  const [position, setPosition] = useState<{ x: number; y: number }>(() =>
87
95
  draggable ? defaultPosition ?? { x: 100, y: 100 } : { x: 0, y: 0 }
88
96
  );
@@ -101,6 +109,16 @@ export function LogWindow({
101
109
  prevNetworkEntriesLengthRef.current = networkEntries.length;
102
110
  }, [entries.length, networkEntries.length, activeTab]);
103
111
 
112
+ useEffect(() => {
113
+ const el = logsPanelRef.current;
114
+ if (el) el.scrollTop = el.scrollHeight;
115
+ }, [entries.length]);
116
+
117
+ useEffect(() => {
118
+ const el = networksPanelRef.current;
119
+ if (el) el.scrollTop = el.scrollHeight;
120
+ }, [networkEntries.length]);
121
+
104
122
  const handleTab = (tab: LogWindowTab) => {
105
123
  setActiveTab(tab);
106
124
  if (tab === 'logs') setUnreadLogsCount(0);
@@ -162,9 +180,10 @@ export function LogWindow({
162
180
 
163
181
  const handleExportCsv = useCallback(() => {
164
182
  const header = 'level,message,source\n';
165
- const rows = entries.map((e) =>
166
- [e.level, escapeCsvCell(e.message), escapeCsvCell(e.source)].join(',')
167
- );
183
+ const rows = entries.map((e) => {
184
+ const msgStr = typeof e.message === 'string' ? e.message : JSON.stringify(e.message);
185
+ return [e.level, escapeCsvCell(msgStr), escapeCsvCell(e.source)].join(',');
186
+ });
168
187
  const csv = header + rows.join('\n');
169
188
  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
170
189
  downloadBlob(blob, `logs-${Date.now()}.csv`);
@@ -179,7 +198,12 @@ export function LogWindow({
179
198
  setExportDialogOpen(false);
180
199
  }, [entries, networkEntries]);
181
200
 
182
- const bodyStyle = height != null ? { maxHeight: typeof height === 'number' ? `${height}px` : height } : undefined;
201
+ const bodyStyle: React.CSSProperties | undefined =
202
+ visibleRows != null
203
+ ? { maxHeight: visibleRows * LOG_ROW_HEIGHT_PX + LOG_PANEL_PADDING_V }
204
+ : height != null
205
+ ? { maxHeight: typeof height === 'number' ? `${height}px` : height }
206
+ : undefined;
183
207
 
184
208
  const rootStyle: React.CSSProperties | undefined = draggable
185
209
  ? {
@@ -300,12 +324,12 @@ export function LogWindow({
300
324
  transform: activeTab === 'logs' ? 'translateX(0)' : 'translateX(-50%)',
301
325
  }}
302
326
  >
303
- <div className="logWindowBodyPanel">
327
+ <div ref={logsPanelRef} className="logWindowBodyPanel">
304
328
  {entries.map((entry) => (
305
329
  <LogEntryRow key={entry.id} entry={entry} />
306
330
  ))}
307
331
  </div>
308
- <div className="logWindowBodyPanel">
332
+ <div ref={networksPanelRef} className="logWindowBodyPanel">
309
333
  {networkEntries.length > 0 ? (
310
334
  networkEntries.map((entry) => (
311
335
  <NetworkEntryRow key={entry.id} entry={entry} />
@@ -6,6 +6,7 @@ export { NetworkEntryRow } from './NetworkEntryRow';
6
6
  export type {
7
7
  LogEntry,
8
8
  LogLevel,
9
+ LogMessage,
9
10
  LogWindowTab,
10
11
  NetworkEntry,
11
12
  NetworkEntryRest,
@@ -1,9 +1,12 @@
1
1
  export type LogLevel = 'info' | 'warn' | 'error';
2
2
 
3
+ /** ログメッセージ(文字列のほか、連想配列・配列の場合はアコーディオンで展開表示) */
4
+ export type LogMessage = string | Record<string, unknown> | unknown[];
5
+
3
6
  export type LogEntry = {
4
7
  id: string;
5
8
  level: LogLevel;
6
- message: string;
9
+ message: LogMessage;
7
10
  source: string;
8
11
  };
9
12
 
@@ -21,7 +21,7 @@ import type { RestApiContextValue, GraphqlApiContextValue } from './ApiContext';
21
21
  import { PrinterProvider } from './PrinterContext';
22
22
  import { useLogWindowShortcut } from '../hooks/useLogWindowShortcut';
23
23
  import { LogWindow } from '../components/LogWindow/LogWindow';
24
- import type { LogEntry, NetworkEntry } from '../components/LogWindow/types';
24
+ import type { LogEntry, LogMessage, NetworkEntry } from '../components/LogWindow/types';
25
25
  import type { Env } from '@yargram/core';
26
26
  import type {
27
27
  ApolloClient as ApolloClientType,
@@ -174,7 +174,10 @@ type YargramPrinterConfig = {
174
174
  };
175
175
 
176
176
  /** LogWindow 設定(Escape 5 回で表示) */
177
- type YargramLogWindowConfig = Record<string, never>;
177
+ type YargramLogWindowConfig = {
178
+ /** 表示する行数(2, 3 など)。未指定時はウィンドウのデフォルト高さ */
179
+ visibleRows?: number;
180
+ };
178
181
 
179
182
  /** 認証設定。本番のみログインを要求する場合は true、カスタム時はオブジェクト */
180
183
  type YargramAuthConfig =
@@ -246,6 +249,7 @@ function LogWindowGate({
246
249
  networkEntries,
247
250
  isLogWindowOpen,
248
251
  closeLogWindow,
252
+ logWindowConfig,
249
253
  }: {
250
254
  instanceId: string;
251
255
  defaultPosition: { x: number; y: number };
@@ -259,6 +263,7 @@ function LogWindowGate({
259
263
  networkEntries: NetworkEntry[];
260
264
  isLogWindowOpen: boolean;
261
265
  closeLogWindow: () => void;
266
+ logWindowConfig?: YargramLogWindowConfig;
262
267
  }) {
263
268
  if (!isLogWindowOpen || typeof document === 'undefined') {
264
269
  return null;
@@ -269,6 +274,7 @@ function LogWindowGate({
269
274
  key={instanceId}
270
275
  entries={logEntries}
271
276
  networkEntries={networkEntries}
277
+ visibleRows={logWindowConfig?.visibleRows}
272
278
  draggable
273
279
  animateOnOpen
274
280
  onClose={closeLogWindow}
@@ -389,17 +395,19 @@ export function YargramProvider({
389
395
 
390
396
  const wrappedPrinter = useMemo(() => {
391
397
  const base = createPrinter(env);
398
+ const toConsoleStr = (msg: LogMessage): string =>
399
+ typeof msg === 'string' ? msg : JSON.stringify(msg);
392
400
  return {
393
- info: (msg: string) => {
394
- base.info(msg);
401
+ info: (msg: LogMessage) => {
402
+ base.info(toConsoleStr(msg));
395
403
  addLogEntryRef.current({ level: 'info', message: msg, source: 'app' });
396
404
  },
397
- warn: (msg: string) => {
398
- base.warn(msg);
405
+ warn: (msg: LogMessage) => {
406
+ base.warn(toConsoleStr(msg));
399
407
  addLogEntryRef.current({ level: 'warn', message: msg, source: 'app' });
400
408
  },
401
- error: (msg: string) => {
402
- base.error(msg);
409
+ error: (msg: LogMessage) => {
410
+ base.error(toConsoleStr(msg));
403
411
  addLogEntryRef.current({ level: 'error', message: msg, source: 'app' });
404
412
  },
405
413
  };
@@ -624,6 +632,7 @@ export function YargramProvider({
624
632
  key={instanceId}
625
633
  entries={logEntries}
626
634
  networkEntries={networkEntries}
635
+ visibleRows={logWindow?.visibleRows}
627
636
  draggable
628
637
  animateOnOpen
629
638
  onClose={closeLogWindow}
@@ -657,6 +666,7 @@ export function YargramProvider({
657
666
  networkEntries={networkEntries}
658
667
  isLogWindowOpen={isLogWindowOpen}
659
668
  closeLogWindow={closeLogWindow}
669
+ logWindowConfig={logWindow}
660
670
  />
661
671
  </>
662
672
  ) : (