@opsnow-mcp/opsnow-mcp-common-ui-server 1.0.39 → 1.0.40

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.
@@ -2476,4 +2476,262 @@ export const DataGridExamples = [
2476
2476
  langCd={i18n.getLocale()}
2477
2477
  />`
2478
2478
  },
2479
+ {
2480
+ title: '셀 단위 로딩 (Partial Loading — refreshCells) + 컬럼 전체 / 실패·재시도',
2481
+ description: '"이 셀만 갱신 중" 같은 부분 로딩 예제입니다. status={GRID_STATUS.LOADING}은 그리드 전체를 가리므로 부분 로딩에 쓸 수 없습니다 — status는 DATA_EXISTS로 두고, 로딩 중인 셀 키를 useRef(Set)에 담은 뒤 ref API refreshCells({ rowNodes, columns, force: true })로 해당 셀만 다시 그립니다. 값이 그대로라 force: true가 필수입니다. rowNodes를 생략하면 그 컬럼의 모든 셀이 대상(컬럼 단위)이며, 실패도 같은 패턴(별도 Set)으로 처리해 인라인 "재시도" 링크를 띄웁니다. 인디케이터로 셀 콘텐츠를 치환하지 말고 visibility: hidden으로 남기고 겹쳐야(CellProgress) autoHeight 행 높이가 유지됩니다.',
2482
+ code_props_usage: `
2483
+ import { useCallback, useMemo, useRef, useState } from 'react'
2484
+ import Box from '@mui/material/Box'
2485
+ import { useCommonComponents, useGlobalContext } from '@opsnow-common/opsnow-finops-common-ui-loader'
2486
+ import i18n from '@opsnow-common/opsnow-finops-common-i18n'
2487
+
2488
+ // 원래 콘텐츠를 hidden 으로 남겨 행 높이를 유지하고 인디케이터를 겹친다 (모듈 최상위에 둘 것)
2489
+ const CellProgress = ({ children }) => {
2490
+ const { OpsnowCommonLoading } = useCommonComponents()
2491
+ return (
2492
+ <Box sx={{ position: 'relative', display: 'inline-flex', alignItems: 'center', minWidth: '18px', minHeight: '20px' }}>
2493
+ <Box aria-hidden sx={{ visibility: 'hidden' }}>{children}</Box>
2494
+ <Box sx={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center' }}>
2495
+ <OpsnowCommonLoading size={18} />
2496
+ </Box>
2497
+ </Box>
2498
+ )
2499
+ }
2500
+
2501
+ const formatCost = (value) => '$' + Number(value).toLocaleString()
2502
+ const fakeApi = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
2503
+
2504
+ const { OpsnowCommonDataGrid, OpsnowCommonButton, OpsnowCommonLink } = useCommonComponents()
2505
+ const { CommonConst } = useGlobalContext()
2506
+ const gridRef = useRef(null)
2507
+
2508
+ const [rowData] = useState([
2509
+ { id: 'r1', account: 'opsnow-prod', service: 'Amazon EC2', cost: 12480 },
2510
+ { id: 'r2', account: 'opsnow-dev', service: 'Amazon S3', cost: 3120 },
2511
+ { id: 'r3', account: 'opsnow-stg', service: 'Amazon RDS', cost: 8640 },
2512
+ ])
2513
+ const status = CommonConst.GRID_STATUS.DATA_EXISTS
2514
+
2515
+ // state 가 아니라 ref — state 면 그리드 전체 리렌더로 스크롤이 튄다
2516
+ const loadingCellsRef = useRef(new Set())
2517
+ const errorCellsRef = useRef(new Set())
2518
+
2519
+ const refreshCostCells = useCallback((nodes) => {
2520
+ gridRef.current?.refreshCells({
2521
+ rowNodes: nodes,
2522
+ columns: ['cost'],
2523
+ force: true, // 값이 그대로라 필수
2524
+ })
2525
+ }, [])
2526
+
2527
+ const fetchCost = useCallback(async (node) => {
2528
+ const key = node.data.id + ':cost'
2529
+ loadingCellsRef.current.add(key)
2530
+ errorCellsRef.current.delete(key)
2531
+ refreshCostCells([node])
2532
+ try {
2533
+ await fakeApi(1200)
2534
+ if (node.data.id === 'r3' && !node.data.retried) { // 데모: 첫 시도만 실패
2535
+ node.data.retried = true
2536
+ throw new Error('cost fetch failed')
2537
+ }
2538
+ node.setDataValue('cost', Math.round(node.data.cost * (0.9 + Math.random() * 0.3)))
2539
+ } catch {
2540
+ errorCellsRef.current.add(key)
2541
+ } finally {
2542
+ loadingCellsRef.current.delete(key)
2543
+ refreshCostCells([node])
2544
+ }
2545
+ }, [refreshCostCells])
2546
+
2547
+ const refreshCostColumn = useCallback(async () => {
2548
+ const nodes = []
2549
+ gridRef.current?.forEachNode((node) => nodes.push(node))
2550
+ nodes.forEach((node) => loadingCellsRef.current.add(node.data.id + ':cost'))
2551
+ gridRef.current?.refreshCells({ columns: ['cost'], force: true }) // rowNodes 생략 = 컬럼 단위
2552
+ await Promise.all(nodes.map((node) => fetchCost(node)))
2553
+ }, [fetchCost])
2554
+
2555
+ const CostCell = useCallback((params) => {
2556
+ const value = <span>{formatCost(params.value)}</span>
2557
+ const key = params.data.id + ':' + params.colDef.field
2558
+ if (loadingCellsRef.current.has(key)) return <CellProgress>{value}</CellProgress>
2559
+ if (errorCellsRef.current.has(key)) {
2560
+ // 버튼을 넣으면 행 높이가 바뀜 — 인라인 링크로
2561
+ return (
2562
+ <Box component="span" sx={(theme) => ({ display: 'inline-flex', alignItems: 'center', gap: '6px', color: theme.palette.error.main })}>
2563
+ 불러오기 실패
2564
+ <OpsnowCommonLink color="primary" sx={{ cursor: 'pointer' }} onClick={() => fetchCost(params.node)}>재시도</OpsnowCommonLink>
2565
+ </Box>
2566
+ )
2567
+ }
2568
+ return value
2569
+ }, [fetchCost])
2570
+
2571
+ const RecalcButtonCell = useCallback((params) => (
2572
+ <OpsnowCommonButton label="재계산" size="small" variant="outlined" color="secondary" onClick={() => fetchCost(params.node)} />
2573
+ ), [fetchCost])
2574
+
2575
+ // useMemo 로 고정 — 매 렌더마다 새로 만들면 그리드가 통째로 다시 그려진다
2576
+ const columnDefs = useMemo(() => [
2577
+ { headerName: '계정', field: 'account', flex: 1, minWidth: 140 },
2578
+ { headerName: '서비스', field: 'service', flex: 1, minWidth: 160 },
2579
+ { headerName: '비용', field: 'cost', flex: 1, minWidth: 200, cellRenderer: CostCell },
2580
+ { headerName: '', colId: 'action', width: 110, sortable: false, cellRenderer: RecalcButtonCell },
2581
+ ], [CostCell, RecalcButtonCell])
2582
+
2583
+ const gridOptions = useMemo(() => ({
2584
+ domLayout: 'autoHeight',
2585
+ suppressCellFocus: true,
2586
+ animateRows: true,
2587
+ }), [])
2588
+ `,
2589
+ code: `<>
2590
+ <div style={{ marginBottom: '16px' }}>
2591
+ <OpsnowCommonButton label="비용 컬럼 새로고침 (컬럼 전체)" size="large" variant="outlined" color="secondary" onClick={refreshCostColumn} />
2592
+ </div>
2593
+ <OpsnowCommonDataGrid
2594
+ ref={gridRef}
2595
+ langCd={i18n.getLocale()}
2596
+ columnDefs={columnDefs}
2597
+ rowData={rowData}
2598
+ status={status}
2599
+ gridOptions={gridOptions}
2600
+ />
2601
+ </>`
2602
+ },
2603
+ {
2604
+ title: '행 단위 로딩 (Partial Loading — rowClassRules + redrawRows) + 선택 행 일괄 로딩',
2605
+ description: '"이 행만 동기화 중" 같은 행 단위 부분 로딩 예제입니다. 로딩 중인 행 id를 useRef(Set)에 담고, gridOptions.rowClassRules로 그 행에 클래스를 붙여 dim + 클릭 차단하며, ref API redrawRows({ rowNodes })로 해당 행만 다시 그리면 rowClassRules가 재평가됩니다. 인디케이터는 컬럼 렌더러를 감싸 행 전체 컬럼(버튼 컬럼 포함)에 적용해야 로딩 중 행 높이가 유지됩니다. 여러 행(체크박스 선택)을 켤 때는 rowNodes 배열로 묶어 redrawRows를 한 번만 호출하고, 끌 때는 응답이 온 행부터 개별 해제합니다. 선택 노드는 forEachNode + node.isSelected()로 수집하고(getSelectedNodes는 ref API에 없음), onSelectionChanged는 gridOptions가 아니라 top-level prop으로 넘겨야 호출됩니다(인자 = 선택된 노드 배열).',
2606
+ code_props_usage: `
2607
+ import { useCallback, useMemo, useRef, useState } from 'react'
2608
+ import Box from '@mui/material/Box'
2609
+ import { useCommonComponents, useGlobalContext } from '@opsnow-common/opsnow-finops-common-ui-loader'
2610
+ import i18n from '@opsnow-common/opsnow-finops-common-i18n'
2611
+
2612
+ // '셀 단위 로딩' 예제와 동일
2613
+ const CellProgress = ({ children }) => {
2614
+ const { OpsnowCommonLoading } = useCommonComponents()
2615
+ return (
2616
+ <Box sx={{ position: 'relative', display: 'inline-flex', alignItems: 'center', minWidth: '18px', minHeight: '20px' }}>
2617
+ <Box aria-hidden sx={{ visibility: 'hidden' }}>{children}</Box>
2618
+ <Box sx={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center' }}>
2619
+ <OpsnowCommonLoading size={18} />
2620
+ </Box>
2621
+ </Box>
2622
+ )
2623
+ }
2624
+
2625
+ const fakeApi = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
2626
+ const nowLabel = () => new Date().toLocaleTimeString()
2627
+
2628
+ const { OpsnowCommonDataGrid, OpsnowCommonButton } = useCommonComponents()
2629
+ const { CommonConst } = useGlobalContext()
2630
+ const gridRef = useRef(null)
2631
+
2632
+ const [rowData] = useState([
2633
+ { id: 'r1', account: 'opsnow-prod', service: 'Amazon EC2', status: '동기화 완료' },
2634
+ { id: 'r2', account: 'opsnow-dev', service: 'Amazon S3', status: '동기화 완료' },
2635
+ { id: 'r3', account: 'opsnow-stg', service: 'Amazon RDS', status: '동기화 완료' },
2636
+ ])
2637
+ const status = CommonConst.GRID_STATUS.DATA_EXISTS
2638
+
2639
+ const loadingRowsRef = useRef(new Set())
2640
+ const [selectedCount, setSelectedCount] = useState(0)
2641
+ const [running, setRunning] = useState(false)
2642
+
2643
+ const setRowLoading = useCallback((node, loading) => {
2644
+ if (loading) loadingRowsRef.current.add(node.data.id)
2645
+ else loadingRowsRef.current.delete(node.data.id)
2646
+ gridRef.current?.redrawRows({ rowNodes: [node] }) // rowClassRules 재평가
2647
+ }, [])
2648
+
2649
+ const refreshRow = useCallback(async (node) => {
2650
+ setRowLoading(node, true)
2651
+ try {
2652
+ await fakeApi(1800)
2653
+ node.setDataValue('status', '동기화 ' + nowLabel())
2654
+ } finally {
2655
+ setRowLoading(node, false)
2656
+ }
2657
+ }, [setRowLoading])
2658
+
2659
+ const syncSelectedRows = useCallback(async () => {
2660
+ const nodes = []
2661
+ gridRef.current?.forEachNode((node) => { if (node.isSelected()) nodes.push(node) }) // getSelectedNodes 없음
2662
+ if (nodes.length === 0) return
2663
+ setRunning(true)
2664
+ nodes.forEach((node) => loadingRowsRef.current.add(node.data.id))
2665
+ gridRef.current?.redrawRows({ rowNodes: nodes }) // 묶어서 한 번만
2666
+ await Promise.all(nodes.map(async (node, index) => {
2667
+ await fakeApi(1000 + index * 600)
2668
+ node.setDataValue('status', '동기화 ' + nowLabel())
2669
+ loadingRowsRef.current.delete(node.data.id)
2670
+ gridRef.current?.redrawRows({ rowNodes: [node] })
2671
+ }))
2672
+ setRunning(false)
2673
+ }, [])
2674
+
2675
+ // defaultColDef 는 마운트 시점에 굳으므로 컬럼 렌더러를 감싼다
2676
+ const withRowLoading = useCallback((Renderer) => {
2677
+ const Wrapped = (params) => {
2678
+ const content = Renderer ? <Renderer {...params} /> : <span>{params.value}</span>
2679
+ return loadingRowsRef.current.has(params.data?.id) ? <CellProgress>{content}</CellProgress> : content
2680
+ }
2681
+ return Wrapped
2682
+ }, [])
2683
+
2684
+ const RefreshButtonCell = useCallback((params) => (
2685
+ <OpsnowCommonButton label="행 새로고침" size="small" variant="outlined" color="secondary" onClick={() => refreshRow(params.node)} />
2686
+ ), [refreshRow])
2687
+
2688
+ // 감싼 렌더러는 useMemo 안에서 한 번만 만든다
2689
+ const columnDefs = useMemo(() => {
2690
+ const valueCell = withRowLoading()
2691
+ return [
2692
+ { headerName: '계정', field: 'account', flex: 1, minWidth: 140, cellRenderer: valueCell },
2693
+ { headerName: '서비스', field: 'service', flex: 1, minWidth: 160, cellRenderer: valueCell },
2694
+ { headerName: '상태', field: 'status', flex: 1, minWidth: 180, cellRenderer: valueCell },
2695
+ // 버튼 컬럼도 감싸야 행 높이가 유지된다
2696
+ { headerName: '', colId: 'action', width: 140, sortable: false, cellRenderer: withRowLoading(RefreshButtonCell) },
2697
+ ]
2698
+ }, [withRowLoading, RefreshButtonCell])
2699
+
2700
+ const gridOptions = useMemo(() => ({
2701
+ domLayout: 'autoHeight',
2702
+ suppressCellFocus: true,
2703
+ animateRows: true,
2704
+ rowSelection: { mode: 'multiRow', checkboxes: true, headerCheckbox: true, enableClickSelection: false },
2705
+ rowClassRules: {
2706
+ 'row-loading': (params) => loadingRowsRef.current.has(params.data?.id),
2707
+ },
2708
+ }), [])
2709
+
2710
+ // gridOptions 가 아니라 top-level prop — 인자는 선택된 노드 배열
2711
+ const handleSelectionChanged = useCallback((selectedNodes) => {
2712
+ setSelectedCount(selectedNodes.length)
2713
+ }, [])
2714
+ `,
2715
+ code: `<Box sx={{ '& .ag-row.row-loading': { opacity: 0.45, pointerEvents: 'none' } }}>
2716
+ <div style={{ marginBottom: '16px' }}>
2717
+ <OpsnowCommonButton
2718
+ label={'선택 행 동기화 (' + selectedCount + ')'}
2719
+ size="large"
2720
+ variant="outlined"
2721
+ color="secondary"
2722
+ disabled={selectedCount === 0 || running}
2723
+ onClick={syncSelectedRows}
2724
+ />
2725
+ </div>
2726
+ <OpsnowCommonDataGrid
2727
+ ref={gridRef}
2728
+ langCd={i18n.getLocale()}
2729
+ columnDefs={columnDefs}
2730
+ rowData={rowData}
2731
+ status={status}
2732
+ gridOptions={gridOptions}
2733
+ onSelectionChanged={handleSelectionChanged}
2734
+ />
2735
+ </Box>`
2736
+ },
2479
2737
  ];
@@ -30,6 +30,7 @@ const gridOptionsSchema = z.object({
30
30
  serverSideSortAllLevels: z.boolean().optional().describe('서버사이드 전체 레벨 정렬'),
31
31
  getRowId: z.string().optional().describe('행 ID 반환 함수(stringified)'),
32
32
  suppressMenuHide: z.boolean().optional().describe('메뉴 숨김 억제'),
33
+ rowClassRules: z.record(z.string()).optional().describe('행 클래스 규칙 { 클래스명: (params) => boolean (stringified) }. redrawRows({ rowNodes }) 호출 시 재평가됨 — 행 단위 로딩(dim/클릭 차단) 표현에 사용. \'셀·행 단위 로딩\' 예제 참고'),
33
34
  });
34
35
  const columnDefsSchema = z.lazy(() => z.object({
35
36
  headerName: z.string().optional().describe('컬럼 헤더명'),
@@ -131,6 +132,8 @@ export const DataGridSchema = z.object({
131
132
  rowData: z.array(z.record(z.any())).optional().describe('그리드에 표시할 데이터'),
132
133
  status: z.number().describe(`그리드 상태값: GRID.STATUS.LOADING(0), GRID.STATUS.DATA_EXISTS(1), GRID.STATUS.ERROR(9) 중 하나를 사용해야 합니다.
133
134
 
135
+ ⚠️ LOADING은 그리드 **전체**를 가리는 로딩입니다. "이 셀만/이 행만 갱신 중" 같은 부분 로딩은 status로 표현할 수 없습니다 — status는 DATA_EXISTS로 두고, ref API(refreshCells/redrawRows) + 로딩 키 Set(useRef) 조합으로 구현합니다. '셀 단위 로딩' / '행 단위 로딩' 예제 참고.
136
+
134
137
  **필수 구현 패턴:**
135
138
  반드시 useState로 상태를 관리하고, useEffect 등을 사용하여 데이터 로딩 로직을 구현해야 합니다.
136
139
  사용자는 이 상태 제어 코드 안에 실제 비즈니스 로직(API 호출, 데이터 처리 등)을 추가할 수 있습니다.
@@ -190,7 +193,7 @@ export const DataGridSchema = z.object({
190
193
  onGridReady: z.string().optional().describe('그리드 준비 이벤트 핸들러(stringified)'),
191
194
  onFirstDataRendered: z.string().optional().describe('첫 데이터 렌더링 이벤트 핸들러(stringified)'),
192
195
  onGridSizeChanged: z.string().optional().describe('그리드 크기 변경 이벤트 핸들러(stringified)'),
193
- onSelectionChanged: z.string().optional().describe('선택 변경 이벤트 핸들러(stringified)'),
196
+ onSelectionChanged: z.string().optional().describe('선택 변경 이벤트 핸들러(stringified). 반드시 gridOptions가 아니라 이 top-level prop으로 넘겨야 호출됨 (래퍼가 AG Grid 이벤트를 내부에서 잡고 이 prop을 부름). 인자는 이벤트 객체가 아니라 선택된 노드 배열: (selectedNodes) => setCount(selectedNodes.length)'),
194
197
  onCellClick: z.string().optional().describe('셀 클릭 이벤트 핸들러(stringified)'),
195
198
  onFilterChanged: z.string().optional().describe('필터 변경 이벤트 핸들러(stringified)'),
196
199
  onUpdateFilter: z.string().optional().describe('필터 업데이트 이벤트 핸들러(stringified)'),
@@ -252,6 +255,18 @@ export function createDataGridComponent() {
252
255
  // 10행 초과 시에만 고정 높이 부여 (autoHeight 셀은 가변이라 픽셀 고정값 사용)
253
256
  <OpsnowCommonDataGrid gridOptions={gridOptions} gridHeight={isScroll ? 600 : undefined} />
254
257
 
258
+ **셀·행 단위 부분 로딩 (Partial Loading) — status로 하지 말 것**
259
+ status={GRID_STATUS.LOADING}은 그리드 전체를 가립니다. "이 셀만 재계산 중", "선택한 행만 동기화 중" 같은 부분 로딩은
260
+ status는 DATA_EXISTS로 유지하고 아래 조합으로 앱에서 구현합니다 ('셀 단위 로딩' / '행 단위 로딩' 예제 전문 참고):
261
+ 1. 로딩 중인 키는 useState가 아니라 useRef(new Set()) 에 담는다 — state로 두면 그리드 전체 리렌더로 스크롤이 튄다
262
+ 2. 셀 단위: gridRef.current.refreshCells({ rowNodes, columns, force: true }) — 값이 그대로라 force: true 필수. rowNodes 생략 시 컬럼 전체
263
+ 3. 행 단위: gridOptions.rowClassRules + gridRef.current.redrawRows({ rowNodes }) — redrawRows 시 rowClassRules 재평가. 여러 행은 rowNodes 배열로 묶어 한 번만 호출
264
+ 4. 인디케이터로 셀 콘텐츠를 치환하지 말고, 원래 콘텐츠를 visibility: hidden 으로 남기고 인디케이터를 position: absolute 로 겹친다 (autoHeight라 치환하면 행 높이가 바뀜)
265
+ 5. columnDefs/셀 렌더러는 useMemo/useCallback 으로 고정 — 매 렌더마다 새로 만들면 그리드가 통째로 다시 그려진다
266
+ 6. 선택 노드 수집: forEachNode + node.isSelected() (getSelectedNodes는 ref API에 없음). onSelectionChanged는 top-level prop (인자 = 선택 노드 배열)
267
+ ref API 요약: refreshCells / redrawRows / forEachNode / node.setDataValue(field, value) / downloadExcel / downloadCsv
268
+ rowModelType: 'serverSide' 그리드는 AG Grid 내장 loadingCellRenderer를 쓰는 것이 맞습니다.
269
+
255
270
  **import:**
256
271
  \`\`\`javascript
257
272
  import { useCommonComponents, useGlobalContext } from '@opsnow-common/opsnow-finops-common-ui-loader';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsnow-mcp/opsnow-mcp-common-ui-server",
3
- "version": "1.0.39",
3
+ "version": "1.0.40",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "bin": {