diff --git a/src/dashboard/Data/CustomDashboard/AddElementDialog.react.js b/src/dashboard/Data/CustomDashboard/AddElementDialog.react.js index 141ff2b4c..8300c20ca 100644 --- a/src/dashboard/Data/CustomDashboard/AddElementDialog.react.js +++ b/src/dashboard/Data/CustomDashboard/AddElementDialog.react.js @@ -30,6 +30,12 @@ const elementTypes = [ title: 'Data Table', description: 'Show filtered data in a table format', }, + { + type: 'view', + icon: 'visibility', + title: 'View', + description: 'Display data from a saved View', + }, ]; const AddElementDialog = ({ onClose, onSelectType }) => { diff --git a/src/dashboard/Data/CustomDashboard/CustomDashboard.react.js b/src/dashboard/Data/CustomDashboard/CustomDashboard.react.js index 3cf605ba8..7f714dc24 100644 --- a/src/dashboard/Data/CustomDashboard/CustomDashboard.react.js +++ b/src/dashboard/Data/CustomDashboard/CustomDashboard.react.js @@ -21,7 +21,10 @@ import GraphElement from './elements/GraphElement.react'; import GraphConfigDialog from './elements/GraphConfigDialog.react'; import DataTableElement from './elements/DataTableElement.react'; import DataTableConfigDialog from './elements/DataTableConfigDialog.react'; +import ViewElement from './elements/ViewElement.react'; +import ViewConfigDialog from './elements/ViewConfigDialog.react'; import GraphPreferencesManager from 'lib/GraphPreferencesManager'; +import ViewPreferencesManager from 'lib/ViewPreferencesManager'; import FilterPreferencesManager from 'lib/FilterPreferencesManager'; import CanvasPreferencesManager from 'lib/CanvasPreferencesManager'; import { CurrentApp } from 'context/currentApp'; @@ -52,11 +55,13 @@ class CustomDashboard extends DashboardView { showStaticTextDialog: false, showGraphDialog: false, showDataTableDialog: false, + showViewDialog: false, showSaveDialog: false, showLoadDialog: false, editingElement: null, availableGraphs: {}, availableFilters: {}, + availableViews: [], classes: [], classSchemas: {}, autoReloadInterval: 0, @@ -139,9 +144,9 @@ class CustomDashboard extends DashboardView { currentCanvasGroup: canvas.group || null, hasUnsavedChanges: false, }, () => { - // Fetch data for all graph and data table elements + // Fetch data for all graph, data table, and view elements canvas.elements?.forEach(element => { - if (element.type === 'graph' || element.type === 'dataTable') { + if (element.type === 'graph' || element.type === 'dataTable' || element.type === 'view') { this.fetchElementData(element.id); } }); @@ -209,6 +214,7 @@ class CustomDashboard extends DashboardView { this.setState({ classes, classSchemas }, () => { this.loadAvailableGraphs(); this.loadAvailableFilters(); + this.loadAvailableViews(); }); } @@ -262,6 +268,22 @@ class CustomDashboard extends DashboardView { this.setState({ availableFilters: filtersByClass }); } + async loadAvailableViews() { + if (!this.context || !this.context.applicationId) { + return; + } + + const viewPreferencesManager = new ViewPreferencesManager(this.context); + + try { + const views = await viewPreferencesManager.getViews(this.context.applicationId); + this.setState({ availableViews: views || [] }); + } catch (e) { + console.error('Error loading views:', e); + this.setState({ availableViews: [] }); + } + } + handleAddElement = (type) => { this.setState({ showAddDialog: false }); switch (type) { @@ -274,6 +296,9 @@ class CustomDashboard extends DashboardView { case 'dataTable': this.setState({ showDataTableDialog: true, editingElement: null }); break; + case 'view': + this.setState({ showViewDialog: true, editingElement: null }); + break; } }; @@ -376,6 +401,41 @@ class CustomDashboard extends DashboardView { } }; + handleSaveView = (config) => { + const { editingElement, elements } = this.state; + + if (editingElement) { + const updatedElements = elements.map(el => + el.id === editingElement.id ? { ...el, config } : el + ); + this.setState({ + elements: updatedElements, + showViewDialog: false, + editingElement: null, + }, () => { + this.fetchElementData(editingElement.id); + this.markUnsavedChanges(); + }); + } else { + const newElement = { + id: generateId(), + type: 'view', + x: 50, + y: 50, + width: 500, + height: 300, + config, + }; + this.setState({ + elements: [...elements, newElement], + showViewDialog: false, + }, () => { + this.fetchElementData(newElement.id); + this.markUnsavedChanges(); + }); + } + }; + async fetchElementData(elementId) { const element = this.state.elements.find(el => el.id === elementId); if (!element) { @@ -403,51 +463,71 @@ class CustomDashboard extends DashboardView { })); try { - const { className, filterConfig, sortField, sortOrder, limit } = config; - const query = new Parse.Query(className); - - if (filterConfig && Array.isArray(filterConfig)) { - filterConfig.forEach(savedFilter => { - // Saved filters have structure: { id, name, filter: '[{field, constraint, compareTo}]' } - // The 'filter' property contains a JSON string array of filter conditions - if (savedFilter.filter) { - try { - const conditions = typeof savedFilter.filter === 'string' - ? JSON.parse(savedFilter.filter) - : savedFilter.filter; - if (Array.isArray(conditions)) { - conditions.forEach(condition => { - this.applyFilterToQuery(query, condition); - }); + let data; + + if (type === 'view') { + // Handle View element - uses aggregation pipeline or cloud function + const { cloudFunction, query: viewQuery, className } = config; + + if (cloudFunction) { + // Cloud Function view + const results = await Parse.Cloud.run(cloudFunction, {}, { useMasterKey: true }); + data = this.normalizeViewResults(results); + } else if (viewQuery && Array.isArray(viewQuery) && className) { + // Aggregation pipeline view + const results = await new Parse.Query(className).aggregate(viewQuery, { useMasterKey: true }); + data = this.normalizeViewResults(results); + } else { + throw new Error('Invalid view configuration'); + } + } else { + // Handle DataTable and Graph elements + const { className, filterConfig, sortField, sortOrder, limit } = config; + const query = new Parse.Query(className); + + if (filterConfig && Array.isArray(filterConfig)) { + filterConfig.forEach(savedFilter => { + // Saved filters have structure: { id, name, filter: '[{field, constraint, compareTo}]' } + // The 'filter' property contains a JSON string array of filter conditions + if (savedFilter.filter) { + try { + const conditions = typeof savedFilter.filter === 'string' + ? JSON.parse(savedFilter.filter) + : savedFilter.filter; + if (Array.isArray(conditions)) { + conditions.forEach(condition => { + this.applyFilterToQuery(query, condition); + }); + } + } catch (e) { + console.error('Error parsing filter conditions:', e); } - } catch (e) { - console.error('Error parsing filter conditions:', e); } - } - }); - } + }); + } - if (sortField) { - if (sortOrder === 'descending') { - query.descending(sortField); - } else { - query.ascending(sortField); + if (sortField) { + if (sortOrder === 'descending') { + query.descending(sortField); + } else { + query.ascending(sortField); + } } - } - if (limit != null) { - const numericLimit = Number(limit); - if (Number.isFinite(numericLimit) && numericLimit >= 0) { - query.limit(numericLimit); + if (limit != null) { + const numericLimit = Number(limit); + if (Number.isFinite(numericLimit) && numericLimit >= 0) { + query.limit(numericLimit); + } else { + query.limit(1000); + } } else { query.limit(1000); } - } else { - query.limit(1000); - } - const results = await query.find({ useMasterKey: true }); - const data = results.map(obj => obj.toJSON()); + const results = await query.find({ useMasterKey: true }); + data = results.map(obj => obj.toJSON()); + } // Check if component is still mounted and this is the latest request if (!this._isMounted || localToken !== this._elementSeq[elementId]) { @@ -539,6 +619,38 @@ class CustomDashboard extends DashboardView { } } + normalizeViewResults(results) { + // Normalize Parse.Object instances to raw JSON for consistent rendering + const normalizeValue = val => { + if (val && typeof val === 'object' && val instanceof Parse.Object) { + return { + __type: 'Pointer', + className: val.className, + objectId: val.id + }; + } + if (val && typeof val === 'object' && !Array.isArray(val)) { + const normalized = {}; + Object.keys(val).forEach(key => { + normalized[key] = normalizeValue(val[key]); + }); + return normalized; + } + if (Array.isArray(val)) { + return val.map(normalizeValue); + } + return val; + }; + + return results.map(item => { + const normalized = {}; + Object.keys(item).forEach(key => { + normalized[key] = normalizeValue(item[key]); + }); + return normalized; + }); + } + handleSelectElement = (id) => { this.setState({ selectedElement: id }); }; @@ -600,6 +712,9 @@ class CustomDashboard extends DashboardView { case 'dataTable': this.setState({ showDataTableDialog: true }); break; + case 'view': + this.setState({ showViewDialog: true }); + break; } }; @@ -610,7 +725,7 @@ class CustomDashboard extends DashboardView { handleReloadAll = () => { const { elements } = this.state; elements.forEach(element => { - if (element.type === 'graph' || element.type === 'dataTable') { + if (element.type === 'graph' || element.type === 'dataTable' || element.type === 'view') { this.fetchElementData(element.id); } }); @@ -719,9 +834,9 @@ class CustomDashboard extends DashboardView { // Update URL to include canvas ID this.navigateToCanvas(canvas.id); - // Fetch data for all graph and data table elements + // Fetch data for all graph, data table, and view elements canvas.elements?.forEach(element => { - if (element.type === 'graph' || element.type === 'dataTable') { + if (element.type === 'graph' || element.type === 'dataTable' || element.type === 'view') { this.fetchElementData(element.id); } }); @@ -895,6 +1010,16 @@ class CustomDashboard extends DashboardView { onRefresh={() => this.handleRefreshElement(element.id)} /> ); + case 'view': + return ( + this.handleRefreshElement(element.id)} + /> + ); default: return null; } @@ -957,7 +1082,7 @@ class CustomDashboard extends DashboardView { } = this.state; const hasDataElements = elements.some( - el => el.type === 'graph' || el.type === 'dataTable' + el => el.type === 'graph' || el.type === 'dataTable' || el.type === 'view' ); const hasElements = elements.length > 0; @@ -1085,11 +1210,13 @@ class CustomDashboard extends DashboardView { showStaticTextDialog, showGraphDialog, showDataTableDialog, + showViewDialog, showSaveDialog, showLoadDialog, editingElement, availableGraphs, availableFilters, + availableViews, classes, classSchemas, savedCanvases, @@ -1139,6 +1266,14 @@ class CustomDashboard extends DashboardView { onSave={this.handleSaveDataTable} /> )} + {showViewDialog && ( + this.setState({ showViewDialog: false, editingElement: null })} + onSave={this.handleSaveView} + /> + )} {showSaveDialog && ( { + const [title, setTitle] = useState(initialConfig?.title || ''); + const [viewId, setViewId] = useState(initialConfig?.viewId || ''); + + const sortedViews = useMemo(() => { + return [...availableViews].sort((a, b) => a.name.localeCompare(b.name)); + }, [availableViews]); + + const selectedView = useMemo(() => { + return availableViews.find(v => v.id === viewId); + }, [availableViews, viewId]); + + const handleSave = () => { + if (!viewId || !selectedView) { + return; + } + + onSave({ + title: title.trim() || null, + viewId, + viewName: selectedView.name, + className: selectedView.className, + cloudFunction: selectedView.cloudFunction || null, + query: selectedView.query || null, + }); + }; + + const isValid = viewId && selectedView; + + return ( + + {availableViews.length === 0 ? ( + } + input={ +
+ No views found. Create a view in the Views section first. +
+ } + /> + ) : ( + <> + } + input={ + + } + /> + } + input={ + + {sortedViews.map(v => ( + + ))} + + } + /> + {selectedView && ( + } + input={ +
+ {selectedView.cloudFunction ? ( +
Cloud Function: {selectedView.cloudFunction}
+ ) : ( + <> +
Class: {selectedView.className}
+ {selectedView.query && ( +
Aggregation Pipeline: {selectedView.query.length} stage(s)
+ )} + + )} +
+ } + /> + )} + + )} +
+ ); +}; + +export default ViewConfigDialog; diff --git a/src/dashboard/Data/CustomDashboard/elements/ViewElement.react.js b/src/dashboard/Data/CustomDashboard/elements/ViewElement.react.js new file mode 100644 index 000000000..43e2c79cd --- /dev/null +++ b/src/dashboard/Data/CustomDashboard/elements/ViewElement.react.js @@ -0,0 +1,344 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import React, { useMemo } from 'react'; +import Icon from 'components/Icon/Icon.react'; +import Pill from 'components/Pill/Pill.react'; +import styles from './ViewElement.scss'; + +// Check if a URL uses a safe protocol (http: or https:) +const isSafeUrl = (url) => { + if (!url || typeof url !== 'string') { + return false; + } + try { + const parsed = new URL(url, window.location.origin); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +}; + +// Compute text width using canvas measurement +const computeTextWidth = (text) => { + let str = text; + if (str === undefined || str === null) { + str = ''; + } else if (typeof str === 'object') { + if (str.__type === 'Date' && str.iso) { + str = new Date(str.iso).toLocaleString(); + } else if (str.__type === 'Link' && str.text) { + str = str.text; + } else if (str.__type === 'Pointer' && str.objectId) { + str = str.objectId; + } else if (str.__type === 'File' && str.name) { + str = str.name; + } else if (str.__type === 'GeoPoint') { + str = `(${str.latitude}, ${str.longitude})`; + } else if (str.__type === 'Image' || str.__type === 'Video') { + // Use specified width if available, otherwise default min width + const specifiedWidth = str.width && parseInt(str.width, 10) > 0 ? parseInt(str.width, 10) : null; + // Add padding (24px) to account for cell padding + return specifiedWidth ? specifiedWidth + 24 : 124; + } else { + str = JSON.stringify(str); + } + } + str = String(str); + + if (typeof document !== 'undefined') { + const canvas = computeTextWidth._canvas || (computeTextWidth._canvas = document.createElement('canvas')); + const context = canvas.getContext('2d'); + context.font = '12px "Source Code Pro", "Courier New", monospace'; + const width = context.measureText(str).width + 32; // Add padding + return Math.max(width, 60); // Minimum 60px + } + return Math.max((str.length + 2) * 8, 60); +}; + +const formatValue = (value) => { + if (value === null || value === undefined) { + return '-'; + } + if (typeof value === 'object') { + switch (value.__type) { + case 'Date': + return value.iso ? new Date(value.iso).toLocaleString() : String(value); + case 'Pointer': + return `${value.className}:${value.objectId}`; + case 'File': + return value.name || 'File'; + case 'GeoPoint': + return `(${value.latitude}, ${value.longitude})`; + case 'Link': + return value.text || value.url || 'Link'; + case 'Image': + return value.alt || value.url || 'Image'; + case 'Video': + return value.url || 'Video'; + default: + return JSON.stringify(value); + } + } + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + return String(value); +}; + +const ViewElement = ({ + config, + data, + columns, + isLoading, + error, + onRefresh, + onPointerClick, +}) => { + // All hooks must be called before any early returns (React Rules of Hooks) + const displayColumns = useMemo(() => { + return columns || Object.keys(data?.[0] || {}).filter(k => k !== 'ACL'); + }, [columns, data]); + + const columnWidths = useMemo(() => { + if (!data || data.length === 0) { + return {}; + } + const widths = {}; + const maxWidth = 250; // Maximum column width + + displayColumns.forEach(col => { + // Start with header width + widths[col] = Math.min(computeTextWidth(col), maxWidth); + }); + + // Check each row's cell content + data.forEach(row => { + displayColumns.forEach(col => { + const cellWidth = computeTextWidth(row[col]); + if (cellWidth > widths[col] && widths[col] < maxWidth) { + widths[col] = Math.min(cellWidth, maxWidth); + } + }); + }); + + return widths; + }, [data, displayColumns]); + + const tableWidth = useMemo(() => { + return Object.values(columnWidths).reduce((sum, w) => sum + w, 0); + }, [columnWidths]); + + // Early returns after all hooks + if (!config || !config.viewId) { + return ( +
+ +

No view configured

+
+ ); + } + + if (isLoading) { + return ( +
+ +

Loading view data...

+
+ ); + } + + if (error) { + return ( +
+ +

Error loading view data

+ {onRefresh && ( + + )} +
+ ); + } + + if (!data || data.length === 0) { + return ( +
+ +

No data found

+
+ ); + } + + const handlePointerClick = (value) => { + if (onPointerClick && value.__type === 'Pointer' && value.className && value.objectId) { + onPointerClick({ className: value.className, id: value.objectId }); + } + }; + + const renderCellContent = (value) => { + if (value === null || value === undefined) { + return '-'; + } + + if (typeof value !== 'object') { + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + return String(value); + } + + // Handle special __type objects + switch (value.__type) { + case 'Pointer': + if (value.className && value.objectId) { + return ( + handlePointerClick(value)} + followClick + shrinkablePill + /> + ); + } + return JSON.stringify(value); + + case 'Date': + return value.iso ? new Date(value.iso).toLocaleString() : String(value); + + case 'File': + return value.name || 'File'; + + case 'GeoPoint': + return `(${value.latitude}, ${value.longitude})`; + + case 'Link': { + const url = isSafeUrl(value.url) ? value.url : '#'; + let text = value.text; + if (!text || text.trim() === '' || !isSafeUrl(text)) { + text = 'Link'; + } + return ( + + {text} + + ); + } + + case 'Image': { + const url = value.url; + if (!url || url.match(/javascript/i) || url.match(/