From eedcd408e353991bbb78334b584f0e25d71273f1 Mon Sep 17 00:00:00 2001 From: Sen Lin Date: Tue, 18 Nov 2025 20:11:26 -0800 Subject: [PATCH 1/8] Resolve media url for basic table --- docs/media-urls.md | 20 +++++++++++++ .../src/components/common/BasicAGTable.tsx | 13 ++++---- .../processedRowData/processedRowDataSlice.ts | 30 +++---------------- .../utils/cellRenderers/AudioCellRenderer.tsx | 11 +++++-- .../utils/cellRenderers/ImageCellRenderer.tsx | 15 ++++++++-- .../utils/cellRenderers/VideoCellRenderer.tsx | 15 ++++++++-- 6 files changed, 63 insertions(+), 41 deletions(-) diff --git a/docs/media-urls.md b/docs/media-urls.md index e11d392..025d11d 100644 --- a/docs/media-urls.md +++ b/docs/media-urls.md @@ -62,6 +62,26 @@ For **every cell in every row**: [useProcessedRowData.ts](../smoosense-gui/src/lib/hooks/useProcessedRowData.ts) - Returns the processed data with all media URLs resolved +## Query Tab Processing + +The Query tab also processes media URLs in query results: + +**Location**: `SqlQueryPanel.tsx` @ [SqlQueryPanel.tsx](../smoosense-gui/src/components/sql/SqlQueryPanel.tsx) + +**Process**: +1. Execute SQL query via `/api/query` endpoint +2. Receive results as `{column_names, rows}` +3. Transform rows using `_.zipObject(column_names, rows)` +4. **Process media URLs** using same logic as Samples tab: + - For each cell, check `needToResolveMediaUrl(value)` + - If true, call `resolveAssetUrl(value, tablePath, baseUrl)` +5. Render in `BasicAGTable` with auto-detected cell renderers + +**Media Rendering**: +- Images displayed in `ImageCellRenderer` +- Videos displayed in `VideoCellRenderer` +- Audio displayed in `AudioCellRenderer` +- Cell renderers auto-detected via `inferRenderTypeFromData()` ## Backend Integration diff --git a/smoosense-gui/src/components/common/BasicAGTable.tsx b/smoosense-gui/src/components/common/BasicAGTable.tsx index 573bc82..701036a 100644 --- a/smoosense-gui/src/components/common/BasicAGTable.tsx +++ b/smoosense-gui/src/components/common/BasicAGTable.tsx @@ -27,13 +27,12 @@ export default function BasicAGTable({ data, className = '', onGridReady, colDef const baseColumnDefs = inferColumnDefinitions(data) // Apply overrides if provided - const columnDefs = colDefOverrides - ? baseColumnDefs.map(colDef => { - const fieldName = colDef.field - const overrides = fieldName ? colDefOverrides[fieldName] : undefined - return overrides ? { ...colDef, ...overrides } : colDef - }) - : baseColumnDefs + const columnDefs = baseColumnDefs.map(colDef => { + const fieldName = colDef.field + const overrides = colDefOverrides && fieldName ? colDefOverrides[fieldName] : undefined + + return overrides ? { ...colDef, ...overrides } : colDef + }) // Construct final grid options const finalGridOptions: GridOptions = { diff --git a/smoosense-gui/src/lib/features/processedRowData/processedRowDataSlice.ts b/smoosense-gui/src/lib/features/processedRowData/processedRowDataSlice.ts index 2849f58..7a47897 100644 --- a/smoosense-gui/src/lib/features/processedRowData/processedRowDataSlice.ts +++ b/smoosense-gui/src/lib/features/processedRowData/processedRowDataSlice.ts @@ -1,6 +1,5 @@ import {type BaseAsyncDataState, createAsyncDataSlice} from '@/lib/utils/createAsyncDataSlice' -import {needToResolveMediaUrl, resolveAssetUrl} from '@/lib/utils/mediaUrlUtils' -import {isNil, mapValues} from 'lodash' +import {isNil} from 'lodash' export type ProcessedRowDataState = BaseAsyncDataState[]> @@ -10,35 +9,14 @@ interface FetchProcessedRowDataParams { // Processed row data fetch function const fetchProcessedRowDataFunction = async ( - { rawData }: FetchProcessedRowDataParams, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - dispatch: any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getState?: any + { rawData }: FetchProcessedRowDataParams ): Promise[]> => { if (!rawData || rawData.length === 0) { return [] } - // Process all cells: resolve media URLs - if (getState) { - const state = getState() - const tablePath = state.ui?.tablePath - const baseUrl = state.ui?.baseUrl - - // Only process if both tablePath and baseUrl are available - if (tablePath && baseUrl) { - // Use pure functional map to transform data without mutation - return rawData.map((row) => - mapValues(row, (value) => - needToResolveMediaUrl(value) - ? resolveAssetUrl(value as string, tablePath, baseUrl) - : value - ) - ) - } - } - + // Return raw data as-is - URL resolution now happens in cell renderers + // This preserves original URLs for proper file type detection return rawData } diff --git a/smoosense-gui/src/lib/utils/cellRenderers/AudioCellRenderer.tsx b/smoosense-gui/src/lib/utils/cellRenderers/AudioCellRenderer.tsx index 36c0ff4..ed86209 100644 --- a/smoosense-gui/src/lib/utils/cellRenderers/AudioCellRenderer.tsx +++ b/smoosense-gui/src/lib/utils/cellRenderers/AudioCellRenderer.tsx @@ -4,7 +4,7 @@ import { memo } from 'react' import dynamic from 'next/dynamic' import CellPopover from '@/components/ui/CellPopover' import { isNil } from 'lodash' -import { proxyedUrl } from '@/lib/utils/urlUtils' +import { needToResolveMediaUrl, resolveAssetUrl } from '../mediaUrlUtils' import AudioMiniMelSpectrogram from '@/components/audio/AudioMiniMelSpectrogram' import { useAppSelector } from '@/lib/hooks' @@ -29,8 +29,15 @@ const AudioCellRenderer = memo(function AudioCellRenderer({ value }: AudioCellRendererProps) { const rowHeight = useAppSelector((state) => state.ui.rowHeight) + const tablePath = useAppSelector((state) => state.ui.tablePath) + const baseUrl = useAppSelector((state) => state.ui.baseUrl) const originalUrl = String(value).trim() + // Resolve URL if needed + const resolvedUrl = (tablePath && baseUrl && needToResolveMediaUrl(value)) + ? resolveAssetUrl(originalUrl, tablePath, baseUrl) + : originalUrl + // Handle empty or invalid values if (isNil(value) || value === '' || !originalUrl) { return ( @@ -40,7 +47,7 @@ const AudioCellRenderer = memo(function AudioCellRenderer({ ) } - const audioUrl = proxyedUrl(originalUrl) + const audioUrl = resolvedUrl const cellContent = (
diff --git a/smoosense-gui/src/lib/utils/cellRenderers/ImageCellRenderer.tsx b/smoosense-gui/src/lib/utils/cellRenderers/ImageCellRenderer.tsx index b623baa..c9a4004 100644 --- a/smoosense-gui/src/lib/utils/cellRenderers/ImageCellRenderer.tsx +++ b/smoosense-gui/src/lib/utils/cellRenderers/ImageCellRenderer.tsx @@ -4,6 +4,8 @@ import { memo } from 'react' import CellPopover from '@/components/ui/CellPopover' import ImageBlock from '@/components/common/ImageBlock' import { isNil } from 'lodash' +import { needToResolveMediaUrl, resolveAssetUrl } from '../mediaUrlUtils' +import { useAppSelector } from '@/lib/hooks' interface ImageCellRendererProps { value: unknown @@ -12,8 +14,15 @@ interface ImageCellRendererProps { const ImageCellRenderer = memo(function ImageCellRenderer({ value }: ImageCellRendererProps) { + const tablePath = useAppSelector((state) => state.ui.tablePath) + const baseUrl = useAppSelector((state) => state.ui.baseUrl) const originalUrl = String(value).trim() + // Resolve URL if needed + const resolvedUrl = (tablePath && baseUrl && needToResolveMediaUrl(value)) + ? resolveAssetUrl(originalUrl, tablePath, baseUrl) + : originalUrl + // Handle empty or invalid values if (isNil(value) || value === '' || !originalUrl) { return ( @@ -25,7 +34,7 @@ const ImageCellRenderer = memo(function ImageCellRenderer({ const cellContent = ( @@ -34,7 +43,7 @@ const ImageCellRenderer = memo(function ImageCellRenderer({ const popoverContent = (
state.ui.tablePath) + const baseUrl = useAppSelector((state) => state.ui.baseUrl) const originalUrl = String(value).trim() + // Resolve URL if needed + const resolvedUrl = (tablePath && baseUrl && needToResolveMediaUrl(value)) + ? resolveAssetUrl(originalUrl, tablePath, baseUrl) + : originalUrl + // Handle empty or invalid values if (isNil(value) || value === '' || !originalUrl) { return ( @@ -28,7 +37,7 @@ const VideoCellRenderer = memo(function VideoCellRenderer({ className="relative rounded overflow-hidden bg-muted w-full h-full" >
@@ -37,7 +46,7 @@ const VideoCellRenderer = memo(function VideoCellRenderer({ const popoverContent = (
@@ -49,7 +58,7 @@ const VideoCellRenderer = memo(function VideoCellRenderer({ cellContent={cellContent} cellContentClassName="items-center justify-center" popoverContent={popoverContent} - url={originalUrl} + url={resolvedUrl} copyValue={originalUrl} /> ) From 5264747b47448c2a776cf3094efe1d5d0fc7cdcd Mon Sep 17 00:00:00 2001 From: Sen Lin Date: Tue, 18 Nov 2025 20:23:33 -0800 Subject: [PATCH 2/8] Removed processedRowData slice --- .../src/components/gallery/Gallery.tsx | 5 +- .../src/components/table/MainTable.tsx | 5 +- .../src/components/table/RowDetails.tsx | 7 +- .../__tests__/processedRowDataSlice.test.ts | 184 ------------------ .../processedRowData/processedRowDataSlice.ts | 39 ---- .../__tests__/useProcessedRowData.test.tsx | 182 ----------------- .../src/lib/hooks/useProcessedRowData.ts | 33 ---- smoosense-gui/src/lib/store.ts | 2 - smoosense-gui/src/lib/test-utils.ts | 8 - 9 files changed, 7 insertions(+), 458 deletions(-) delete mode 100644 smoosense-gui/src/lib/features/processedRowData/__tests__/processedRowDataSlice.test.ts delete mode 100644 smoosense-gui/src/lib/features/processedRowData/processedRowDataSlice.ts delete mode 100644 smoosense-gui/src/lib/hooks/__tests__/useProcessedRowData.test.tsx delete mode 100644 smoosense-gui/src/lib/hooks/useProcessedRowData.ts diff --git a/smoosense-gui/src/components/gallery/Gallery.tsx b/smoosense-gui/src/components/gallery/Gallery.tsx index 71e73ab..e6f03f8 100644 --- a/smoosense-gui/src/components/gallery/Gallery.tsx +++ b/smoosense-gui/src/components/gallery/Gallery.tsx @@ -1,8 +1,7 @@ 'use client' import { useAppSelector, useAppDispatch } from '@/lib/hooks' -import { useRenderType } from '@/lib/hooks' -import { useProcessedRowData } from '@/lib/hooks/useProcessedRowData' +import { useRenderType, useRowData } from '@/lib/hooks' import { setJustClickedRowId } from '@/lib/features/viewing/viewingSlice' import { handPickRow } from '@/lib/features/handPickedRows/handPickedRowsSlice' import { isVisualType } from '@/lib/utils/renderTypeUtils' @@ -12,7 +11,7 @@ import GalleryItem from './GalleryItem' export default function Gallery() { const dispatch = useAppDispatch() - const { data: rowData } = useProcessedRowData() + const { data: rowData } = useRowData() const renderTypeColumns = useRenderType() const columnForGalleryVisual = useAppSelector((state) => state.ui.columnForGalleryVisual) const columnForGalleryCaption = useAppSelector((state) => state.ui.columnForGalleryCaption) diff --git a/smoosense-gui/src/components/table/MainTable.tsx b/smoosense-gui/src/components/table/MainTable.tsx index c7b2e6e..d66108e 100644 --- a/smoosense-gui/src/components/table/MainTable.tsx +++ b/smoosense-gui/src/components/table/MainTable.tsx @@ -4,9 +4,8 @@ import { useRef, useCallback, useImperativeHandle, forwardRef, useMemo, memo, us import { shallowEqual } from 'react-redux' import { AgGridReact } from 'ag-grid-react' import { GridReadyEvent, GridApi, ColumnResizedEvent, ColumnVisibleEvent, RowClickedEvent, SortChangedEvent } from 'ag-grid-community' -import { useAGGridTheme, useAg, useRenderType } from '@/lib/hooks' +import { useAGGridTheme, useAg, useRenderType, useRowData } from '@/lib/hooks' import { useAppSelector, useAppDispatch } from '@/lib/hooks' -import { useProcessedRowData } from '@/lib/hooks/useProcessedRowData' import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community' import { expandColDef, RenderType } from '@/lib/utils/agGridCellRenderers' import { AlertCircle } from 'lucide-react' @@ -52,7 +51,7 @@ const MainTable = memo(forwardRef((_props, ref) => { const gridApiRef = useRef(null) // Always call hooks - but handle null tablePath gracefully in the hooks - const { data, error: dataError } = useProcessedRowData() + const { data, error: dataError } = useRowData() const { ag: baseColumnDefs } = useAg() const renderTypeColumns = useRenderType() diff --git a/smoosense-gui/src/components/table/RowDetails.tsx b/smoosense-gui/src/components/table/RowDetails.tsx index 127ca01..a6a9d55 100644 --- a/smoosense-gui/src/components/table/RowDetails.tsx +++ b/smoosense-gui/src/components/table/RowDetails.tsx @@ -7,8 +7,7 @@ import JsonBox from '@/components/ui/JsonBox' import { RenderType } from '@/lib/utils/agGridCellRenderers' import { List, X } from 'lucide-react' import { isNil } from 'lodash' -import { useRenderType } from '@/lib/hooks' -import { useProcessedRowData } from '@/lib/hooks/useProcessedRowData' +import { useRenderType, useRowData } from '@/lib/hooks' import AutoLink from '@/components/common/AutoLink' import { setShowRowDetailsPanel } from '@/lib/features/ui/uiSlice' import RichAudioPlayer from '@/components/audio/RichAudioPlayer' @@ -117,9 +116,9 @@ function renderValueByType(value: unknown, renderType: RenderType): React.ReactN } export default function RowDetails() { - // Get data from Redux store (using processed data that includes derived columns) + // Get data from Redux store const justClickedRowId = useAppSelector((state) => state.viewing.justClickedRowId) - const { data: rowData } = useProcessedRowData() + const { data: rowData } = useRowData() const columnDefs = useAppSelector((state) => state.ag.columnDefs) const renderTypeColumns = useRenderType() diff --git a/smoosense-gui/src/lib/features/processedRowData/__tests__/processedRowDataSlice.test.ts b/smoosense-gui/src/lib/features/processedRowData/__tests__/processedRowDataSlice.test.ts deleted file mode 100644 index bacbc45..0000000 --- a/smoosense-gui/src/lib/features/processedRowData/__tests__/processedRowDataSlice.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { configureStore } from '@reduxjs/toolkit' -import processedRowDataReducer, { fetchProcessedRowData, type ProcessedRowDataState } from '../processedRowDataSlice' -import { resolveAssetUrl } from '@/lib/utils/mediaUrlUtils' - -// Mock the dependencies -jest.mock('@/lib/utils/urlUtils', () => ({ - proxyAllUrlsInRowData: jest.fn(({ rowData }) => Promise.resolve(rowData)), - API_PREFIX: './api' -})) - -jest.mock('@/lib/utils/derivedColumnUtils', () => ({ - evaluateAllExpressionsForAllRows: jest.fn(({ rowData }) => Promise.resolve(rowData)) -})) - -interface TestStore { - processedRowData: ProcessedRowDataState - derivedColumns: { columns: unknown[] } -} - -describe('processedRowDataSlice', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let store: any - - beforeEach(() => { - store = configureStore({ - reducer: { - processedRowData: processedRowDataReducer, - derivedColumns: () => ({ columns: [] }) - } - }) - }) - - it('should handle initial state', () => { - const state = (store.getState() as TestStore).processedRowData - expect(state.data).toBeNull() - expect(state.loading).toBe(false) - expect(state.error).toBeNull() - expect(state.needRefresh).toBe(false) - }) - - it('should handle fetch with empty data', async () => { - const result = await store.dispatch(fetchProcessedRowData({ rawData: [] })) - - // Should not dispatch due to shouldWait condition - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((result as any).meta.requestStatus).toBe('fulfilled') - }) - - it('should handle fetch with valid data', async () => { - const testData = [{ id: 1, name: 'test' }] - - const result = await store.dispatch(fetchProcessedRowData({ rawData: testData })) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((result as any).meta.requestStatus).toBe('fulfilled') - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((result as any).payload).toEqual(testData) - - const state = (store.getState() as TestStore).processedRowData - expect(state.data).toEqual(testData) - expect(state.loading).toBe(false) - expect(state.error).toBeNull() - }) -}) - -describe('resolveAssetUrl', () => { - const baseUrl = 'http://localhost:8001' - const tablePath = '/data/folder/file.parquet' - - describe('relative URLs starting with ./', () => { - it('should resolve relative URL with tablePath', () => { - const url = './images/photo.jpg' - const expected = 'api/get-file?path=%2Fdata%2Ffolder%2Fimages%2Fphoto.jpg&redirect=false' - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(baseUrl + '/' + expected) - }) - - it('should resolve relative URL with nested path', () => { - const url = './subfolder/nested/file.txt' - const expected = 'api/get-file?path=%2Fdata%2Ffolder%2Fsubfolder%2Fnested%2Ffile.txt&redirect=false' - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(baseUrl + '/' + expected) - }) - - it('should resolve relative URL with parent directory', () => { - const url = './../other/file.txt' - const expected = 'api/get-file?path=%2Fdata%2Ffolder%2F..%2Fother%2Ffile.txt&redirect=false' - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(baseUrl + '/' + expected) - }) - }) - - describe('absolute file paths starting with /', () => { - it('should convert absolute path to API URL with baseUrl', () => { - const url = '/data/images/photo.jpg' - const expected = 'api/get-file?path=%2Fdata%2Fimages%2Fphoto.jpg&redirect=false' - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(baseUrl + '/' + expected) - }) - - it('should handle paths with spaces', () => { - const url = '/data/path with spaces/file.txt' - const expected = 'api/get-file?path=%2Fdata%2Fpath+with+spaces%2Ffile.txt&redirect=false' - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(baseUrl + '/' + expected) - }) - - it('should handle paths with special characters', () => { - const url = '/data/file@#$%.txt' - const expected = 'api/get-file?path=%2Fdata%2Ffile%40%23%24%25.txt&redirect=false' - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(baseUrl + '/' + expected) - }) - }) - - describe('home directory paths starting with ~/', () => { - it('should convert home path to API URL with baseUrl', () => { - const url = '~/Documents/file.txt' - const expected = 'api/get-file?path=%7E%2FDocuments%2Ffile.txt&redirect=false' - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(baseUrl + '/' + expected) - }) - - it('should handle home path with nested folders', () => { - const url = '~/folder/subfolder/file.txt' - const expected = 'api/get-file?path=%7E%2Ffolder%2Fsubfolder%2Ffile.txt&redirect=false' - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(baseUrl + '/' + expected) - }) - }) - - describe('S3 URLs', () => { - it('should proxy S3 URL through s3-proxy endpoint', () => { - const url = 's3://bucket/folder/file.txt' - const expected = 'api/s3-proxy?url=' + encodeURIComponent(url) - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(baseUrl + '/' + expected) - }) - - it('should proxy S3 URL with special characters', () => { - const url = 's3://my-bucket/path with spaces/file@#.jpg' - const expected = 'api/s3-proxy?url=' + encodeURIComponent(url) - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(baseUrl + '/' + expected) - }) - - it('should proxy S3 URL with nested paths', () => { - const url = 's3://bucket/folder/subfolder/image.png' - const expected = 'api/s3-proxy?url=' + encodeURIComponent(url) - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(baseUrl + '/' + expected) - }) - }) - - describe('absolute URLs', () => { - it('should return HTTP URL unchanged', () => { - const url = 'http://example.com/image.jpg' - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(url) - }) - - it('should return HTTPS URL unchanged', () => { - const url = 'https://example.com/image.jpg' - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(url) - }) - - it('should return other protocol URLs unchanged', () => { - const url = 'ftp://server/file.txt' - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(url) - }) - - it('should return data URLs unchanged', () => { - const url = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(url) - }) - }) - - describe('edge cases', () => { - it('should handle root path /', () => { - const url = '/' - const expected = 'api/get-file?path=%2F&redirect=false' - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(baseUrl + '/' + expected) - }) - - it('should handle single file in root', () => { - const url = '/file.txt' - const expected = 'api/get-file?path=%2Ffile.txt&redirect=false' - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(baseUrl + '/' + expected) - }) - - it('should handle URL with query parameters (absolute URL)', () => { - const url = 'https://example.com/image.jpg?size=large' - expect(resolveAssetUrl(url, tablePath, baseUrl)).toBe(url) - }) - }) -}) \ No newline at end of file diff --git a/smoosense-gui/src/lib/features/processedRowData/processedRowDataSlice.ts b/smoosense-gui/src/lib/features/processedRowData/processedRowDataSlice.ts deleted file mode 100644 index 7a47897..0000000 --- a/smoosense-gui/src/lib/features/processedRowData/processedRowDataSlice.ts +++ /dev/null @@ -1,39 +0,0 @@ -import {type BaseAsyncDataState, createAsyncDataSlice} from '@/lib/utils/createAsyncDataSlice' -import {isNil} from 'lodash' - -export type ProcessedRowDataState = BaseAsyncDataState[]> - -interface FetchProcessedRowDataParams { - rawData: Record[] -} - -// Processed row data fetch function -const fetchProcessedRowDataFunction = async ( - { rawData }: FetchProcessedRowDataParams -): Promise[]> => { - if (!rawData || rawData.length === 0) { - return [] - } - - // Return raw data as-is - URL resolution now happens in cell renderers - // This preserves original URLs for proper file type detection - return rawData -} - -// Should wait condition - check if rawData is provided -const processedRowDataShouldWait = ({ rawData }: FetchProcessedRowDataParams) => { - return !isNil(rawData) -} - -// Create the slice using the factory -const sliceResult = createAsyncDataSlice[], FetchProcessedRowDataParams>({ - name: 'processedRowData', - fetchFunction: fetchProcessedRowDataFunction, - shouldWait: processedRowDataShouldWait, - errorMessage: 'Failed to process row data' -}) - -export const processedRowDataSlice = sliceResult.slice -export const fetchProcessedRowData = sliceResult.fetchThunk -export const { clearProcessedRowData, setProcessedRowDataError, setNeedRefresh } = sliceResult.actions -export default sliceResult.reducer \ No newline at end of file diff --git a/smoosense-gui/src/lib/hooks/__tests__/useProcessedRowData.test.tsx b/smoosense-gui/src/lib/hooks/__tests__/useProcessedRowData.test.tsx deleted file mode 100644 index 91b753e..0000000 --- a/smoosense-gui/src/lib/hooks/__tests__/useProcessedRowData.test.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import React from 'react' -import { renderHook } from '@testing-library/react' -import { Provider } from 'react-redux' -import { configureStore } from '@reduxjs/toolkit' -import { useProcessedRowData } from '../useProcessedRowData' -import { useRowData } from '../useRowData' - -// Mock the dependencies -jest.mock('../useRowData') -jest.mock('../useAsyncData') - -const mockUseRowData = useRowData as jest.MockedFunction - -// Mock useAsyncData -jest.mock('../useAsyncData', () => ({ - useAsyncData: jest.fn() -})) - -import { useAsyncData } from '../useAsyncData' -const mockUseAsyncData = useAsyncData as jest.MockedFunction - -// Create a minimal store for testing -const createTestStore = () => { - return configureStore({ - reducer: { - processedRowData: (state = { data: null, loading: false, error: null, needRefresh: false }) => state, - }, - }) -} - -const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} -) - -describe('useProcessedRowData', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - it('should return empty array when rowData.data is empty list', () => { - // Mock useRowData to return empty array - mockUseRowData.mockReturnValue({ - data: [], - loading: false, - error: null, - setNeedRefresh: jest.fn() - }) - - // Mock useAsyncData to return empty array when given empty rawData - mockUseAsyncData.mockReturnValue({ - data: [], - loading: false, - error: null, - setNeedRefresh: jest.fn() - }) - - const { result } = renderHook(() => useProcessedRowData(), { wrapper }) - - expect(result.current.data).toEqual([]) - expect(result.current.loading).toBe(false) - expect(result.current.error).toBe(null) - }) - - it('should call useAsyncData with correct params when rawData is empty array', () => { - // Mock useRowData to return empty array - mockUseRowData.mockReturnValue({ - data: [], - loading: false, - error: null, - setNeedRefresh: jest.fn() - }) - - // Mock useAsyncData - mockUseAsyncData.mockReturnValue({ - data: [], - loading: false, - error: null, - setNeedRefresh: jest.fn() - }) - - renderHook(() => useProcessedRowData(), { wrapper }) - - // Verify useAsyncData was called - expect(mockUseAsyncData).toHaveBeenCalledWith({ - stateSelector: expect.any(Function), - fetchAction: expect.any(Function), - setNeedRefreshAction: expect.any(Function), - buildParams: expect.any(Function), - dependencies: [[]] - }) - - // Verify buildParams returns correct value for empty array - const call = mockUseAsyncData.mock.calls[0][0] - const buildParams = call.buildParams - const params = buildParams() - - expect(params).toEqual({ - rawData: [] - }) - }) - - it('should handle loading state correctly', () => { - // Mock useRowData to return empty array while loading (simulating data being fetched) - mockUseRowData.mockReturnValue({ - data: [], // useRowData never returns null, always returns array - loading: true, - error: null, - setNeedRefresh: jest.fn() - }) - - // Mock useAsyncData to return loading state - mockUseAsyncData.mockReturnValue({ - data: [], - loading: true, - error: null, - setNeedRefresh: jest.fn() - }) - - const { result } = renderHook(() => useProcessedRowData(), { wrapper }) - - // Verify the final result during loading - expect(result.current.data).toEqual([]) - expect(result.current.loading).toBe(true) // Should show loading from both sources - }) - - it('should fallback to empty array when processedData is null', () => { - // Mock useRowData to return some data - mockUseRowData.mockReturnValue({ - data: [{ id: 1 }], - loading: false, - error: null, - setNeedRefresh: jest.fn() - }) - - // Mock useAsyncData to return null data - mockUseAsyncData.mockReturnValue({ - data: null, - loading: false, - error: null, - setNeedRefresh: jest.fn() - }) - - const { result } = renderHook(() => useProcessedRowData(), { wrapper }) - - // Should fallback to empty array due to (processedData || []) - expect(result.current.data).toEqual([]) - }) - - it('should process empty array and return empty array', () => { - // This is the key test case - empty filter results should be processed - mockUseRowData.mockReturnValue({ - data: [], // Empty array from filters - loading: false, - error: null, - setNeedRefresh: jest.fn() - }) - - // Mock useAsyncData to simulate processing empty array and returning empty array - mockUseAsyncData.mockReturnValue({ - data: [], // Processed empty array should still be empty array - loading: false, - error: null, - setNeedRefresh: jest.fn() - }) - - const { result } = renderHook(() => useProcessedRowData(), { wrapper }) - - // Verify that buildParams is called with empty array (not skipped) - const call = mockUseAsyncData.mock.calls[0][0] - const buildParams = call.buildParams - const params = buildParams() - - expect(params).toEqual({ - rawData: [] - }) - - // Final result should be empty array - expect(result.current.data).toEqual([]) - expect(result.current.loading).toBe(false) - expect(result.current.error).toBe(null) - }) -}) \ No newline at end of file diff --git a/smoosense-gui/src/lib/hooks/useProcessedRowData.ts b/smoosense-gui/src/lib/hooks/useProcessedRowData.ts deleted file mode 100644 index 7fc9e0d..0000000 --- a/smoosense-gui/src/lib/hooks/useProcessedRowData.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useRowData } from './useRowData' -import { useAsyncData } from './useAsyncData' -import { fetchProcessedRowData, setNeedRefresh as setNeedRefreshAction } from '@/lib/features/processedRowData/processedRowDataSlice' - -interface UseProcessedRowDataResult { - data: Record[] - loading: boolean - error: string | null - setNeedRefresh: (needRefresh: boolean) => void -} - -export function useProcessedRowData(): UseProcessedRowDataResult { - // Get raw row data first - const { data: rawData, loading: rawDataLoading, error: rawDataError } = useRowData() - - // Use the async data pattern for processed row data - const { data: processedData, loading: processedLoading, error: processedError, setNeedRefresh } = useAsyncData({ - stateSelector: (state) => state.processedRowData, - fetchAction: fetchProcessedRowData, - setNeedRefreshAction: setNeedRefreshAction, - buildParams: () => { - return { rawData } - }, - dependencies: [rawData] - }) - - return { - data: (processedData || []) as Record[], - loading: rawDataLoading || processedLoading, - error: rawDataError || processedError, - setNeedRefresh - } -} \ No newline at end of file diff --git a/smoosense-gui/src/lib/store.ts b/smoosense-gui/src/lib/store.ts index 88989cf..1e15d8f 100644 --- a/smoosense-gui/src/lib/store.ts +++ b/smoosense-gui/src/lib/store.ts @@ -3,7 +3,6 @@ import uiReducer from './features/ui/uiSlice' import columnMetaReducer from './features/columnMeta/columnMetaSlice' import sqlHistoryReducer from './features/sqlHistory/sqlHistorySlice' import rowDataReducer from './features/rowData/rowDataSlice' -import processedRowDataReducer from './features/processedRowData/processedRowDataSlice' import viewingReducer from './features/viewing/viewingSlice' import agReducer from './features/colDefs/agSlice' import columnsReducer from './features/columns/columnsSlice' @@ -23,7 +22,6 @@ const storeInstance = configureStore({ columnMeta: columnMetaReducer, sqlHistory: sqlHistoryReducer, rowData: rowDataReducer, - processedRowData: processedRowDataReducer, viewing: viewingReducer, ag: agReducer, columns: columnsReducer, diff --git a/smoosense-gui/src/lib/test-utils.ts b/smoosense-gui/src/lib/test-utils.ts index 4ca2651..ca822f7 100644 --- a/smoosense-gui/src/lib/test-utils.ts +++ b/smoosense-gui/src/lib/test-utils.ts @@ -3,7 +3,6 @@ import uiReducer from '@/lib/features/ui/uiSlice' import columnMetaReducer from '@/lib/features/columnMeta/columnMetaSlice' import sqlHistoryReducer from '@/lib/features/sqlHistory/sqlHistorySlice' import rowDataReducer from '@/lib/features/rowData/rowDataSlice' -import processedRowDataReducer from '@/lib/features/processedRowData/processedRowDataSlice' import viewingReducer from '@/lib/features/viewing/viewingSlice' import agReducer from '@/lib/features/colDefs/agSlice' import columnsReducer from '@/lib/features/columns/columnsSlice' @@ -83,12 +82,6 @@ export function createDefaultTestState(): RootState { error: null, needRefresh: false, }, - processedRowData: { - data: null, - loading: false, - error: null, - needRefresh: false, - }, viewing: { pageSize: 10, pageNumber: 1, @@ -222,7 +215,6 @@ export function createTestStore(stateOverrides?: DeepPartial) { columnMeta: columnMetaReducer, sqlHistory: sqlHistoryReducer, rowData: rowDataReducer, - processedRowData: processedRowDataReducer, viewing: viewingReducer, ag: agReducer, columns: columnsReducer, From 5098c623473aeea3e05b99cef10676e323d3e3ce Mon Sep 17 00:00:00 2001 From: Sen Lin Date: Tue, 18 Nov 2025 20:29:35 -0800 Subject: [PATCH 3/8] resolve media in gallery and row details --- .../components/gallery/GalleryItemVisual.tsx | 23 +++++++--- .../src/components/table/RowDetails.tsx | 44 +++++++++++++------ 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/smoosense-gui/src/components/gallery/GalleryItemVisual.tsx b/smoosense-gui/src/components/gallery/GalleryItemVisual.tsx index 778cf6d..c2f2b48 100644 --- a/smoosense-gui/src/components/gallery/GalleryItemVisual.tsx +++ b/smoosense-gui/src/components/gallery/GalleryItemVisual.tsx @@ -1,7 +1,7 @@ 'use client' import { RenderType } from '@/lib/utils/agGridCellRenderers' -import { proxyedUrl } from '@/lib/utils/urlUtils' +import { needToResolveMediaUrl, resolveAssetUrl } from '@/lib/utils/mediaUrlUtils' import { parseBbox, buildBboxVizUrl } from '@/lib/utils/bboxUtils' import ImageBlock from '@/components/common/ImageBlock' import ImageMask from '@/components/viz/ImageMask' @@ -24,8 +24,17 @@ export default function GalleryItemVisual({ index, galleryItemHeight }: GalleryItemVisualProps) { + const tablePath = useAppSelector((state) => state.ui.tablePath) const baseUrl = useAppSelector((state) => state.ui.baseUrl) + // Helper function to resolve media URL + const resolveUrl = (value: unknown): string => { + const originalUrl = String(value).trim() + return (tablePath && baseUrl && needToResolveMediaUrl(value)) + ? resolveAssetUrl(originalUrl, tablePath, baseUrl) + : originalUrl + } + return (
{renderType === RenderType.ImageUrl && ( @@ -41,19 +50,19 @@ export default function GalleryItemVisual({ {renderType === RenderType.ImageMask && ( )} {renderType === RenderType.VideoUrl && ( - + )} {renderType === RenderType.AudioUrl && (
- +
)} @@ -87,7 +96,7 @@ export default function GalleryItemVisual({ return (