-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathQueryModel.ts
More file actions
1327 lines (1178 loc) · 50.6 KB
/
QueryModel.ts
File metadata and controls
1327 lines (1178 loc) · 50.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { immerable, produce } from 'immer';
import { Filter, Query } from '@labkey/api';
import { GRID_CHECKBOX_OPTIONS, GRID_SELECTION_INDEX } from '../../internal/constants';
import { DataViewInfo } from '../../internal/DataViewInfo';
import { getQueryParams } from '../../internal/util/URL';
import { encodePart, SchemaQuery } from '../SchemaQuery';
import { QuerySort } from '../QuerySort';
import { isLoading, LoadingState } from '../LoadingState';
import { QueryInfo } from '../QueryInfo';
import { ViewInfo } from '../../internal/ViewInfo';
import { QueryColumn } from '../QueryColumn';
import { caseInsensitive } from '../../internal/util/utils';
import { naturalSortByProperty } from '../sort';
import { PaginationData } from '../../internal/components/pagination/Pagination';
import { SelectRowsOptions } from '../../internal/query/selectRows';
export function flattenValuesFromRow(
row: any,
columns: string[],
colFieldKeyMap?: Record<string, string>
): Record<string, any> {
const values = {};
if (row && columns) {
columns.forEach((col: string) => {
if (row[col]) {
values[col] = row[col].value;
}
});
}
// convert values[key] to values[fieldKey] if fieldKeyMap provided
if (colFieldKeyMap) {
Object.keys(colFieldKeyMap).forEach(col => {
const fieldKey = colFieldKeyMap[col];
if (fieldKey && fieldKey !== col && values[col] !== undefined) {
values[fieldKey] = values[col];
delete values[col];
}
});
}
return values;
}
function offsetFromString(rowsPerPage: number, pageStr: string): number {
if (pageStr === null) {
return undefined;
}
let offset = 0;
const page = parseInt(pageStr, 10);
if (!isNaN(page)) {
offset = (page - 1) * rowsPerPage;
}
return offset >= 0 ? offset : 0;
}
function querySortsFromString(sortsStr: string): QuerySort[] {
return sortsStr?.split(',').map(QuerySort.fromString);
}
function searchFiltersFromString(searchStr: string): Filter.IFilter[] {
return searchStr?.split(';').map(search => Filter.create('*', search, Filter.Types.Q));
}
/**
* Returns true if a given location has queryParams that would conflict with savedSettings: filters, sorts, view,
* page offset, pageSize.
* @param prefix the QueryModel prefix
* @param searchParams The URLSearchParams returned by the react-router useSearchParams hook
*/
export function locationHasQueryParamSettings(prefix: string, searchParams?: URLSearchParams): boolean {
if (searchParams === undefined) return false;
// Reports
if (searchParams.get(`${prefix}.selectedReportIds`) !== null) return true;
// View
if (searchParams.get(`${prefix}.view`) !== null) return true;
// Search Filters
if (searchParams.get(`${prefix}.q`) !== null) return true;
// Column Filters
if (Filter.getFiltersFromParameters(getQueryParams(searchParams), prefix).length > 0) return true;
// Sorts
if (searchParams.get(`${prefix}.sort`) !== null) return true;
// Page offset
if (searchParams.get(`${prefix}.p`) !== null) return true;
// Page size
return searchParams.get(`${prefix}.pageSize`) !== null;
}
/**
* Creates a QueryModel ID for a given SchemaQuery. The id is just the SchemaQuery snake-cased as encoded
* schemaName.queryName.
*
* @param schemaQuery: SchemaQuery
*/
export function createQueryModelId(schemaQuery: SchemaQuery): string {
const { schemaName, queryName } = schemaQuery;
return `${encodePart(schemaName)}.${encodePart(queryName)}`;
}
const sortStringMapper = (s: QuerySort): string => s.toRequestString();
export interface GridMessage {
area?: string;
content: string;
type?: string;
}
export enum SavedSettings {
all = 'all', // Restores filters, maxRows, sorts, and view
noFilters = 'noFilters', // Restores maxRows and sorts only
none = 'none',
}
export interface QueryConfig {
/**
* An array of base [Filter.IFilter](https://labkey.github.io/labkey-api-js/interfaces/Filter.IFilter.html)
* filters to be applied to the [[QueryModel]] data load. These base filters will be concatenated with URL filters,
* the keyValue filter, and view filters when applicable.
*/
baseFilters?: Filter.IFilter[];
/**
* Flag used to indicate whether filters/sorts/etc. should be persisted on the URL. Defaults to false.
*/
bindURL?: boolean;
/**
* One of the values of [Query.ContainerFilter](https://labkey.github.io/labkey-api-js/enums/Query.ContainerFilter.html)
* that sets the scope of this query. Defaults to ContainerFilter.current, and is interpreted relative to
* config.containerPath.
*/
containerFilter?: Query.ContainerFilter;
/**
* The path to the container in which the schema and query are defined, if different than the current container.
* If not supplied, the current container's path will be used.
*/
containerPath?: string;
/**
* An array of [Filter.IFilter](https://labkey.github.io/labkey-api-js/interfaces/Filter.IFilter.html)
* filters to be applied to the QueryModel data load. These filters will be concatenated with base filters, URL filters,
* they keyValue filter, and view filters when applicable.
*/
readonly filterArray?: Filter.IFilter[];
/**
* Id value to use for referencing a given [[QueryModel]]. If not provided, one will be generated for this [[QueryModel]]
* instance based on the [[SchemaQuery]] and keyValue where applicable.
*/
id?: string;
/**
* Include the Details link column in the set of columns (defaults to false). If included, the column will
* have the name "\~\~Details\~\~". The underlying table/query must support details links or the column will
* be omitted in the response.
*/
includeDetailsColumn?: boolean;
/**
* Include the total count in the query model via a second query to the server for this value.
* This second query will be made in parallel with the initial query to get the model data.
*/
includeTotalCount?: boolean;
/**
* Include the Update (or edit) link column in the set of columns (defaults to false). If included, the column
* will have the name "\~\~Update\~\~". The underlying table/query must support update links or the column
* will be omitted in the response.
*/
includeUpdateColumn?: boolean;
/**
* Primary key value, used when loading/rendering details pages to get a single row of data in a [[QueryModel]].
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
keyValue?: any;
/**
* The maximum number of rows to return from the server (defaults to 100000).
* If you want to return all possible rows, set this config property to -1.
*/
maxRows?: number;
/**
* The index of the first row to return from the server (defaults to 0). Use this along with the
* maxRows config property to request pages of data.
*/
offset?: number;
/**
* Array of column names to be explicitly excluded from the column list in the [[QueryModel]] data load.
*/
omittedColumns?: string[];
/**
* Query parameters used as input to a parameterized query.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryParameters?: Record<string, any>;
/**
* Array of column names to be explicitly included in the column list in the [[QueryModel]] data load.
*/
requiredColumns?: string[];
/**
* Request requiredColumns as "fields" property on query-getQueryDetails.api.
* This will ensure the required columns are available on the queryInfo. Defaults to false.
*/
requiredColumnsAsQueryInfoFields?: boolean;
/**
* Definition of the [[SchemaQuery]] (i.e. schema, query, and optionally view name) to use for the [[QueryModel]] data load.
*/
schemaQuery: SchemaQuery;
/**
* The path to the container in which to make selection changes on the server.
* If not supplied, the "containerPath" will be used.
*/
selectionContainerPath?: string;
/**
* Array of [[QuerySort]] objects to use for the [[QueryModel]] data load.
*/
sorts?: QuerySort[];
/**
* String value to use in grid panel header.
*/
title?: string;
/**
* Prefix string value to use in url parameters when bindURL is true. Defaults to "query".
*/
urlPrefix?: string;
/**
* Used to optionally load saved settings from localStorage when initially loading the model, but only if there are
* no settings on the URL.
* - 'all' will load filters, sorts, pageSize, and viewName
* - 'noFilters' will load sorts and pageSize
* - 'none' disables this feature
*
* Important: If you are using this flag you must ensure your grid id is stable and unique. It must be stable
* between page loads/visits, or we won't be able to fetch the settings. It must be unique, or we'll override other
* grid models.
*/
useSavedSettings?: SavedSettings;
}
export const DEFAULT_OFFSET = 0;
export const DEFAULT_MAX_ROWS = 20;
/**
* An object that describes the current selection pivot row for shift-select behavior. When a single row is selected
* it becomes the "pivot" row for shift-select behavior. Subsequently, if a user selects another row while holding
* the shift key then all rows between the pivot row and the newly selected row will be selected/deselected.
*/
export interface SelectionPivot {
checked: boolean;
selection: string;
}
/**
* This is the base model used to store all the data for a query. At a high level the QueryModel API is a wrapper around
* the [selectRows](https://labkey.github.io/labkey-api-js/modules/Query.html#selectRows) API.
* If you need to retrieve data from a LabKey table or query, so you can render it in a React
* component, then the QueryModel API is most likely what you want.
*
* This model stores some client-side only data as well as data retrieved from the server. You can manually instantiate a
* QueryModel, but you will almost never do this, instead you will use the [[withQueryModels]] HOC to inject the needed
* QueryModel(s) into your component. To create a QueryModel you will need to define a [[QueryConfig]] object. At a
* minimum, your [[QueryConfig]] must have a valid [[SchemaQuery]], but we also support many other attributes that
* allow you to configure the model before it is loaded, all of the attributes can be found on the [[QueryConfig]]
* interface.
*/
export class QueryModel {
/**
* @hidden
*/
[immerable] = true;
// Fields from QueryConfig
// Some of the fields we have in common with QueryConfig are not optional because we give them default values.
/**
* An array of base [Filter.IFilter](https://labkey.github.io/labkey-api-js/interfaces/Filter.IFilter.html)
* filters to be applied to the QueryModel data load. These base filters will be concatenated with URL filters,
* they keyValue filter, and view filters when applicable.
*/
readonly baseFilters: Filter.IFilter[];
/**
* Flag used to indicate whether or not filters/sorts/etc. should be persisted on the URL. Defaults to false.
*/
readonly bindURL: boolean;
/**
* One of the values of [Query.ContainerFilter](https://labkey.github.io/labkey-api-js/enums/Query.ContainerFilter.html)
* that sets the scope of this query. Defaults to ContainerFilter.current, and is interpreted relative to
* config.containerPath.
*/
readonly containerFilter?: Query.ContainerFilter;
/**
* The path to the container in which the schema and query are defined, if different than the current container.
* If not supplied, the current container's path will be used.
*/
readonly containerPath?: string;
/**
* Id value to use for referencing a given QueryModel. If not provided, one will be generated for this QueryModel
* instance based on the [[SchemaQuery]] and keyValue where applicable.
*/
readonly id: string;
/**
* Include the Details link column in the set of columns (defaults to false). If included, the column will
* have the name "\~\~Details\~\~". The underlying table/query must support details links or the column will
* be omitted in the response.
*/
readonly includeDetailsColumn: boolean;
/**
* Include the Update (or edit) link column in the set of columns (defaults to false). If included, the column
* will have the name "\~\~Update\~\~". The underlying table/query must support update links or the column
* will be omitted in the response.
*/
readonly includeUpdateColumn: boolean;
/**
* Include the total count in the query model via a second query to the server for this value.
* This second query will be made in parallel with the initial query to get the model data.
*/
readonly includeTotalCount: boolean;
/**
* Primary key value, used when loading/rendering details pages to get a single row of data in a QueryModel.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly keyValue?: any;
/**
* The maximum number of rows to return from the server (defaults to 20).
* If you want to return all possible rows, set this config property to -1.
*/
readonly maxRows: number;
/**
* The index of the first row to return from the server (defaults to 0). Use this along with the
* maxRows config property to request pages of data.
*/
readonly offset: number;
/**
* Array of column names to be explicitly excluded from the column list in the QueryModel data load.
*/
readonly omittedColumns: string[];
/**
* Query parameters used as input to a parameterized query.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly queryParameters?: Record<string, any>;
/**
* Array of column names to be explicitly included from the column list in the QueryModel data load.
*/
readonly requiredColumns: string[];
/**
* Request requiredColumns as "fields" property on query-getQueryDetails.api.
* This will ensure the required columns are available on the queryInfo. Defaults to false.
*/
readonly requiredColumnsAsQueryInfoFields?: boolean;
/**
* Definition of the [[SchemaQuery]] (i.e. schema, query, and optionally view name) to use for the QueryModel data load.
*/
readonly schemaQuery: SchemaQuery;
readonly selectionContainerPath?: string;
/**
* Array of [[QuerySort]] objects to use for the QueryModel data load.
*/
readonly sorts: QuerySort[];
/**
* String value to use in grid panel header.
*/
readonly title?: string;
/**
* Prefix string value to use in url parameters when bindURL is true. Defaults to "query".
*/
readonly urlPrefix?: string;
/**
* Used to optionally load saved settings from localStorage when initially loading the model, but only if there are
* no settings on the URL.
* - 'all' will load filters, sorts, pageSize, and viewName
* - 'noFilters' will load sorts and pageSize
* - 'none' disables this feature
*
* Important: If you are using this flag you must ensure your grid id is stable and unique. It must be stable
* between page loads/visits, or we won't be able to fetch the settings. It must be unique, or we'll override other
* grid models.
*/
readonly useSavedSettings?: SavedSettings;
/**
* An array of [Filter.IFilter](https://labkey.github.io/labkey-api-js/interfaces/Filter.IFilter.html)
* filters to be applied to the QueryModel data load. These filters will be concatenated with base filters, URL filters,
* they keyValue filter, and view filters when applicable.
*/
readonly filterArray: Filter.IFilter[];
// QueryModel only fields
/**
* Array of [[GridMessage]]. When used with a [[GridPanel]], these message will be shown above the table of data rows.
*/
readonly messages?: GridMessage[];
/**
* Array of row key values in sort order from the loaded data rows object.
*/
readonly orderedRows?: string[];
/**
* [[QueryInfo]] object for the given QueryModel.
*/
readonly queryInfo?: QueryInfo;
/**
* Error message from API call to load the query info.
*/
readonly queryInfoError?: string;
/**
* [[LoadingState]] for the API call to load the query info.
*/
readonly queryInfoLoadingState: LoadingState;
/**
* Object containing the data rows loaded for the given QueryModel. The object key is the primary key value for the row
* and the object values is the row values for the given key.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly rows?: Record<string, any>;
/**
* The count of rows returned from the query for the given QueryModel. If includeTotalCount is true, this will
* be the total count of rows for the query parameters.
*/
readonly rowCount?: number;
/**
* Error message from API call to load the data rows.
*/
readonly rowsError?: string;
/**
* [[LoadingState]] for the API call to load the data rows.
*/
readonly rowsLoadingState: LoadingState;
/**
* ReportId, from the URL, to be used for showing a chart via the [[ChartMenu]].
*/
readonly selectedReportIds: string[];
/**
* [[SelectionPivot]] object that describes the current selection pivot row for shift-select behavior.
*/
readonly selectionPivot?: SelectionPivot;
/**
* Array of row keys for row selections in the QueryModel.
*/
readonly selections?: Set<string>; // Note: ES6 Set is being used here, not Immutable Set.
/**
* Error message from API call to load the row selections.
*/
readonly selectionsError?: string;
/**
* [[LoadingState]] for the API call to load the row selections.
*/
readonly selectionsLoadingState: LoadingState;
/**
* Error message from API call to load the total count.
*/
readonly totalCountError?: string;
/**
* [[LoadingState]] for the API call to load the total count.
*/
readonly totalCountLoadingState: LoadingState;
/**
* Array of [[DataViewInfo]] objects that define the charts attached to the given QueryModel.
*/
readonly charts: DataViewInfo[];
/**
* Error message from API call to load the chart definitions.
*/
readonly chartsError: string;
/**
* [[LoadingState]] for the API call to load the chart definitions.
*/
readonly chartsLoadingState: LoadingState;
/**
* Error message from initial API call to be retained for retry (i.e. for a requested view that does not exist or calculated field expressions causing errors).
*/
readonly viewError: string;
/**
* Constructor which takes a [[QueryConfig]] definition and creates a new QueryModel, applying default values
* to those properties not defined in the [[QueryConfig]]. Note that we default to the Details view if we have a
* keyValue and the user hasn't specified a view.
* @param queryConfig
*/
constructor(queryConfig: QueryConfig) {
const { schemaQuery, keyValue } = queryConfig;
this.baseFilters = queryConfig.baseFilters ?? [];
this.containerFilter = queryConfig.containerFilter;
this.containerPath = queryConfig.containerPath;
this.selectionContainerPath = queryConfig.selectionContainerPath ?? this.containerPath;
// Even though this is a situation that we shouldn't be in due to the type annotations it's still possible
// due to conversion from any, and it's best to have a specific error than an error due to undefined later
// when we try to use the model during an API request.
if (schemaQuery === undefined) {
throw new Error('schemaQuery is required to instantiate a QueryModel');
}
// Default to the Details view if we have a keyValue and the user hasn't specified a view.
// Note: this default may not be appropriate outside of Biologics/SM
if (keyValue !== undefined && schemaQuery.viewName === undefined) {
const { schemaName, queryName } = schemaQuery;
this.schemaQuery = new SchemaQuery(schemaName, queryName, ViewInfo.DETAIL_NAME);
this.bindURL = false;
} else {
this.schemaQuery = schemaQuery;
this.bindURL = queryConfig.bindURL ?? false;
}
this.id = queryConfig.id ?? createQueryModelId(this.schemaQuery);
this.includeDetailsColumn = queryConfig.includeDetailsColumn ?? false;
this.includeUpdateColumn = queryConfig.includeUpdateColumn ?? false;
this.includeTotalCount = queryConfig.includeTotalCount ?? false;
this.keyValue = queryConfig.keyValue;
this.maxRows = queryConfig.maxRows ?? DEFAULT_MAX_ROWS;
this.offset = queryConfig.offset ?? DEFAULT_OFFSET;
this.omittedColumns = queryConfig.omittedColumns ?? [];
this.queryParameters = queryConfig.queryParameters;
this.requiredColumns = queryConfig.requiredColumns ?? [];
this.requiredColumnsAsQueryInfoFields = queryConfig.requiredColumnsAsQueryInfoFields ?? false;
this.sorts = queryConfig.sorts ?? [];
this.rowsError = undefined;
this.filterArray = queryConfig.filterArray ?? [];
this.messages = [];
this.queryInfo = undefined;
this.queryInfoError = undefined;
this.queryInfoLoadingState = LoadingState.INITIALIZED;
this.orderedRows = undefined;
this.rows = undefined;
this.rowCount = undefined;
this.rowsLoadingState = LoadingState.INITIALIZED;
this.selectedReportIds = [];
this.selectionPivot = undefined;
this.selections = undefined;
this.selectionsError = undefined;
this.selectionsLoadingState = LoadingState.INITIALIZED;
this.title = queryConfig.title;
this.totalCountError = undefined;
this.totalCountLoadingState = LoadingState.INITIALIZED;
this.urlPrefix = queryConfig.urlPrefix ?? 'query'; // match Data Region defaults
this.useSavedSettings = queryConfig.useSavedSettings ?? SavedSettings.none;
this.charts = undefined;
this.chartsError = undefined;
this.chartsLoadingState = LoadingState.INITIALIZED;
this.viewError = undefined;
}
get schemaName(): string {
return this.schemaQuery.schemaName;
}
get queryName(): string {
return this.schemaQuery.queryName;
}
get viewName(): string {
return this.schemaQuery.viewName;
}
get currentView(): ViewInfo {
return this.queryInfo?.getView(this.viewName, true);
}
/**
* Array of [[QueryColumn]] objects from the [[QueryInfo]] "\~\~DETAILS\~\~" view. This will exclude those columns listed
* in omittedColumns.
*/
get detailColumns(): QueryColumn[] {
return this.queryInfo?.getDetailDisplayColumns(ViewInfo.DETAIL_NAME, this.omittedColumns);
}
/**
* Array of [[QueryColumn]] objects from the [[QueryInfo]] view. This will exclude those columns listed
* in omittedColumns.
*/
get displayColumns(): QueryColumn[] {
return this.queryInfo?.getDisplayColumns(this.viewName, this.omittedColumns);
}
// Issue 50607: Updated field labels are not shown in the grid if the default grid view has been changed.
getCustomViewTitleOverride(column: QueryColumn): string {
const label = column.customViewTitle;
const originalCol = this.queryInfo.getColumn(column.fieldKey);
if (!originalCol || originalCol.caption !== label) return label;
return '';
}
/**
* Array of all [[QueryColumn]] objects from the [[QueryInfo]] view. This will exclude those columns listed
* in omittedColumns.
*/
get allColumns(): QueryColumn[] {
return this.queryInfo?.getAllColumns(this.viewName, this.omittedColumns);
}
/**
* Array of [[QueryColumn]] objects from the [[QueryInfo]] "\~\~UPDATE\~\~" view. This will exclude those columns listed
* in omittedColumns.
*/
get updateColumns(): QueryColumn[] {
return this.queryInfo?.getUpdateDisplayColumns(ViewInfo.UPDATE_NAME, this.omittedColumns);
}
/**
* Array of primary key [[QueryColumn]] objects from the [[QueryInfo]].
*/
get keyColumns(): QueryColumn[] {
return this.queryInfo?.getPkCols();
}
get uniqueIdColumns(): QueryColumn[] {
return this.allColumns.filter(column => column.isUniqueIdColumn);
}
/**
* @hidden
*
* Get an array of filters to use for the details view, which includes the base filters but explicitly excludes
* the "replaced" column filter for the assay run case. For internal use only.
*
* Issue 39765: When viewing details for assays, we need to apply an "is not blank" filter on the "Replaced" column
* in order to see replaced assay runs. So this is the one case (we know of) where we want to apply base filters
* when viewing details since the default view restricts the set of items found.
*
* Applying other base filters will be problematic (Issue 39719) in that they could possibly exclude the row you are
* trying to get details for.
*/
get detailFilters(): Filter.IFilter[] {
return this.baseFilters.filter(filter => filter.getColumnName().toLowerCase() === 'replaced');
}
getModelFilters(excludeViewFilters?: boolean): Filter.IFilter[] {
const { baseFilters, queryInfo, keyValue } = this;
if (!queryInfo) {
// Throw an error because this method is only used when making an API request, and if we don't have a
// QueryInfo then we're going to make a bad request. It's better to error here before hitting the server.
throw new Error('Cannot get filters, no QueryInfo available');
}
if (this.keyValue !== undefined) {
const pkFilter = [];
if (queryInfo.pkCols.length === 1) {
pkFilter.push(Filter.create(queryInfo.pkCols[0], keyValue));
} else {
// Note: This behavior of not throwing an error, and continuing despite not having a single PK column is
// inherited from QueryGridModel, we may want to rethink this before widely adopting this API.
const warning = 'Too many keys. Unable to filter for specific keyValue.';
console.warn(warning, queryInfo.pkCols);
}
return [...pkFilter, ...this.detailFilters];
}
if (excludeViewFilters) {
// Issue 49634: LKSM: Saving search filters in default grid view has odd behavior
const searchViewFilters = this.viewFilters.filter(f => f.getColumnName() === '*');
return [...baseFilters, ...searchViewFilters];
}
return [...baseFilters, ...this.viewFilters];
}
get modelFilters(): Filter.IFilter[] {
return this.getModelFilters(false);
}
get viewFilters(): Filter.IFilter[] {
const { queryInfo, viewName } = this;
if (!queryInfo) {
// Throw an error because this method is only used when making an API request, and if we don't have a
// QueryInfo then we're going to make a bad request. It's better to error here before hitting the server.
throw new Error('Cannot get filters, no QueryInfo available');
}
return [...queryInfo.getFilters(viewName)];
}
loadRowsFilters(excludeViewFilters?: boolean): Filter.IFilter[] {
const modelFilters = this.getModelFilters(excludeViewFilters);
if (this.keyValue !== undefined) return modelFilters;
return [...modelFilters, ...this.filterArray];
}
/**
* An array of [Filter.IFilter](https://labkey.github.io/labkey-api-js/interfaces/Filter.IFilter.html) objects
* for the QueryModel. If a keyValue is provided, this will be a filter on the primary key column concatenated with
* the detailFilters. Otherwise, this will be a concatenation of the baseFilters, filterArray, and [[QueryInfo]] view filters.
*/
get filters(): Filter.IFilter[] {
return this.loadRowsFilters(false);
}
/**
* Comma-delimited string of fieldKeys for requiredColumns, keyColumns, and displayColumns. If provided, the
* omittedColumns will be removed from this list.
*/
get columnString(): string {
const { queryInfo } = this;
if (!queryInfo) {
// Throw an error because this method is only used when making an API request, and if we don't have a
// QueryInfo then we're going to make a bad request. It's better to error here before hitting the server.
throw new Error('Cannot construct column string, no QueryInfo available');
}
return this.getRequestColumnsString();
}
getRequestColumnsString(requiredColumns?: string[], omittedColumns?: string[], isForUpdate?: boolean): string {
const _requiredColumns = requiredColumns ?? this.requiredColumns;
const _omittedColumns = omittedColumns ?? this.omittedColumns;
// Note: ES6 Set is being used here, not Immutable Set
const uniqueFieldKeys = new Set<string>();
this.keyColumns.forEach(col => uniqueFieldKeys.add(col.fieldKey));
this.uniqueIdColumns.forEach(col => uniqueFieldKeys.add(col.fieldKey));
// Issue 46478: Include update columns in requested columns to ensure values are available
if (isForUpdate) {
this.updateColumns.forEach(col => uniqueFieldKeys.add(col.fieldKey));
} else {
this.displayColumns.forEach(col => uniqueFieldKeys.add(col.fieldKey));
}
// add requiredColumns last so fieldKeys from QueryColumns are preferred, when there is a case difference
// For example, choose Ancestors/RegistryAndSources/Participant (from displayColumns) over Ancestors/RegistryAndSources/participant (from requiredColumns)
_requiredColumns?.forEach(col => uniqueFieldKeys.add(col));
let fieldKeys = Array.from(uniqueFieldKeys);
if (_omittedColumns.length) {
const lowerOmit = new Set(_omittedColumns.map(c => c.toLowerCase()));
fieldKeys = fieldKeys.filter(fieldKey => !lowerOmit.has(fieldKey.toLowerCase()));
}
// remove duplicate
const fieldKeysLc = new Set();
const fieldKeysCleaned = [];
fieldKeys.forEach(fieldKey => {
if (!fieldKeysLc.has(fieldKey.toLowerCase())) {
fieldKeysCleaned.push(fieldKey);
fieldKeysLc.add(fieldKey.toLowerCase());
}
});
return fieldKeysCleaned.join(',');
}
/**
* Comma-delimited string of fields that appear in an export. These are the same as the display columns but
* do not exclude omitted columns.
*/
get exportColumnString(): string {
return this.displayColumns.map(column => column.fieldKey).join(',');
}
/**
* An array of load-related errors on this model. This specifically targets errors related to initializing and/or
* loading data. Subsequent errors that can occur (e.g. charting errors, selection errors, etc) are not included
* as those are intended to be handled explicitly.
*/
get loadErrors(): string[] {
return [this.queryInfoError, this.rowsError].filter(e => !!e);
}
/**
* Comma-delimited string of sorts from the [[QueryInfo]] sorts property. If the view has defined sorts, they
* will be concatenated with the sorts property.
*/
get sortString(): string {
const { sorts, viewName, queryInfo } = this;
if (!queryInfo) {
// Throw an error because this method is only used when making an API request, and if we don't have a
// QueryInfo then we're going to make a bad request. It's better to error here before hitting the server.
throw new Error('Cannot construct sort string, no QueryInfo available');
}
let sortStrings = sorts.map(sortStringMapper);
const viewSorts = queryInfo.getSorts(viewName)?.map(sortStringMapper) ?? [];
if (viewSorts.length > 0) {
sortStrings = sortStrings.concat(viewSorts);
}
return sortStrings.join(',');
}
/**
* Returns the data needed for a <Grid /> component to render.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get gridData(): Record<string, any>[] {
const { selections } = this;
return this.orderedRows.map(value => {
const row = this.rows[value];
if (selections) {
return {
...row,
[GRID_SELECTION_INDEX]: selections.has(value),
};
}
return row;
});
}
/**
* Returns an object representing the query params of the model. Used when updating the URL when bindURL is set to
* true.
*/
get urlQueryParams(): Record<string, string> {
const { currentPage, urlPrefix, filterArray, maxRows, selectedReportIds, sorts, viewName } = this;
const filters = filterArray.filter(f => f.getColumnName() !== '*');
const searches = filterArray
.filter(f => f.getColumnName() === '*')
.map(f => f.getValue())
.join(';');
// ReactRouter location.query is typed as any.
const modelParams: Record<string, string> = {};
if (currentPage !== 1) {
modelParams[`${urlPrefix}.p`] = currentPage.toString(10);
}
if (maxRows !== DEFAULT_MAX_ROWS) {
modelParams[`${urlPrefix}.pageSize`] = maxRows.toString(10);
}
if (viewName !== undefined) {
modelParams[`${urlPrefix}.view`] = viewName;
}
if (sorts.length > 0) {
modelParams[`${urlPrefix}.sort`] = sorts.map(sortStringMapper).join(',');
}
if (searches.length > 0) {
modelParams[`${urlPrefix}.q`] = searches;
}
if (selectedReportIds.length > 0) {
modelParams[`${urlPrefix}.selectedReportIds`] = selectedReportIds.join(';');
}
filters.forEach((filter): void => {
modelParams[filter.getURLParameterName(urlPrefix)] = filter.getURLParameterValue();
});
return modelParams;
}
/**
* Gets a column by fieldKey.
* @param fieldKey: string
*/
getColumnByFieldKey(fieldKey: string): QueryColumn {
const locFieldKey = fieldKey.toLowerCase();
let col = this.allColumns?.find(c => c.fieldKey?.toLowerCase() === locFieldKey);
if (!col) {
col = this.allColumns?.filter(queryColumn => {
if (queryColumn.isLookup()) {
return queryColumn.displayField?.toLowerCase() === locFieldKey;
}
})?.[0];
}
return col;
}
/**
* Gets a column by name. Implementation adapted from parseColumns in grid/utils.ts.
* @param name: string
*/
getColumn(name: string): QueryColumn {
const lowered = name.toLowerCase();
const isLookup = lowered.indexOf('/') > -1;
const allColumns = this.allColumns;
// First find all possible matches by name/lookup
const columns = allColumns.filter(queryColumn => {
if (isLookup && queryColumn.isLookup()) {
return (
queryColumn.name.toLowerCase() === lowered || queryColumn.displayField?.toLowerCase() === lowered
);
}
return queryColumn.name.toLowerCase() === lowered;
});
// Use exact match first, else first possible match
let column = columns.find(c => c.name.toLowerCase() === lowered || c.displayField?.toLowerCase() === lowered);
if (column === undefined && columns.length > 0) {
column = columns[0];
}
if (column !== undefined) {
return column;
}
// Fallback to finding by shortCaption
return allColumns.find(column => {
return column.shortCaption.toLowerCase() === lowered;
});
}
/**
* Returns the data for the specified key parameter on the QueryModel.rows object.
* If no key parameter is provided, the first data row will be returned.
*/
getRow(key?: string): Record<string, any> | undefined {
if (!this.hasRows) {
return undefined;
}
if (key === undefined) {
key = this.orderedRows[0];
}
return this.rows[key];
}
/**
* Returns the value of a specific column in the first row.
* @param columnName Case insensitive name of the column.
*/
getRowValue(columnName: string): any {
return caseInsensitive(this.getRow(), columnName)?.value;
}
/**
* Get the total page count for the results rows in this QueryModel-based on the total row count and the
* max rows per page value.
*/
get pageCount(): number {
const { maxRows, rowCount } = this;
return maxRows > 0 ? Math.ceil(rowCount / maxRows) : 1;
}
/**
* Get the current page number based off of the results offset and max rows per page values.
*/
get currentPage(): number {
const { offset, maxRows } = this;
return offset > 0 ? Math.floor(offset / maxRows) + 1 : 1;
}
/**
* Get the last page offset value for the given QueryModel rows.
*/
get lastPageOffset(): number {
return (this.pageCount - 1) * this.maxRows;
}
/**
* An array of [[ViewInfo]] objects for the saved views for the given QueryModel. Note that the returned array
* will be sorted by view label.
*/
get views(): ViewInfo[] {
return this.queryInfo?.views.valueArray.sort(naturalSortByProperty('label')) || [];
}
/**
* An array of [[ViewInfo]] objects that are visible for a user to choose in the view menu. Note that the returned
* array will be sorted by view label.
*/
get visibleViews(): ViewInfo[] {
return this.queryInfo?.getVisibleViews();
}
/**
* True if data has been loaded, even if no rows were returned.
*/
get hasData(): boolean {
return this.rows !== undefined;
}
/** True if there are any load errors. */
get hasLoadErrors(): boolean {
return this.loadErrors.length > 0;
}
/**
* True if the model has > 0 rows.
*/
get hasRows(): boolean {
return this.hasData && Object.keys(this.rows).length > 0;
}
/**
* True if the charts have been loaded, even if there are no saved charts returned.
*/
get hasCharts(): boolean {
return this.charts !== undefined;
}
/**
* True if the QueryModel has row selections.
*/
get hasSelections(): boolean {
return this.selections?.size > 0;
}
/**
* Key to attach to selections, which are specific to a view