diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index e765c75ba1..0961392afd 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.68.1", + "version": "6.69.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.68.1", + "version": "6.69.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 7093ac4805..d1844880d3 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.68.1", + "version": "6.69.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 0ece3a35b2..9a0663b1a0 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,21 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 6.69.0 +*Released*: 5 November 2025 +- Merge from release25.11-SNAPSHOT to develop + - includes changes from 6.68.2 #1878 + - includes changes from 6.68.3 #1884 + +### version 6.68.3 +*Released*: 4 November 2025 +- Issue 53983: Sort fields by caption +- Issue 53927: Adjust column view layout to wrap + +### version 6.68.2 +*Released*: 3 November 2025 +- SVGChart: render curve fit statistics when available + ### version 6.68.1 *Released* 2 November 2025 - Issue 52063: Domain designer to handle lookup to a query with a pipe character diff --git a/packages/components/src/internal/components/ColumnSelectionModal.test.tsx b/packages/components/src/internal/components/ColumnSelectionModal.test.tsx index d572e0f1fb..2390c646e6 100644 --- a/packages/components/src/internal/components/ColumnSelectionModal.test.tsx +++ b/packages/components/src/internal/components/ColumnSelectionModal.test.tsx @@ -10,9 +10,9 @@ import { wrapDraggable } from '../test/testHelpers'; import { ColumnChoice, - ColumnChoiceProps, ColumnChoiceGroup, ColumnChoiceGroupProps, + ColumnChoiceProps, ColumnInView, ColumnInViewProps, ColumnSelectionModal, @@ -52,7 +52,7 @@ describe('ColumnSelectionModal', () => { test('isInView', () => { render(); - expect(document.querySelector('.field-caption').textContent).toBe('Test Column'); + expect(document.querySelector('.field-caption')).toHaveTextContent('Test Column'); expect(document.querySelectorAll('.fa-check')).toHaveLength(1); expect(document.querySelectorAll('.fa-plus')).toHaveLength(0); expect(document.querySelectorAll('.field-expand-icon')).toHaveLength(1); @@ -62,7 +62,7 @@ describe('ColumnSelectionModal', () => { test('not isInView', () => { render(); - expect(document.querySelector('.field-caption').textContent).toBe('Test Column'); + expect(document.querySelector('.field-caption')).toHaveTextContent('Test Column'); expect(document.querySelectorAll('.fa-check')).toHaveLength(0); expect(document.querySelectorAll('.fa-plus')).toHaveLength(1); expect(document.querySelectorAll('.field-expand-icon')).toHaveLength(1); @@ -72,7 +72,7 @@ describe('ColumnSelectionModal', () => { test('lookup, collapsed', () => { render(); - expect(document.querySelector('.field-caption').textContent).toBe('Test Column'); + expect(document.querySelector('.field-caption')).toHaveTextContent('Test Column'); expect(document.querySelectorAll('.fa-check')).toHaveLength(0); expect(document.querySelectorAll('.fa-plus')).toHaveLength(1); expect(document.querySelectorAll('.field-expand-icon')).toHaveLength(3); @@ -82,7 +82,7 @@ describe('ColumnSelectionModal', () => { test('lookup, expanded', () => { render(); - expect(document.querySelector('.field-caption').textContent).toBe('Test Column'); + expect(document.querySelector('.field-caption')).toHaveTextContent('Test Column'); expect(document.querySelectorAll('.fa-check')).toHaveLength(0); expect(document.querySelectorAll('.fa-plus')).toHaveLength(1); expect(document.querySelectorAll('.field-expand-icon')).toHaveLength(3); @@ -116,7 +116,7 @@ describe('ColumnSelectionModal', () => { } function validate(column: QueryColumn, deleteDisabled: boolean): void { - expect(document.querySelector('.field-caption span').textContent).toBe(column.caption); + expect(document.querySelector('.field-caption span')).toHaveTextContent(column.caption); const removeIcon = document.querySelector('.fa-times'); if (deleteDisabled) { expect(removeIcon).toBeFalsy(); @@ -184,7 +184,7 @@ describe('ColumnSelectionModal', () => { describe('FieldLabelDisplay', () => { test('not lookup', () => { render(); - expect(document.querySelector('.field-caption span').textContent).toBe(QUERY_COL.caption); + expect(document.querySelector('.field-caption span')).toHaveTextContent(QUERY_COL.caption); expect(document.querySelectorAll('.overlay-trigger')).toHaveLength(1); expect(document.querySelectorAll('input')).toHaveLength(0); }); @@ -266,8 +266,8 @@ describe('ColumnSelectionModal', () => { ); validate(true, true); @@ -279,8 +279,8 @@ describe('ColumnSelectionModal', () => { ); validate(true, true, true); @@ -292,8 +292,8 @@ describe('ColumnSelectionModal', () => { ); validate(true, true, true); @@ -321,8 +321,8 @@ describe('ColumnSelectionModal', () => { ); @@ -336,9 +336,9 @@ describe('ColumnSelectionModal', () => { !c.removeFromViews)} expandedColumns={{ [QUERY_COL_LOOKUP.index]: queryInfo }} - columnsInView={[QUERY_COL_LOOKUP]} /> ); validate(true, true); @@ -376,15 +376,67 @@ describe('ColumnSelectionModal', () => { !c.isJunctionLookup())} expandedColumns={{ [QUERY_COL_LOOKUP.index]: queryInfo }} - columnsInView={[QUERY_COL_LOOKUP]} showAllColumns /> ); validate(true, true, true); expect(document.querySelectorAll('.list-group-item')).toHaveLength(2); }); + + // Issue 53983 + test('Expanded lookup child fields are sorted by caption', () => { + const parent = new QueryColumn({ + caption: 'Parent', + name: 'parent', + fieldKey: 'parent', + fieldKeyArray: ['parent'], + fieldKeyPath: 'parent', + selectable: true, + lookup: new QueryLookup({}), + }); + const childZ = new QueryColumn({ + caption: 'zeta', + name: 'z', + fieldKey: 'z', + fieldKeyArray: ['z'], + fieldKeyPath: 'parent/z', + selectable: true, + }); + const childA = new QueryColumn({ + caption: 'Alpha', + name: 'a', + fieldKey: 'a', + fieldKeyArray: ['a'], + fieldKeyPath: 'parent/a', + selectable: true, + }); + + const expandedInfo = new QueryInfo({ + columns: new ExtendedMap({ + [childZ.fieldKey]: childZ, + [childA.fieldKey]: childA, + }), + }); + + render( + + ); + + // Expect list to contain parent followed by children sorted by caption: Alpha, zeta + const items = Array.from(document.querySelectorAll('.list-group-item .field-caption')).map(el => + el.textContent.trim() + ); + + expect(items).toEqual(['Parent', 'Alpha', 'zeta']); + }); }); describe('ColumnSelectionModal', () => { @@ -404,8 +456,8 @@ describe('ColumnSelectionModal', () => { expect(titles).toHaveLength(2); expect(document.querySelectorAll('.list-group-item')).toHaveLength(2); - expect(titles[0].textContent).toContain('Available Fields'); - expect(titles[1].textContent).toContain('Selected Fields'); + expect(titles[0]).toHaveTextContent('Available Fields'); + expect(titles[1]).toHaveTextContent('Selected Fields'); expect(document.querySelector('.field-modal__footer')).toBeFalsy(); }); @@ -432,5 +484,60 @@ describe('ColumnSelectionModal', () => { // verify useEffect initialization of selectedIndex, selectedColumns expect(document.querySelectorAll('.list-group-item.active')).toHaveLength(1); }); + + // Issue 53983 + test('Available Fields list is sorted by caption', () => { + const colB = new QueryColumn({ + caption: 'b Beta', + name: 'b', + fieldKey: 'b', + fieldKeyArray: ['b'], + fieldKeyPath: 'b', + selectable: true, + }); + const colA = new QueryColumn({ + caption: 'A Alpha', + name: 'a', + fieldKey: 'a', + fieldKeyArray: ['a'], + fieldKeyPath: 'a', + selectable: true, + }); + const col10 = new QueryColumn({ + caption: 'Item 10', + name: 'item10', + fieldKey: 'item10', + fieldKeyArray: ['item10'], + fieldKeyPath: 'item10', + selectable: true, + }); + const col2 = new QueryColumn({ + caption: 'Item 2', + name: 'item2', + fieldKey: 'item2', + fieldKeyArray: ['item2'], + fieldKeyPath: 'item2', + selectable: true, + }); + + const queryInfo = new QueryInfo({ + columns: new ExtendedMap({ + [colB.fieldKey]: colB, + [colA.fieldKey]: colA, + [col10.fieldKey]: col10, + [col2.fieldKey]: col2, + }), + }); + + render(); + + // Available Fields (left column) + const availableFields = document.querySelector('.field-modal__col-content'); + const captions = [...availableFields.querySelectorAll('.list-group-item .field-caption')].map( + el => el.textContent?.trim() ?? '' + ); + + expect(captions).toEqual(['A Alpha', 'b Beta', 'Item 2', 'Item 10']); + }); }); }); diff --git a/packages/components/src/internal/components/ColumnSelectionModal.tsx b/packages/components/src/internal/components/ColumnSelectionModal.tsx index 9ac7e979cc..24d87ad273 100644 --- a/packages/components/src/internal/components/ColumnSelectionModal.tsx +++ b/packages/components/src/internal/components/ColumnSelectionModal.tsx @@ -5,6 +5,7 @@ import classNames from 'classnames'; import { QueryColumn } from '../../public/QueryColumn'; import { QueryInfo } from '../../public/QueryInfo'; +import { naturalSortByProperty } from '../../public/sort'; import { Modal, ModalProps } from '../Modal'; @@ -62,13 +63,13 @@ export const FieldLabelDisplay: FC = memo(props => { return ( ); } @@ -135,11 +136,10 @@ export const ColumnChoice: FC = memo(props => { ); return ( -
+
{supportsExpand && ( <> {parentFieldKeys.map((parent, index) => ( - // eslint-disable-next-line react/no-array-index-key
))}
@@ -161,11 +161,11 @@ export const ColumnChoice: FC = memo(props => { {!isInView && column.selectable && (
{show && createPortal(popover, portalEl)} @@ -219,19 +219,20 @@ export const ColumnChoiceGroup: FC = memo(props => { {isLookupExpanded && expandedColumns[column.index].columns.valueArray .filter(fkCol => expandedColumnFilter?.(fkCol, showAllColumns) ?? true) + .sort(naturalSortByProperty('caption')) // Issue 53983: Sort fields by caption .map(fkCol => ( ))} @@ -292,7 +293,7 @@ export const ColumnInView: FC = memo(props => { }, [onEditTitle]); return ( - + {(dragProvided, snapshot) => (
= memo(props => {
{!editing && ( @@ -315,17 +316,17 @@ export const ColumnInView: FC = memo(props => { {allowEditLabel && ( - + )} {!disableDelete && ( @@ -341,7 +342,7 @@ export const ColumnInView: FC = memo(props => { ColumnInView.displayName = 'ColumnInView'; -export interface ColumnSelectionModalProps extends Omit { +export interface ColumnSelectionModalProps extends Omit { allowEditLabel?: boolean; allowEmptySelection?: boolean; allowShowAll?: boolean; @@ -506,7 +507,8 @@ export const ColumnSelectionModal: FC = memo(props => if (!isLoaded || !queryInfo) return []; return queryInfo.columns.valueArray .filter(c => expandedColumnFilter?.(c, showAllColumns) ?? true) - .filter(c => c.fieldKeyArray.length === 1); // at the top level don't include lookup fields + .filter(c => c.fieldKeyArray.length === 1) // at the top level don't include lookup fields + .sort(naturalSortByProperty('caption')); // Issue 53983: Sort fields by caption }, [expandedColumnFilter, isLoaded, queryInfo, showAllColumns]); const disabledMsg = useMemo(() => { @@ -528,28 +530,28 @@ export const ColumnSelectionModal: FC = memo(props => {isLoaded && (
-
+
{leftColumnTitle}
-
+
{availableColumns.map(column => ( ))}
{allowShowAll && ( -
- +
+  Show all system and user-defined fields
)} @@ -575,16 +577,16 @@ export const ColumnSelectionModal: FC = memo(props => {selectedColumns.map((column, index) => ( = 0} - key={column.index} column={column} + disableDelete={fixedFieldKeys?.indexOf(column.fieldKey) >= 0} index={index} isDragDisabled={editingColumnTitle} - onRemoveColumn={onRemoveColumn} - selected={selectedIndex === index} + key={column.index} onClick={onSelectField} onEditTitle={onEditTitle} + onRemoveColumn={onRemoveColumn} onUpdateTitle={onUpdateTitle} + selected={selectedIndex === index} /> ))} {dropProvided.placeholder} diff --git a/packages/components/src/internal/components/chart/Chart.tsx b/packages/components/src/internal/components/chart/Chart.tsx index 3ec911400b..37fc366297 100644 --- a/packages/components/src/internal/components/chart/Chart.tsx +++ b/packages/components/src/internal/components/chart/Chart.tsx @@ -27,6 +27,7 @@ import { LoadingSpinner } from '../base/LoadingSpinner'; import { ChartAPIWrapper, DEFAULT_API_WRAPPER } from './api'; import { ChartConfig, ChartQueryConfig } from './models'; import { getChartRenderMsg } from './ChartBuilderModal'; +import { CurveFitStatsGrid } from './CurveFitStatsGrid'; interface ChartLoadingMaskProps { msg?: string; @@ -82,7 +83,7 @@ interface Props { chart: DataViewInfo; container?: string; filters?: Filter.IFilter[]; - queryParameters?: { [key: string]: any }; + queryParameters?: Record; } export const SVGChart: FC = memo(({ api, chart, container, filters, queryParameters }) => { @@ -95,6 +96,7 @@ export const SVGChart: FC = memo(({ api, chart, container, filters, query const [loadingData, setLoadingData] = useState(false); const [measureStore, setMeasureStore] = useState(undefined); const [trendlineData, setTrendlineData] = useState(undefined); + const [plot, setPlot] = useState(undefined); const [renderMsg, setRenderMsg] = useState(undefined); const [loadError, setLoadError] = useState(undefined); const filterKey = useMemo(() => computeFilterKey(filters), [filters]); @@ -163,7 +165,7 @@ export const SVGChart: FC = memo(({ api, chart, container, filters, query } ); } else { - LABKEY_VIS.GenericChartHelper.generateChartSVG( + const plots = LABKEY_VIS.GenericChartHelper.generateChartSVG( divId, { ...chartConfig, @@ -173,6 +175,7 @@ export const SVGChart: FC = memo(({ api, chart, container, filters, query measureStore, trendlineData ); + setPlot(plots[0]); } } }; @@ -189,17 +192,22 @@ export const SVGChart: FC = memo(({ api, chart, container, filters, query {(isLoading(loadingState) || loadingData) && } {renderMsg && {renderMsg}}
+ {trendlineData !== undefined && ( + + )}
); }); SVGChart.displayName = 'SVGChart'; +interface FileAnchor { + href: string; + text: string; +} + interface RReportData { error?: string; - fileAnchors: Array<{ - href: string; - text: string; - }>; + fileAnchors: FileAnchor[]; imageUrls: string[]; } @@ -309,7 +317,7 @@ const RReport: FC = memo(({ api, chart, container, filters }) => { {imageUrls !== undefined && (
{imageUrls.map(url => ( -
+
R Report Image Output
))} diff --git a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx new file mode 100644 index 0000000000..a8a6583519 --- /dev/null +++ b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx @@ -0,0 +1,375 @@ +import React, { FC, memo, useCallback, useMemo } from 'react'; +import { Utils, UtilsDOM } from '@labkey/api'; + +import { Row } from '../../query/selectRows'; +import { GridColumn } from '../base/models/GridColumn'; +import { Grid } from '../base/Grid'; +import { List as ImmutableList } from 'immutable'; +import { naturalSortByProperty } from '../../../public/sort'; +import { DropdownButton, MenuHeader, MenuItem } from '../../dropdowns'; + +enum ExportType { + COMMA = 'COMMA', + EXCEL = 'EXCEL', + TAB = 'TAB', +} + +// Equivalent to what we're doing when generating the hoverText in GenericChartHelper generateTrendlinePathHover +const roundedValue = (value: number) => { + const rounded = Utils.roundNumber(value, 4); + return !isNaN(rounded) ? rounded : value; +}; +const roundedColumn = (value: number) => { + const str = roundedValue(value); + return
{str}
; +}; +const MIN_COL = new GridColumn({ index: 'min', title: 'Min', cell: roundedColumn }); +const MAX_COL = new GridColumn({ index: 'max', title: 'Max', cell: roundedColumn }); +const ASYMMETRY_COLUMN = new GridColumn({ index: 'asymmetry', title: 'Asymmetry', cell: roundedColumn }); +const INFLECTION_COLUMN = new GridColumn({ index: 'inflection', title: 'Inflection', cell: roundedColumn }); +const R_SQUARED_COLUMN = new GridColumn({ index: 'RSquared', title: 'R-Squared', cell: roundedColumn }); +const ADJUSTED_R_SQUARED_COLUMN = new GridColumn({ + index: 'adjustedRSquared', + title: 'Adjusted R-Squared', + cell: roundedColumn, +}); +const RSS_COLUMN = new GridColumn({ index: 'RSS', title: 'RSS', cell: roundedColumn }); +const TSS_COLUMN = new GridColumn({ index: 'TSS', title: 'TSS', cell: roundedColumn }); +const RMSE_COLUMN = new GridColumn({ index: 'RMSE', title: 'RMSE', cell: roundedColumn }); +const SLOPE_COLUMN = new GridColumn({ index: 'slope', title: 'Slope', cell: roundedColumn }); +const INTERCEPT_COLUMN = new GridColumn({ index: 'intercept', title: 'Intercept', cell: roundedColumn }); +const COEFFICIENT_1_COLUMN = new GridColumn({ index: 'coefficient1', title: 'Coefficient 1', cell: roundedColumn }); +const COEFFICIENT_2_COLUMN = new GridColumn({ index: 'coefficient2', title: 'Coefficient 2', cell: roundedColumn }); +const COEFFICIENT_3_COLUMN = new GridColumn({ index: 'coefficient3', title: 'Coefficient 3', cell: roundedColumn }); +const STATS_COLUMNS = [RSS_COLUMN, TSS_COLUMN, RMSE_COLUMN, R_SQUARED_COLUMN]; +const NONLINEAR_COLUMNS = [ + MIN_COL, + MAX_COL, + SLOPE_COLUMN, + ASYMMETRY_COLUMN, + INFLECTION_COLUMN, + ...STATS_COLUMNS, + ADJUSTED_R_SQUARED_COLUMN, +]; +const LINEAR_REGRESSION_COLUMNS = [SLOPE_COLUMN, INTERCEPT_COLUMN, ...STATS_COLUMNS]; +const POLYNOMIAL_COLUMNS = [COEFFICIENT_1_COLUMN, COEFFICIENT_2_COLUMN, COEFFICIENT_3_COLUMN, ...STATS_COLUMNS]; + +interface GeneratedPoint { + x: number; + y: number; +} + +interface CurveFitStats { + RMSE: number; + RSquared: number; + RSS: number; + TSS: number; +} + +interface NonlinearCurveFitStats extends CurveFitStats { + adjustedRSquared: number; +} + +interface CurveFit { + type: string; +} + +interface PolynomialCurveFitData { + coefficients: number[]; +} + +interface PolynomialCurveFit extends CurveFit, PolynomialCurveFitData { + type: 'Polynomial'; +} + +interface LinearRegressionCurveFitData { + intercept: number; + slope: number; +} + +interface LinearRegressionCurveFit extends CurveFit, LinearRegressionCurveFitData { + type: 'Linear'; +} + +// Note: most nonlinear curve fits also have a fitError attribute, however we are ignoring that because it's just RMSE +// or R-Squared. +interface NonlinearCurveFitData { + asymmetry: number; + inflection: number; + max: number; + min: number; + slope: number; +} + +interface NonlinearCurveFit extends CurveFit, NonlinearCurveFitData { + type: '3 Parameter' | '4 Parameter' | 'Five Parameter' | 'Four Parameter' | 'Three Parameter'; +} + +interface CurveFitData { + curveFit: T; + generatedPoints: GeneratedPoint[]; + stats: S; +} + +interface Trendline { + count: number; + data: CurveFitData; + generatedPoints: GeneratedPoint[]; + name: string; + rawData: Row[]; + total: number; +} + +interface BaseCurveFitRow extends CurveFitStats { + series: string; +} + +interface PolynomialCurveFitRow extends BaseCurveFitRow { + coefficient1: number; + coefficient2: number; + coefficient3: number; +} + +type LinearRegressionCurveFitRow = BaseCurveFitRow & LinearRegressionCurveFitData; +type NonlinearCurveFitRow = BaseCurveFitRow & NonlinearCurveFitData & NonlinearCurveFitStats; +type CurveFitRow = LinearRegressionCurveFitRow | NonlinearCurveFitRow | PolynomialCurveFitRow; +type PossibleTrendlines = + | Trendline + | Trendline + | Trendline; + +const nonLinearTrendlineTypes = ['3 Parameter', '4 Parameter', 'Five Parameter', 'Four Parameter', 'Three Parameter']; +function isNonlinearTrendline( + trendline: PossibleTrendlines +): trendline is Trendline { + const type = trendline.data.curveFit.type; + return nonLinearTrendlineTypes.includes(type); +} + +function trendLineToCurveFitRow(trendline: PossibleTrendlines): CurveFitRow { + const series = trendline.name; + // We want to render empty rows in the grid if the series doesn't have data + if (trendline.data === undefined) return { series } as CurveFitRow; + const { curveFit, stats } = trendline.data; + + if (curveFit.type === 'Polynomial') { + return { + ...stats, + coefficient1: curveFit.coefficients[0], + coefficient2: curveFit.coefficients[1], + coefficient3: curveFit.coefficients[2], + series, + }; + } + + if (curveFit.type === 'Linear') { + const { intercept, slope } = curveFit; + return { ...stats, intercept, series, slope }; + } + + if (isNonlinearTrendline(trendline)) { + const { asymmetry, inflection, max, min, slope } = curveFit; + return { ...(stats as NonlinearCurveFitStats), asymmetry, inflection, max, min, series, slope }; + } + + return undefined; +} + +const STATS_EXPORT_COLS = ['RSS', 'TSS', 'RMSE', 'RSquared']; +const LINEAR_EXPORT_COLS = ['slope', 'intercept', ...STATS_EXPORT_COLS]; +const POLYNOMIAL_EXPORT_COLS = ['coefficient1', 'coefficient2', 'coefficient3', ...STATS_EXPORT_COLS]; +const NONLINEAR_EXPORT_COLS = [ + 'min', + 'max', + 'slope', + 'asymmetry', + 'inflection', + ...STATS_EXPORT_COLS, + 'adjustedRSquared', +]; + +const STATS_EXPORT_HEADERS = ['RSS', 'TSS', 'RMSE', 'R-Squared']; +const LINEAR_EXPORT_HEADERS = ['Slope', 'Intercept', ...STATS_EXPORT_HEADERS]; +const POLYNOMIAL_EXPORT_HEADERS = ['Coefficient 1', 'Coefficient 2', 'Coefficient 3', ...STATS_EXPORT_HEADERS]; +const NONLINEAR_EXPORT_HEADERS = [ + 'Min', + 'Max', + 'Slope', + 'Asymmetry', + 'Inflection', + ...STATS_EXPORT_HEADERS, + 'Adjusted R-Squared', +]; + +/** + * Converts CurveFitRow[] to an array of arrays as expected by convertToExcel and convertToTable + */ +function curveFitRowsToExportFormat(hasSeries: boolean, type: string, rows: CurveFitRow[]): (number | string)[][] { + let headers = hasSeries ? ['Series'] : []; + let cols = hasSeries ? ['series'] : []; + if (type === 'Polynomial') { + headers = headers.concat(POLYNOMIAL_EXPORT_HEADERS); + cols = cols.concat(POLYNOMIAL_EXPORT_COLS); + } else if (type === 'Linear') { + headers = headers.concat(LINEAR_EXPORT_HEADERS); + cols = cols.concat(LINEAR_EXPORT_COLS); + } else { + headers = headers.concat(NONLINEAR_EXPORT_HEADERS); + cols = cols.concat(NONLINEAR_EXPORT_COLS); + } + + const exportRows = rows.map((row: CurveFitRow) => cols.map(col => roundedValue(row[col])?.toString(10))); + exportRows.unshift(headers); + return exportRows; +} + +type ShapeMethod = (size: number) => string; +type ScaleMethod = (value: string) => T; + +/** + * This is a subset of what our scale objects look like in our Vis API. Only defining what we're using at this moment. + */ +interface PlotScale { + scale: ScaleMethod; +} + +/** + * This is the subset of attributes from the object returned by new LABKEY.vis.Plot that we need in order to render the + * curve fit statistics. + */ +interface PlotObject { + scales: { + color: PlotScale; + shape: PlotScale; + }; +} + +const SHAPE_SIZE = 5; // hard coded size 5 because that's what our chart legends already do + +interface SeriesCellProps { + colorScale: ScaleMethod; + series: string; + shapeScale: ScaleMethod; +} + +const SeriesCell: FC = memo(({ colorScale, series, shapeScale }) => { + const color = colorScale(series); + const shape = shapeScale(series)(SHAPE_SIZE); + return ( +
+ + + +
{series}
+
+ ); +}); +SeriesCell.displayName = 'SeriesCell'; + +type PossibleTrendlineArrays = + | Trendline[] + | Trendline[] + | Trendline[]; +interface Props { + name: string; + plot: PlotObject | undefined; + trendLineData: PossibleTrendlineArrays; +} + +export const CurveFitStatsGrid: FC = memo(({ name, plot, trendLineData }) => { + // A trendline will not have a data attribute if the series doesn't have enough data + const validTrendlines = trendLineData.filter(t => t.data !== undefined); + const hasValidTrendlines = validTrendlines.length > 0; + const type = validTrendlines[0]?.data.curveFit.type; + const hasSeries = !(trendLineData.length === 1 && trendLineData[0].name === 'All'); + const gridData = useMemo(() => { + if (!hasValidTrendlines) return undefined; + return trendLineData.map(trendLineToCurveFitRow).sort(naturalSortByProperty('series')); + }, [hasValidTrendlines, trendLineData]); + + const gridColumns = useMemo(() => { + if (!hasValidTrendlines) return undefined; + let seriesColumn: GridColumn; + + if (hasSeries) { + const colConfig = { cell: undefined, index: 'series', title: 'Series' }; + const colorScale = plot?.scales.color; + const shapeScale = plot?.scales.shape; + + if (colorScale && shapeScale) { + colConfig.cell = (series: string) => ( + + ); + } + + seriesColumn = new GridColumn(colConfig); + } + + let columns = seriesColumn ? [seriesColumn] : []; + + if (type === 'Polynomial') columns = columns.concat(POLYNOMIAL_COLUMNS); + else if (type === 'Linear') columns = columns.concat(LINEAR_REGRESSION_COLUMNS); + else columns = columns.concat(NONLINEAR_COLUMNS); + + return ImmutableList(columns); + }, [hasSeries, hasValidTrendlines, plot, type]); + + const onExportTextFile = useCallback( + (delimiter: ExportType) => { + const exportData = curveFitRowsToExportFormat(hasSeries, type, gridData); + + if (delimiter === ExportType.EXCEL) { + UtilsDOM.convertToExcel({ + fileName: `${name}_statistics.xlsx`, + sheets: [{ name: 'statistics', data: exportData }], + }); + } else { + UtilsDOM.convertToTable({ + fileNamePrefix: `${name}_statistics`, + delim: delimiter === ExportType.COMMA ? UtilsDOM.DelimiterType.COMMA : UtilsDOM.DelimiterType.TAB, + quoteChar: UtilsDOM.QuoteCharType.DOUBLE, + rows: exportData, + }); + } + }, + [gridData, hasSeries, name, type] + ); + const onExportCsv = useCallback(() => onExportTextFile(ExportType.COMMA), [onExportTextFile]); + const onExportExcel = useCallback(() => onExportTextFile(ExportType.EXCEL), [onExportTextFile]); + const onExportTsv = useCallback(() => onExportTextFile(ExportType.TAB), [onExportTextFile]); + + if (!hasValidTrendlines) return null; + + return ( +
+
+
Statistics
+ } + > + + + + + CSV + + + + + Excel + + + + + TSV + + +
+ +
+ ); +}); +CurveFitStatsGrid.displayName = 'CurveFitStatsGrid'; diff --git a/packages/components/src/internal/components/search/QueryFilterPanel.tsx b/packages/components/src/internal/components/search/QueryFilterPanel.tsx index 96321a5268..598b5dac55 100644 --- a/packages/components/src/internal/components/search/QueryFilterPanel.tsx +++ b/packages/components/src/internal/components/search/QueryFilterPanel.tsx @@ -9,6 +9,7 @@ import { ChoicesListItem } from '../base/ChoicesListItem'; import { QueryColumn } from '../../../public/QueryColumn'; import { QueryInfo } from '../../../public/QueryInfo'; +import { naturalSortByProperty } from '../../../public/sort'; import { NOT_ANY_FILTER_TYPE } from '../../url/NotAnyFilterType'; @@ -40,7 +41,7 @@ interface Props { entityDataType?: EntityDataType; fieldKey?: string; fields?: QueryColumn[]; - filters: { [key: string]: FieldFilter[] }; + filters: Record; fullWidth?: boolean; hasNotInQueryFilter?: boolean; hasNotInQueryFilterLabel?: string; @@ -146,7 +147,7 @@ export const QueryFilterPanel: FC = memo(props => { setActiveField(undefined); if (!queryInfo) return; - let validFields; + let validFields: QueryColumn[]; if (fields) validFields = fields; else { const qFields = skipDefaultViewCheck @@ -159,6 +160,11 @@ export const QueryFilterPanel: FC = memo(props => { ); } + // Issue 53983: Sort fields by caption + if (validFields) { + validFields = validFields.sort(naturalSortByProperty('caption')); + } + setQueryFields(validFields); if (fieldKey) { const field = validFields.find(f => f.getDisplayFieldKey() === fieldKey); @@ -239,11 +245,11 @@ export const QueryFilterPanel: FC = memo(props => { {entityDataType?.supportHasNoValueInQuery && (
onHasNoValueInQueryChange(event.target.checked)} - checked={hasNotInQueryFilter} + type="checkbox" />
{hasNotInQueryFilterLabel ?? 'Without data from this type'} @@ -261,14 +267,14 @@ export const QueryFilterPanel: FC = memo(props => { return ( {caption}} - onSelect={() => onFieldClick(field)} componentRight={ hasFilters(field) && } disabled={hasNotInQueryFilter} + index={index} + key={field.fieldKeyPath} + label={{caption}} + onSelect={() => onFieldClick(field)} /> ); })} @@ -291,16 +297,16 @@ export const QueryFilterPanel: FC = memo(props => { {activeTab === FieldFilterTabs.Filter && ( filter.filter)} - onFieldFilterUpdate={(newFilters, index) => - onFilterUpdate(activeField, newFilters, index) - } - disabled={hasNotInQueryFilter} includeAllAncestorFilter={ isAncestor && activeField?.fieldKey.toLowerCase() === 'name' } + key={activeFieldKey} + onFieldFilterUpdate={(newFilters, index) => + onFilterUpdate(activeField, newFilters, index) + } /> )} @@ -310,6 +316,14 @@ export const QueryFilterPanel: FC = memo(props => { Find values for {activeField.caption}
filter.filter)} + fieldKey={activeFieldKey} + key={activeFieldKey} + onFieldFilterUpdate={(newFilters, index) => + onFilterUpdate(activeField, newFilters, index) + } selectDistinctOptions={{ ...selectDistinctOptions, column: activeFieldKey, @@ -318,14 +332,6 @@ export const QueryFilterPanel: FC = memo(props => { viewName, filterArray: fieldDistinctValueFilters, }} - fieldFilters={currentFieldFilters?.map(filter => filter.filter)} - fieldKey={activeFieldKey} - canBeBlank={!activeField?.required && !activeField.nameExpression} - key={activeFieldKey} - onFieldFilterUpdate={(newFilters, index) => - onFilterUpdate(activeField, newFilters, index) - } - disabled={hasNotInQueryFilter} /> )} diff --git a/packages/components/src/public/QueryModel/ChartPanel.tsx b/packages/components/src/public/QueryModel/ChartPanel.tsx index 498e39d33a..c02650e335 100644 --- a/packages/components/src/public/QueryModel/ChartPanel.tsx +++ b/packages/components/src/public/QueryModel/ChartPanel.tsx @@ -120,12 +120,12 @@ export const ChartPanel: FC = memo(({ actions, api = DEFAULT_API_WRAPPER, > - -   PDF + + PDF - -   PNG + + PNG diff --git a/packages/components/src/theme/charts.scss b/packages/components/src/theme/charts.scss index e66756ee49..c8985585ae 100644 --- a/packages/components/src/theme/charts.scss +++ b/packages/components/src/theme/charts.scss @@ -321,3 +321,28 @@ font-family: monospace; font-size: 13px; } + +.curve-fit-statistics__header { + display: flex; + gap: 10px; + align-items: center; +} + +.curve-fit-statistics__title { + font-size: 16px; + font-weight: normal; +} + +.curve-fit-series-cell { + display: flex; + align-items: center; + gap: 5px; +} + +.curve-fit-series-cell__name { + padding-top: 2px; +} + +.curve-fit-value-cell { + text-align: right; +} diff --git a/packages/components/src/theme/filter.scss b/packages/components/src/theme/filter.scss index 72ab068882..a5d910884e 100644 --- a/packages/components/src/theme/filter.scss +++ b/packages/components/src/theme/filter.scss @@ -25,6 +25,10 @@ .list-group-item .field-caption { flex: 1; cursor: default; + + // Issue 53927: Prevent clipping/overlap with field caption + overflow: hidden; + word-wrap: break-word; } .list-group-item .field-expand-icon { diff --git a/packages/components/src/theme/utils.scss b/packages/components/src/theme/utils.scss index a0f793b282..910bf343b1 100644 --- a/packages/components/src/theme/utils.scss +++ b/packages/components/src/theme/utils.scss @@ -50,6 +50,10 @@ margin-right: 10px; } +.margin-right-small { + margin-right: 5px; +} + .margin-right-more { margin-right: 20px; } @@ -70,6 +74,10 @@ margin-left: 10px; } +.margin-left-small { + margin-left: 5px; +} + .margin-left-more { margin-left: 20px; }