@isrd-isi-edu/ermrestjs 2.0.1 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ERMrestJS [![Build Status](https://github.com/informatics-isi-edu/ermrestjs/actions/workflows/unit-test.yml/badge.svg?branch=master)](https://github.com/informatics-isi-edu/ermrestjs/actions?query=workflow%3A%22ERmrestJS+tests%22+branch%3Amaster)
2
2
 
3
- ERMrestJS is a javascript client library for interacting with the [ERMrest](http://github.com/informatics-isi-edu/ermrest) service. It provides higher-level, simplified application programming interfaces (APIs) for working with the Entity-Relationship concepts native to ERMrest.
3
+ ERMrestJS is a javascript client library for interacting with the [ERMrest](http://github.com/informatics-isi-edu/ermrest) service. It provides higher-level, simplified application programming interfaces (APIs) for working with the Entity-Relationship concepts native to ERMrest.
4
4
 
5
5
  The library has been extended to also support [Hatrac](https://github.com/informatics-isi-edu/hatrac) (an object store service), and [deriva-web export](https://github.com/informatics-isi-edu/deriva-web). ERMrestJS is a part of [Deriva Platform](http://isrd.isi.edu/deriva).
6
6
 
@@ -29,7 +29,7 @@ Documents are categorized based on their audience.
29
29
 
30
30
  When developing new code for ERMrestJS, please make sure you're following these steps:
31
31
 
32
- 1. create a new branch and make your updates to the code in the branch (avoid changing the `master` branch directly);
32
+ 1. create a new branch and make your updates to the code in the branch (follow DO NOT change `master` branch directly and ensure your commit messages follow [the convetions described here](docs/dev-docs/dev-guide.md#commit-message-conventions));
33
33
  2. do your own quality assurance;
34
34
  3. update all relevant documentation (Please refer to [this page](docs/dev-docs/update-docs.md) for more information);
35
35
  4. update the unit tests (if applicable);
package/js/core.js CHANGED
@@ -481,7 +481,7 @@ import {
481
481
  // load the catalog (or use the one that is cached)
482
482
  this._get().then((response) => {
483
483
  this.snaptime = response.snaptime;
484
-
484
+
485
485
  let versionCorrected = false;
486
486
  if (isStringAndNotEmpty(this.version) && this.version !== this.snaptime) {
487
487
  this.version = this.snaptime;
@@ -4351,7 +4351,7 @@ import {
4351
4351
  var definitions = this._table.sourceDefinitions, wm = _warningMessages;
4352
4352
  var logErr = function (bool, message, i) {
4353
4353
  if (bool) {
4354
- $log.info("inbound foreignkeys list for table: " + self._table.name + ", context: " + context + ", fk index:" + i);
4354
+ $log.info(`vis-fk for table '${self._table.name}' in context '${context}' at index '${i}':`);
4355
4355
  $log.info(message);
4356
4356
  }
4357
4357
  return bool;
package/js/parser.js CHANGED
@@ -858,11 +858,12 @@ import HistoryService from '@isrd-isi-edu/ermrestjs/src/services/history';
858
858
 
859
859
  /**
860
860
  * The datetime iso string representation of the catalog version
861
- * @returns {String} the iso string or null if version is not specified
861
+ * @returns {String|null} the iso string or null if version is not specified
862
862
  */
863
863
  get versionAsISOString() {
864
864
  if (this._versionAsISOString === undefined) {
865
- this._versionAsISOString = HistoryService.snapshotToDatetimeISO(this._version, false);
865
+ this._versionAsISOString = HistoryService.snapshotToDatetimeISO(this._version, true);
866
+ if (this._versionAsISOString === '') this._versionAsISOString = null;
866
867
  }
867
868
  return this._versionAsISOString;
868
869
  },
@@ -972,7 +973,7 @@ import HistoryService from '@isrd-isi-edu/ermrestjs/src/services/history';
972
973
 
973
974
  /**
974
975
  * filter is converted to the last join table (if uri has join)
975
- * @returns {ParsedFilter} undefined if there is no filter
976
+ * @returns {ParsedFilter|undefined} undefined if there is no filter
976
977
  */
977
978
  get filter() {
978
979
  return this.lastPathPart ? this.lastPathPart.filter : undefined;
@@ -1059,7 +1060,7 @@ import HistoryService from '@isrd-isi-edu/ermrestjs/src/services/history';
1059
1060
 
1060
1061
  /**
1061
1062
  * facets object of the last path part
1062
- * @type {ParsedFacets} facets object
1063
+ * @type {ParsedFacets|undefined} facets object
1063
1064
  */
1064
1065
  get facets() {
1065
1066
  return this.lastPathPart ? this.lastPathPart.facets : undefined;
@@ -1084,7 +1085,7 @@ import HistoryService from '@isrd-isi-edu/ermrestjs/src/services/history';
1084
1085
 
1085
1086
  /**
1086
1087
  * the custom facet of the last path part
1087
- * @type {CustomFacets}
1088
+ * @type {CustomFacets|undefined}
1088
1089
  */
1089
1090
  get customFacets() {
1090
1091
  return this.lastPathPart ? this.lastPathPart.customFacets : undefined;
@@ -642,6 +642,10 @@ import AuthnService from '@isrd-isi-edu/ermrestjs/src/services/authn';
642
642
 
643
643
  /**
644
644
  * Given the source object and default comment props, will return the comment that should be used.
645
+ * @param {any} sourceObject the object that might have comment props
646
+ * @param {string=} defaultComment the default comment that should be used if sourceObject doesn't have comment
647
+ * @param {boolean=} defaultCommentRenderMd the default comment_render_markdown that should be used if sourceObject doesn't have comment_render_markdown
648
+ * @param {string=} defaultDisplayMode the default comment_display that should be used if sourceObject doesn't have comment_display
645
649
  * @returns {CommentType}
646
650
  * @private
647
651
  */
@@ -894,24 +894,61 @@ import { parse, _convertSearchTermToFilter } from '@isrd-isi-edu/ermrestjs/js/pa
894
894
  },
895
895
 
896
896
  /**
897
- * Given a source object wrapper, do we support the facet for it or not
898
- * @ignore
897
+ * Given a source definition object, it will return a SourceObjectWrapper that can be used as a facet object.
898
+ * It will return null if the source definition is not supported as a facet.
899
+ *
900
+ * NOTE:
901
+ * - this function will remove any filter defined in the source definition if hasFilterOrFacet is true.
902
+ * - might throw an error if the source definition is invalid.
903
+ *
904
+ *
905
+ * @param {any} obj the source definition object
906
+ * @param {Table} table the table that this source definition is based on
907
+ * @param {boolean} hasFilterOrFacet whether the url has any filter or facet defined. If this is true, we will remove any filter defined in the source definition.
908
+ *
909
+ * @throws {Error} if the source definition is invalid for facet
910
+ *
911
+ * @returns {SourceObjectWrapper} the source object wrapper that can be used as a facet object.
899
912
  */
900
- checkFacetObjectWrapper: function (facetObjectWrapper) {
901
- var col = facetObjectWrapper.column;
913
+ sourceDefToFacetObjectWrapper: function (obj, table, hasFilterOrFacet) {
914
+ let wrapper;
915
+ // if both source and sourcekey are defined, ignore the source and use sourcekey
916
+ if (obj.sourcekey) {
917
+ const sd = table.sourceDefinitions.getSource(obj.sourcekey);
918
+ if (!sd) {
919
+ throw new Error(_facetingErrors.invalidSourcekey);
920
+ }
921
+
922
+ wrapper = sd.clone(obj, table, true);
923
+ } else {
924
+ wrapper = new SourceObjectWrapper(obj, table, true);
925
+ }
926
+
927
+ const col = wrapper.column;
902
928
 
903
929
  // aggregate is not supported
904
- if (facetObjectWrapper.hasAggregate) {
905
- return false;
930
+ if (wrapper.hasAggregate) {
931
+ throw new Error(_facetingErrors.aggregateFnNowtAllowed);
906
932
  }
907
933
 
908
934
  // column type array is not supported
909
935
  if (col.type.isArray) {
910
- return false;
936
+ throw new Error(_facetingErrors.arrayColumnTypeNotSupported);
911
937
  }
912
938
 
913
939
  // check the column type
914
- return _facetUnsupportedTypes.indexOf(col.type.name) === -1;
940
+ if (_facetUnsupportedTypes.indexOf(col.type.name) !== -1) {
941
+ throw new Error(`Facet of column type '${col.type.name}' is not supported.`);
942
+ }
943
+
944
+ // if we have filters in the url, we will get the filters only from url
945
+ if (hasFilterOrFacet) {
946
+ delete wrapper.sourceObject.not_null;
947
+ delete wrapper.sourceObject.choices;
948
+ delete wrapper.sourceObject.search;
949
+ delete wrapper.sourceObject.ranges;
950
+ }
951
+ return wrapper;
915
952
  },
916
953
 
917
954
  /**
@@ -1003,7 +1040,10 @@ import { parse, _convertSearchTermToFilter } from '@isrd-isi-edu/ermrestjs/js/pa
1003
1040
  * NOTE: facetColumns MUST be only used in COMPACT_SELECT context
1004
1041
  * It doesn't feel right that I am doing contextualization in here,
1005
1042
  * it's something that should be in client.
1006
- * @ignore
1043
+ * @param {SourceObjectWrapper} facetObjectWrapper the facet object
1044
+ * @param {boolean} usedAnnotation the annotation that was used to create this facet (if any)
1045
+ * @param {Table} table the current table that we want to make sure the facetObject is valid for.
1046
+ * @returns {boolean} whether the facetObjectWrapper is valid for this table.
1007
1047
  */
1008
1048
  checkForAlternative: function (facetObjectWrapper, usedAnnotation, table) {
1009
1049
  var currTable = facetObjectWrapper.column.table;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@isrd-isi-edu/ermrestjs",
3
3
  "description": "ERMrest client library in JavaScript",
4
- "version": "2.0.1",
4
+ "version": "2.1.1",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
7
7
  "node": ">= 20.0.0",
@@ -19,12 +19,14 @@
19
19
  ],
20
20
  "scripts": {
21
21
  "build": "tsc && vite build",
22
+ "watch": "tsc --watch && vite build --watch",
22
23
  "pretest": "make deps-test && make dist-w-deps",
23
24
  "deploy": "make deploy",
24
25
  "test": "make test",
25
26
  "lint": "eslint src js --quiet",
26
27
  "lint-w-warn": "eslint src js",
27
- "format": "prettier --write src"
28
+ "format": "prettier --write src",
29
+ "prepare": "husky"
28
30
  },
29
31
  "repository": {
30
32
  "type": "git",
@@ -43,7 +45,7 @@
43
45
  "dependencies": {
44
46
  "@types/markdown-it": "^14.1.2",
45
47
  "@types/q": "^1.5.8",
46
- "axios": "1.12.2",
48
+ "axios": "1.13.2",
47
49
  "handlebars": "4.7.8",
48
50
  "lz-string": "^1.5.0",
49
51
  "markdown-it": "12.3.2",
@@ -52,25 +54,28 @@
52
54
  "mustache": "x",
53
55
  "q": "1.5.1",
54
56
  "spark-md5": "^3.0.0",
55
- "terser": "^5.43.1",
56
- "typescript": "~5.8.3",
57
+ "terser": "^5.44.1",
58
+ "typescript": "~5.9.3",
57
59
  "vite": "^6.4.1",
58
60
  "vite-plugin-compression2": "^2.2.1"
59
61
  },
60
62
  "devDependencies": {
63
+ "@commitlint/cli": "^20.2.0",
64
+ "@commitlint/config-conventional": "^20.2.0",
61
65
  "@eslint/js": "^9.36.0",
62
66
  "@isrd-isi-edu/ermrest-data-utils": "0.0.5",
67
+ "@semantic-release/git": "^10.0.1",
63
68
  "@types/node": "^24.6.1",
64
69
  "eslint": "^9.36.0",
65
70
  "eslint-config-prettier": "^10.1.8",
66
71
  "eslint-plugin-prettier": "^5.5.4",
67
- "file-api": "^0.10.4",
68
72
  "globals": "^16.4.0",
73
+ "husky": "^9.1.7",
69
74
  "jasmine": "2.5.3",
70
75
  "jasmine-expect": "3.7.1",
71
76
  "jasmine-spec-reporter": "2.5.0",
72
77
  "nock": "13.5.4",
73
- "prettier": "^3.6.2",
78
+ "prettier": "^3.7.4",
74
79
  "require-reload": "^0.2.2",
75
80
  "rollup-plugin-visualizer": "^6.0.3",
76
81
  "typescript-eslint": "^8.45.0",
@@ -18,6 +18,7 @@ import {
18
18
  KeyPseudoColumn,
19
19
  AssetPseudoColumn,
20
20
  InboundForeignKeyPseudoColumn,
21
+ type FacetGroup,
21
22
  type PseudoColumn,
22
23
  type ColumnAggregateFn,
23
24
  } from '@isrd-isi-edu/ermrestjs/src/models/reference-column';
@@ -124,8 +125,6 @@ export const resolve = async (uri: string, contextHeaderParams?: any): Promise<R
124
125
  // It should have been taken care by outer try but did not work
125
126
  const loc = parse(uri);
126
127
 
127
- console.log(loc.catalog);
128
-
129
128
  const server = ermrestFactory.getServer(loc.service, contextHeaderParams);
130
129
 
131
130
  const catalog = await server.catalogs.get(loc.catalog);
@@ -239,6 +238,7 @@ export class Reference {
239
238
  private _referenceColumns?: Array<VisibleColumn>;
240
239
  private _related?: Array<RelatedReference>;
241
240
  private _facetColumns?: Array<FacetColumn>;
241
+ private _facetColumnsStructure?: Array<number | FacetGroup>;
242
242
  private _activeList?: any;
243
243
  private _citation?: Citation | null;
244
244
  private _googleDatasetMetadata?: GoogleDatasetMetadata | null;
@@ -614,19 +614,37 @@ export class Reference {
614
614
  }
615
615
 
616
616
  /**
617
- * NOTE this will not map the entity choice pickers, use "generateFacetColumns" instead.
618
- * so directly using this is not recommended.
617
+ * Returns the list of facets.
618
+ *
619
+ * NOTE this will not map the entity choice pickers, make sure to call "generateFacetColumns" first.
619
620
  */
620
621
  get facetColumns(): FacetColumn[] {
621
622
  if (this._facetColumns === undefined) {
622
623
  const res = generateFacetColumns(this, true);
623
624
  if (!(res instanceof Promise)) {
624
625
  this._facetColumns = res.facetColumns;
626
+ this._facetColumnsStructure = res.facetColumnsStructure;
625
627
  }
626
628
  }
627
629
  return this._facetColumns!;
628
630
  }
629
631
 
632
+ /**
633
+ * An array of numbers and FacetGroup objects that represent the structure of facet columns.
634
+ * Each number indicates the number of facet columns in that group, while a FacetGroup object
635
+ * represents a nested group of facets.
636
+ */
637
+ get facetColumnsStructure(): Array<number | FacetGroup> {
638
+ if (this._facetColumnsStructure === undefined) {
639
+ const res = generateFacetColumns(this, true);
640
+ if (!(res instanceof Promise)) {
641
+ this._facetColumns = res.facetColumns;
642
+ this._facetColumnsStructure = res.facetColumnsStructure;
643
+ }
644
+ }
645
+ return this._facetColumnsStructure!;
646
+ }
647
+
630
648
  /**
631
649
  * Returns the facets that should be represented to the user.
632
650
  * It will return a promise resolved with the following object:
@@ -663,14 +681,25 @@ export class Reference {
663
681
  * });
664
682
  * ```
665
683
  */
666
- generateFacetColumns(): Promise<{ facetColumns: FacetColumn[]; issues: UnsupportedFilters | null }> {
684
+ generateFacetColumns(): Promise<{
685
+ facetColumns: FacetColumn[];
686
+ facetColumnsStructure: Array<number | FacetGroup>;
687
+ issues: UnsupportedFilters | null;
688
+ }> {
667
689
  return new Promise((resolve, reject) => {
668
- const p = generateFacetColumns(this, false) as Promise<{ facetColumns: FacetColumn[]; issues: UnsupportedFilters | null }>;
690
+ const p = generateFacetColumns(this, false) as Promise<{
691
+ facetColumns: FacetColumn[];
692
+ issues: UnsupportedFilters | null;
693
+ facetColumnsStructure: Array<number | FacetGroup>;
694
+ }>;
695
+
669
696
  p.then((res) => {
670
697
  this._facetColumns = res.facetColumns;
698
+ this._facetColumnsStructure = res.facetColumnsStructure;
671
699
  resolve(res);
672
700
  }).catch((err) => {
673
701
  this._facetColumns = [];
702
+ this._facetColumnsStructure = [];
674
703
  reject(err);
675
704
  });
676
705
  });
@@ -680,8 +709,9 @@ export class Reference {
680
709
  * This is only added so _applyFilters in facet-column can use it.
681
710
  * SHOULD NOT be used outside of this library.
682
711
  */
683
- manuallySetFacetColumns(facetCols: FacetColumn[]) {
712
+ manuallySetFacetColumns(facetCols: FacetColumn[], facetColsStructure: Array<number | FacetGroup>) {
684
713
  this._facetColumns = facetCols;
714
+ this._facetColumnsStructure = facetColsStructure;
685
715
  }
686
716
 
687
717
  /**
@@ -1195,11 +1225,11 @@ export class Reference {
1195
1225
 
1196
1226
  /**
1197
1227
  * Remove all the filters, facets, and custom-facets from the reference
1198
- * @param {boolean} sameFilter By default we're removing filters, if this is true filters won't be changed.
1199
- * @param {boolean} sameCustomFacet By default we're removing custom-facets, if this is true custom-facets won't be changed.
1200
- * @param {boolean} sameFacet By default we're removing facets, if this is true facets won't be changed.
1228
+ * @param sameFilter By default we're removing filters, if this is true filters won't be changed.
1229
+ * @param sameCustomFacet By default we're removing custom-facets, if this is true custom-facets won't be changed.
1230
+ * @param sameFacet By default we're removing facets, if this is true facets won't be changed.
1201
1231
  *
1202
- * @return {reference} A reference without facet filters
1232
+ * @return A reference without facet filters
1203
1233
  */
1204
1234
  removeAllFacetFilters(sameFilter?: boolean, sameCustomFacet?: boolean, sameFacet?: boolean) {
1205
1235
  verify(!(sameFilter && sameCustomFacet && sameFacet), 'at least one of the options must be false.');
@@ -1214,10 +1244,16 @@ export class Reference {
1214
1244
  // compute that logic again, those facets will disappear.
1215
1245
  newReference._facetColumns = [];
1216
1246
  this.facetColumns.forEach((fc) => {
1217
- newReference._facetColumns!.push(new FacetColumn(newReference, fc.index, fc.sourceObjectWrapper, sameFacet ? fc.filters.slice() : []));
1247
+ newReference._facetColumns!.push(
1248
+ new FacetColumn(newReference, fc.index, fc.sourceObjectWrapper, fc.groupIndex, sameFacet ? fc.filters.slice() : []),
1249
+ );
1250
+ });
1251
+ newReference._facetColumnsStructure = [];
1252
+ this.facetColumnsStructure.forEach((structure) => {
1253
+ newReference._facetColumnsStructure!.push(typeof structure === 'number' ? structure : structure.copy(newReference));
1218
1254
  });
1219
1255
 
1220
- // update the location objectcd
1256
+ // update the location object
1221
1257
  newReference._location = this._location._clone(newReference);
1222
1258
  newReference._location.beforeObject = null;
1223
1259
  newReference._location.afterObject = null;
@@ -1802,7 +1838,7 @@ export class Reference {
1802
1838
  * b) A single term with space using ""
1803
1839
  * c) use space for conjunction of terms
1804
1840
  */
1805
- search(term: string) {
1841
+ search(term?: string | null) {
1806
1842
  if (term) {
1807
1843
  if (typeof term === 'string') term = term.trim();
1808
1844
  else throw new InvalidInputError('Invalid input. Seach expects a string.');
@@ -1822,7 +1858,13 @@ export class Reference {
1822
1858
  if (this._facetColumns !== undefined) {
1823
1859
  newReference._facetColumns = [];
1824
1860
  this.facetColumns.forEach((fc) => {
1825
- newReference._facetColumns!.push(new FacetColumn(newReference, fc.index, fc.sourceObjectWrapper, fc.filters.slice()));
1861
+ newReference._facetColumns!.push(new FacetColumn(newReference, fc.index, fc.sourceObjectWrapper, fc.groupIndex, fc.filters.slice()));
1862
+ });
1863
+ }
1864
+ if (this._facetColumnsStructure !== undefined) {
1865
+ newReference._facetColumnsStructure = [];
1866
+ this.facetColumnsStructure.forEach((structure) => {
1867
+ newReference._facetColumnsStructure!.push(typeof structure === 'number' ? structure : structure.copy(newReference));
1826
1868
  });
1827
1869
  }
1828
1870
 
@@ -1859,7 +1901,13 @@ export class Reference {
1859
1901
  if (this._facetColumns !== undefined) {
1860
1902
  newReference._facetColumns = [];
1861
1903
  this.facetColumns.forEach((fc) => {
1862
- newReference._facetColumns!.push(new FacetColumn(newReference, fc.index, fc.sourceObjectWrapper, fc.filters.slice()));
1904
+ newReference._facetColumns!.push(new FacetColumn(newReference, fc.index, fc.sourceObjectWrapper, fc.groupIndex, fc.filters.slice()));
1905
+ });
1906
+ }
1907
+ if (this._facetColumnsStructure !== undefined) {
1908
+ newReference._facetColumnsStructure = [];
1909
+ this.facetColumnsStructure.forEach((structure) => {
1910
+ newReference._facetColumnsStructure!.push(typeof structure === 'number' ? structure : structure.copy(newReference));
1863
1911
  });
1864
1912
  }
1865
1913
 
@@ -1,7 +1,7 @@
1
1
  // models
2
2
  import SourceObjectWrapper from '@isrd-isi-edu/ermrestjs/src/models/source-object-wrapper';
3
3
  import type SourceObjectNode from '@isrd-isi-edu/ermrestjs/src/models/source-object-node';
4
- import { ReferenceColumn } from '@isrd-isi-edu/ermrestjs/src/models/reference-column';
4
+ import { FacetGroup, ReferenceColumn } from '@isrd-isi-edu/ermrestjs/src/models/reference-column';
5
5
  import type { CommentType } from '@isrd-isi-edu/ermrestjs/src/models/comment';
6
6
  import type { DisplayName } from '@isrd-isi-edu/ermrestjs/src/models/display-name';
7
7
  import { type Tuple, Reference } from '@isrd-isi-edu/ermrestjs/src/models/reference';
@@ -163,7 +163,8 @@ class NotNullFacetFilter {
163
163
 
164
164
  /**
165
165
  * @param {Reference} reference the reference that this FacetColumn blongs to.
166
- * @param {int} index The index of this FacetColumn in the list of facetColumns
166
+ * @param {number} index The index of this FacetColumn in the list of facetColumns
167
+ * @param {number} structureIndex The index of this FacetColumn in the structure array
167
168
  * @param {SourceObjectWrapper} facetObjectWrapper The filter object that this FacetColumn will be created based on
168
169
  * @param {?FacetFilter[]} filters Array of filters
169
170
  */
@@ -180,10 +181,14 @@ export class FacetColumn {
180
181
 
181
182
  /**
182
183
  * The index of facetColumn in the list of facetColumns
183
- * NOTE: Might not be needed
184
184
  */
185
185
  public index: number;
186
186
 
187
+ /**
188
+ * if part of the group, the index of that group in the facetColumnsStructure array
189
+ */
190
+ public groupIndex?: number;
191
+
187
192
  /**
188
193
  * A valid data-source path
189
194
  * NOTE: we're not validating this data-source, we assume that this is valid.
@@ -245,10 +250,17 @@ export class FacetColumn {
245
250
  private _choiceFilters?: ChoiceFacetFilter[];
246
251
  private _rangeFilters?: RangeFacetFilter[];
247
252
 
248
- constructor(reference: Reference, index: number, facetObjectWrapper: SourceObjectWrapper, filters?: Array<FacetFilter | NotNullFacetFilter>) {
253
+ constructor(
254
+ reference: Reference,
255
+ index: number,
256
+ facetObjectWrapper: SourceObjectWrapper,
257
+ groupIndex?: number,
258
+ filters?: Array<FacetFilter | NotNullFacetFilter>,
259
+ ) {
249
260
  this._column = facetObjectWrapper.column!;
250
261
  this.reference = reference;
251
262
  this.index = index;
263
+ this.groupIndex = groupIndex;
252
264
  this.dataSource = facetObjectWrapper.sourceObject.source;
253
265
  this.compressedDataSource = _compressSource(this.dataSource);
254
266
 
@@ -1326,6 +1338,7 @@ export class FacetColumn {
1326
1338
  const loc = this.reference.location;
1327
1339
  const newReference = this.reference.copy();
1328
1340
  const facets: FacetColumn[] = [];
1341
+ const facetColsStructure: Array<FacetGroup | number> = [];
1329
1342
 
1330
1343
  // create a new FacetColumn so it doesn't reference to the current FacetColum
1331
1344
  // TODO can be refactored
@@ -1337,9 +1350,9 @@ export class FacetColumn {
1337
1350
  let newFc: FacetColumn;
1338
1351
  this.reference.facetColumns.forEach((fc: FacetColumn) => {
1339
1352
  if (fc.index !== this.index) {
1340
- newFc = new FacetColumn(newReference, fc.index, fc.sourceObjectWrapper, fc.filters.slice() as FacetFilter[]);
1353
+ newFc = new FacetColumn(newReference, fc.index, fc.sourceObjectWrapper, fc.groupIndex, fc.filters.slice() as FacetFilter[]);
1341
1354
  } else {
1342
- newFc = new FacetColumn(newReference, this.index, this.sourceObjectWrapper, filters as FacetFilter[]);
1355
+ newFc = new FacetColumn(newReference, this.index, this.sourceObjectWrapper, this.groupIndex, filters as FacetFilter[]);
1343
1356
  }
1344
1357
 
1345
1358
  facets.push(newFc);
@@ -1349,7 +1362,12 @@ export class FacetColumn {
1349
1362
  }
1350
1363
  });
1351
1364
 
1352
- newReference.manuallySetFacetColumns(facets);
1365
+ // recreate the facetColumnsStructure so we don't have to recompute the whole thing.
1366
+ this.reference.facetColumnsStructure.forEach((structure) => {
1367
+ facetColsStructure.push(typeof structure === 'number' ? structure : structure.copy(newReference));
1368
+ });
1369
+
1370
+ newReference.manuallySetFacetColumns(facets, facetColsStructure);
1353
1371
  newReference.setLocation(this.reference.location._clone(newReference));
1354
1372
  newReference.location.beforeObject = null;
1355
1373
  newReference.location.afterObject = null;
@@ -0,0 +1,76 @@
1
+ import type { DisplayName } from '@isrd-isi-edu/ermrestjs/src/models/display-name';
2
+ import type { CommentType } from '@isrd-isi-edu/ermrestjs/src/models/comment';
3
+ import type { FacetObjectGroupWrapper } from '@isrd-isi-edu/ermrestjs/src/models/source-object-wrapper';
4
+ import type { Reference } from '@isrd-isi-edu/ermrestjs/src/models/reference';
5
+
6
+ import { _processSourceObjectComment } from '@isrd-isi-edu/ermrestjs/js/utils/helpers';
7
+
8
+ export class FacetGroup {
9
+ /**
10
+ * The reference that this facet blongs to
11
+ */
12
+ public reference: Reference;
13
+ /**
14
+ * the index of this group in the reference.facetColumnsStructure array
15
+ */
16
+ public structureIndex: number;
17
+ /**
18
+ * the facet object group wrapper from the source object
19
+ */
20
+ public facetObjectGroupWrapper: FacetObjectGroupWrapper;
21
+ /**
22
+ * the indices of the children facet columns in the facetColumns array
23
+ */
24
+ public children: Array<number>;
25
+
26
+ public displayname: DisplayName;
27
+ public comment: CommentType;
28
+
29
+ private _isOpen?: boolean;
30
+
31
+ constructor(reference: Reference, index: number, facetObjectGroupWrapper: FacetObjectGroupWrapper, children: Array<number>) {
32
+ this.reference = reference;
33
+ this.structureIndex = index;
34
+ this.facetObjectGroupWrapper = facetObjectGroupWrapper;
35
+ this.children = children;
36
+ this.displayname = facetObjectGroupWrapper.displayname;
37
+ this.comment = _processSourceObjectComment(facetObjectGroupWrapper.sourceObject);
38
+ }
39
+
40
+ /**
41
+ * whether the group should be opened by default or not
42
+ */
43
+ get isOpen(): boolean {
44
+ if (this._isOpen === undefined) {
45
+ // by default all groups are opened
46
+ let isOpen = true;
47
+
48
+ // if the source object has open property, use it
49
+ const sourceOpen = this.facetObjectGroupWrapper.sourceObject.open;
50
+ if (typeof sourceOpen === 'boolean') {
51
+ isOpen = sourceOpen;
52
+ }
53
+
54
+ // if any of the childrens have filters, the group should be opened
55
+ for (const child of this.facetObjectGroupWrapper.children) {
56
+ const facet = this.reference.facetColumns.find((fc) => fc.sourceObjectWrapper.name === child.name);
57
+ if (facet && facet.filters.length > 0) {
58
+ isOpen = true;
59
+ break;
60
+ }
61
+ }
62
+
63
+ this._isOpen = isOpen;
64
+ }
65
+ return this._isOpen;
66
+ }
67
+
68
+ /**
69
+ * creates a copy of this facet group for the new reference
70
+ * @param newReference the reference that the cloned facet group will belong to
71
+ * @returns a cloned facet group
72
+ */
73
+ copy(newReference?: Reference): FacetGroup {
74
+ return new FacetGroup(newReference ? newReference : this.reference, this.structureIndex, this.facetObjectGroupWrapper, this.children.slice());
75
+ }
76
+ }
@@ -4,6 +4,7 @@
4
4
  * other files must import from here and not directly from each individual class
5
5
  */
6
6
  export * from '@isrd-isi-edu/ermrestjs/src/models/reference-column/reference-column';
7
+ export * from '@isrd-isi-edu/ermrestjs/src/models/reference-column/facet-group';
7
8
  export * from '@isrd-isi-edu/ermrestjs/src/models/reference-column/facet-column';
8
9
  export * from '@isrd-isi-edu/ermrestjs/src/models/reference-column/pseudo-column';
9
10
  export * from '@isrd-isi-edu/ermrestjs/src/models/reference-column/foreign-key-pseudo-column';
@@ -1,22 +1,29 @@
1
1
  /* eslint-disable prettier/prettier */
2
2
 
3
3
  // models
4
- import SourceObjectNode from '@isrd-isi-edu/ermrestjs/src/models/source-object-node';
5
- import { ReferenceColumn } from '@isrd-isi-edu/ermrestjs/src/models/reference-column';
6
- import { Tuple, Reference } from '@isrd-isi-edu/ermrestjs/src/models/reference';
4
+ import type SourceObjectNode from '@isrd-isi-edu/ermrestjs/src/models/source-object-node';
5
+ import type { ReferenceColumn } from '@isrd-isi-edu/ermrestjs/src/models/reference-column';
6
+ import type { Tuple, Reference } from '@isrd-isi-edu/ermrestjs/src/models/reference';
7
+ import type { DisplayName } from '@isrd-isi-edu/ermrestjs/src/models/display-name';
7
8
 
8
9
  // services
9
- // import $log from '@isrd-isi-edu/ermrestjs/src/services/logger';
10
+ import $log from '@isrd-isi-edu/ermrestjs/src/services/logger';
10
11
 
11
12
  // utils
12
13
  import { isObjectAndNotNull, isStringAndNotEmpty } from '@isrd-isi-edu/ermrestjs/src/utils/type-utils';
13
- import { _contexts, _facetFilterTypes, _pseudoColAggregateFns, _sourceDefinitionAttributes, _warningMessages } from '@isrd-isi-edu/ermrestjs/src/utils/constants';
14
+ import {
15
+ _contexts,
16
+ _facetFilterTypes,
17
+ _pseudoColAggregateFns,
18
+ _sourceDefinitionAttributes,
19
+ _warningMessages,
20
+ } from '@isrd-isi-edu/ermrestjs/src/utils/constants';
14
21
  import { renderMarkdown } from '@isrd-isi-edu/ermrestjs/src/utils/markdown-utils';
15
22
  import { createPseudoColumn } from '@isrd-isi-edu/ermrestjs/src/utils/column-utils';
16
23
 
17
24
  // legacy imports that need to be accessed
18
- import { _sourceColumnHelpers } from '@isrd-isi-edu/ermrestjs/js/utils/pseudocolumn_helpers';
19
- import { Column, Table } from '@isrd-isi-edu/ermrestjs/js/core';
25
+ import { _facetColumnHelpers, _sourceColumnHelpers } from '@isrd-isi-edu/ermrestjs/js/utils/pseudocolumn_helpers';
26
+ import type { Column, Table } from '@isrd-isi-edu/ermrestjs/js/core';
20
27
 
21
28
  export type FilterPropsType = {
22
29
  /**
@@ -68,7 +75,7 @@ export type InputIframePropsType = {
68
75
  /**
69
76
  * Represents a column-directive
70
77
  */
71
- class SourceObjectWrapper {
78
+ export default class SourceObjectWrapper {
72
79
  /**
73
80
  * the source object
74
81
  */
@@ -107,7 +114,7 @@ class SourceObjectWrapper {
107
114
  isFilterProcessed: true,
108
115
  hasRootFilter: false,
109
116
  hasFilterInBetween: false,
110
- leafFilterString: ''
117
+ leafFilterString: '',
111
118
  };
112
119
 
113
120
  /**
@@ -138,7 +145,6 @@ class SourceObjectWrapper {
138
145
  public isInputIframe = false;
139
146
  public inputIframeProps?: InputIframePropsType;
140
147
 
141
-
142
148
  /**
143
149
  * used for facets
144
150
  * TODO is there a better way to manage this?
@@ -152,6 +158,8 @@ class SourceObjectWrapper {
152
158
  * @param sources already generated source (only useful for source-def generation)
153
159
  * @param mainTuple the main tuple that is used for filters
154
160
  * @param skipProcessingFilters whether we should skip processing filters or not
161
+ *
162
+ * @throws Will throw an error if the source object is invalid.
155
163
  */
156
164
  constructor(
157
165
  sourceObject: Record<string, unknown>,
@@ -226,7 +234,7 @@ class SourceObjectWrapper {
226
234
  isFacet?: boolean,
227
235
  sources?: any,
228
236
  mainTuple?: Tuple,
229
- skipProcessingFilters?: boolean
237
+ skipProcessingFilters?: boolean,
230
238
  ): true | { error: boolean; message: string } {
231
239
  const sourceObject = this.sourceObject;
232
240
  const wm = _warningMessages;
@@ -270,7 +278,7 @@ class SourceObjectWrapper {
270
278
  table.schema.catalog.id,
271
279
  sources,
272
280
  mainTuple,
273
- skipProcessingFilters
281
+ skipProcessingFilters,
274
282
  ) as any;
275
283
 
276
284
  if (res.error) {
@@ -346,21 +354,13 @@ class SourceObjectWrapper {
346
354
 
347
355
  // generate name:
348
356
  // TODO maybe we shouldn't even allow aggregate in faceting (for now we're ignoring it)
349
- if (
350
- sourceObject.self_link === true ||
351
- this.isFiltered ||
352
- this.hasPath ||
353
- this.isEntityMode ||
354
- (isFacet !== true && this.hasAggregate)
355
- ) {
357
+ if (sourceObject.self_link === true || this.isFiltered || this.hasPath || this.isEntityMode || (isFacet !== true && this.hasAggregate)) {
356
358
  this.name = _sourceColumnHelpers.generateSourceObjectHashName(sourceObject, !!isFacet, sourceObjectNodes) as string;
357
359
 
358
360
  this.isHash = true;
359
361
 
360
362
  if (table.columns.has(this.name)) {
361
- return returnError(
362
- 'Generated Hash `' + this.name + '` for pseudo-column exists in table `' + table.name + '`.'
363
- );
363
+ return returnError('Generated Hash `' + this.name + '` for pseudo-column exists in table `' + table.name + '`.');
364
364
  }
365
365
  } else {
366
366
  this.name = col.name;
@@ -403,19 +403,12 @@ class SourceObjectWrapper {
403
403
  if (reverse) {
404
404
  return sn.toString(reverse, isLeft, outAlias, isReverseRightJoin);
405
405
  } else {
406
- return sn.toString(
407
- reverse,
408
- isLeft,
409
- this.foreignKeyPathLength === sn.nodeObject.foreignKeyPathLength ? outAlias : null,
410
- isReverseRightJoin
411
- );
406
+ return sn.toString(reverse, isLeft, this.foreignKeyPathLength === sn.nodeObject.foreignKeyPathLength ? outAlias : null, isReverseRightJoin);
412
407
  }
413
408
  }
414
409
 
415
410
  const fkStr = sn.toString(reverse, isLeft);
416
- const addAlias =
417
- outAlias &&
418
- ((reverse && sn === this.firstForeignKeyNode) || (!reverse && sn === this.lastForeignKeyNode));
411
+ const addAlias = outAlias && ((reverse && sn === this.firstForeignKeyNode) || (!reverse && sn === this.lastForeignKeyNode));
419
412
 
420
413
  // NOTE alias on each node is ignored!
421
414
  // currently we've added alias only for the association and
@@ -465,12 +458,7 @@ class SourceObjectWrapper {
465
458
  const sn = this.sourceObjectNodes[i];
466
459
  if (sn.isPathPrefix) {
467
460
  // if this is the last element, we have to add the alias to this
468
- path.push(
469
- ...sn.nodeObject.getRawSourcePath(
470
- reverse,
471
- this.foreignKeyPathLength == sn.nodeObject.foreignKeyPathLength ? outAlias : null
472
- )
473
- );
461
+ path.push(...sn.nodeObject.getRawSourcePath(reverse, this.foreignKeyPathLength == sn.nodeObject.foreignKeyPathLength ? outAlias : null));
474
462
  } else if (sn.isFilter) {
475
463
  path.push(sn.nodeObject);
476
464
  } else {
@@ -549,7 +537,11 @@ class SourceObjectWrapper {
549
537
  * @param usedIframeInputMappings an object capturing columns used in other mappings. used to avoid overlapping
550
538
  * @param tuple the tuple object
551
539
  */
552
- processInputIframe(reference: Reference, usedIframeInputMappings: any, tuple?: Tuple): {
540
+ processInputIframe(
541
+ reference: Reference,
542
+ usedIframeInputMappings: any,
543
+ tuple?: Tuple,
544
+ ): {
553
545
  success?: boolean;
554
546
  error?: boolean;
555
547
  message?: string;
@@ -595,20 +587,14 @@ class SourceObjectWrapper {
595
587
  const isSerial = c.type.name.indexOf('serial') === 0;
596
588
 
597
589
  // we cannot use getInputDisabled since we just want to do this based on ACLs
598
- if (
599
- context === _contexts.CREATE &&
600
- (c.isSystemColumn || c.isGeneratedPerACLs || isSerial)
601
- ) {
590
+ if (context === _contexts.CREATE && (c.isSystemColumn || c.isGeneratedPerACLs || isSerial)) {
602
591
  if (colName in optionalFieldNames) continue;
603
592
  return {
604
593
  error: true,
605
594
  message: 'column `' + colName + '` cannot be modified by this user.',
606
595
  };
607
596
  }
608
- if (
609
- (context === _contexts.EDIT || context === _contexts.ENTRY) &&
610
- (c.isSystemColumn || c.isImmutablePerACLs || isSerial)
611
- ) {
597
+ if ((context === _contexts.EDIT || context === _contexts.ENTRY) && (c.isSystemColumn || c.isImmutablePerACLs || isSerial)) {
612
598
  if (colName in optionalFieldNames) continue;
613
599
  return {
614
600
  error: true,
@@ -660,7 +646,10 @@ class SourceObjectWrapper {
660
646
  * @param mainTuple
661
647
  * @param dontThrowError if set to true, will not throw an error if the filters are not valid
662
648
  */
663
- processFilterNodes(mainTuple?: Tuple, dontThrowError?: boolean): {
649
+ processFilterNodes(
650
+ mainTuple?: Tuple,
651
+ dontThrowError?: boolean,
652
+ ): {
664
653
  success: boolean;
665
654
  error?: boolean;
666
655
  message?: string;
@@ -691,4 +680,65 @@ class SourceObjectWrapper {
691
680
  }
692
681
  }
693
682
 
694
- export default SourceObjectWrapper;
683
+ /**
684
+ * Represents a facet object group
685
+ */
686
+ export class FacetObjectGroupWrapper {
687
+ sourceObject: Record<string, unknown>;
688
+ children: SourceObjectWrapper[];
689
+ displayname: DisplayName;
690
+
691
+ /**
692
+ * Creates a new instance of FacetObjectGroupWrapper.
693
+ * Will throw an error if there was any issues in processing the source object.
694
+ *
695
+ * @param sourceObject The source object representing the facet group.
696
+ * @param table The table to which the facet group belongs.
697
+ * @param hasFilterOrFacet Indicates if the group has any filters or facets.
698
+ *
699
+ * @throws Will throw an error if the source object is invalid or has no valid children.
700
+ */
701
+ constructor(sourceObject: Record<string, unknown>, table: Table, hasFilterOrFacet: boolean) {
702
+ if (!Array.isArray(sourceObject.and) || sourceObject.and.length === 0) {
703
+ throw new Error('valid array of children is required');
704
+ }
705
+
706
+ this.sourceObject = sourceObject;
707
+
708
+ let displayname: DisplayName | null = null;
709
+ const rendered = renderMarkdown(
710
+ sourceObject.markdown_name && typeof sourceObject.markdown_name === 'string' ? sourceObject.markdown_name : '',
711
+ true,
712
+ );
713
+ if (rendered) {
714
+ displayname = {
715
+ value: rendered,
716
+ unformatted: sourceObject.markdown_name as string,
717
+ isHTML: true,
718
+ };
719
+ }
720
+
721
+ if (!displayname) {
722
+ throw new Error('valid markdown_name is required');
723
+ }
724
+
725
+ this.displayname = displayname;
726
+
727
+ const children: SourceObjectWrapper[] = [];
728
+ for (const child of sourceObject.and) {
729
+ try {
730
+ const wrapper = _facetColumnHelpers.sourceDefToFacetObjectWrapper(child, table, hasFilterOrFacet);
731
+ children.push(wrapper);
732
+ } catch (exp: unknown) {
733
+ $log.info(`child of facet group "${sourceObject.markdown_name}", index: ${children.length} is invalid:`);
734
+ $log.info((exp as Error).message);
735
+ }
736
+ }
737
+
738
+ if (children.length === 0) {
739
+ throw new Error('the group must have at least one valid child');
740
+ }
741
+
742
+ this.children = children;
743
+ }
744
+ }
@@ -81,8 +81,10 @@ class TableSourceDefinitions {
81
81
 
82
82
  /**
83
83
  * Get a source definition by its key.
84
+ * This will also make sure the filter nodes are processed and valid.
84
85
  * @param sourcekey The key of the source definition.
85
- * @returns The source definition or undefined if not found.
86
+ * @param mainTuple The main tuple to use for processing the filter nodes.
87
+ * @returns The source object wrapper or undefined if not found.
86
88
  */
87
89
  getSource(sourcekey: string, mainTuple?: Tuple): SourceObjectWrapper | undefined {
88
90
  const sd = this.sources[sourcekey];
@@ -353,6 +353,8 @@ export const _facetingErrors = Object.freeze({
353
353
  onlyOneNullFilter: 'Only one null filter is allowed in the facets',
354
354
  duplicateFacets: 'Cannot define two different sets of facets',
355
355
  invalidSourcekey: 'Given sourcekey string is not valid',
356
+ aggregateFnNowtAllowed: 'Aggregate functions are not allowed in facet source definition.',
357
+ arrayColumnTypeNotSupported: 'Facet of array column types are not supported.',
356
358
  });
357
359
 
358
360
  export const _HTTPErrorCodes = Object.freeze({
@@ -370,6 +372,7 @@ export const _HTTPErrorCodes = Object.freeze({
370
372
 
371
373
  export const _warningMessages = Object.freeze({
372
374
  NO_PSEUDO_IN_ENTRY: 'pseudo-columns are not allowed in entry contexts.',
375
+ INVALID_FACET_ENTRY: 'given value must be an object with either `source` or `sourcekey` defined.',
373
376
  INVALID_SOURCE: 'given object is invalid. `source` is required and it must be valid',
374
377
  INVALID_SOURCEKEY: 'given object is invalid. The defined `sourcekey` is invalid.',
375
378
  INVALID_VIRTUAL_NO_NAME: '`markdown_name` is required when `source` and `sourcekey` are undefiend.',
@@ -1,9 +1,10 @@
1
1
  // models
2
2
  import { InvalidPageCriteria, InvalidSortCriteria, UnsupportedFilters } from '@isrd-isi-edu/ermrestjs/src/models/errors';
3
- import SourceObjectWrapper from '@isrd-isi-edu/ermrestjs/src/models/source-object-wrapper';
3
+ import SourceObjectWrapper, { FacetObjectGroupWrapper } from '@isrd-isi-edu/ermrestjs/src/models/source-object-wrapper';
4
4
  import {
5
5
  AssetPseudoColumn,
6
6
  FacetColumn,
7
+ FacetGroup,
7
8
  ForeignKeyPseudoColumn,
8
9
  InboundForeignKeyPseudoColumn,
9
10
  KeyPseudoColumn,
@@ -61,7 +62,7 @@ export interface ReadPathResult {
61
62
  }
62
63
 
63
64
  export type ValidatedFacetFilters = {
64
- facetObjectWrappers: Array<SourceObjectWrapper>;
65
+ facetObjectWrappers: Array<SourceObjectWrapper | FacetObjectGroupWrapper>;
65
66
  newFilters: Array<any>;
66
67
  issues: UnsupportedFilters | null;
67
68
  };
@@ -522,6 +523,21 @@ export function generateColumnsList(reference: Reference | RelatedReference, tup
522
523
  const isCompact = typeof context === 'string' && context.startsWith(_contexts.COMPACT);
523
524
  const isCompactEntry = typeof context === 'string' && context.startsWith(_contexts.COMPACT_ENTRY);
524
525
 
526
+ // create a map of tableColumns to make it easier to find one
527
+ reference.table.columns.all().forEach((c: any) => {
528
+ tableColumns[c.name] = true;
529
+ });
530
+
531
+ // get columns from the input (used when we just want to process the given list of columns)
532
+ let columns: unknown = -1;
533
+ if (Array.isArray(columnsList)) {
534
+ columns = columnsList;
535
+ }
536
+ // get column orders from annotation
537
+ else if (reference.table.annotations.contains(_annotations.VISIBLE_COLUMNS)) {
538
+ columns = _getRecursiveAnnotationValue(reference.context, reference.table.annotations.get(_annotations.VISIBLE_COLUMNS).content);
539
+ }
540
+
525
541
  // check if we should hide some columns or not.
526
542
  // NOTE: if the reference is actually an inbound related reference, we should hide the foreign key that created reference link.
527
543
  const hasOrigFKRToHide = typeof context === 'string' && context.startsWith(_contexts.COMPACT_BRIEF) && isObjectAndNotNull(hiddenFKR);
@@ -596,29 +612,15 @@ export function generateColumnsList(reference: Reference | RelatedReference, tup
596
612
  };
597
613
 
598
614
  const wm = _warningMessages;
599
- const logCol = (bool: boolean, message: string, index?: number) => {
615
+ const logCol = (bool: boolean, message: string, index: number) => {
600
616
  if (bool && !skipLog) {
601
- $log.info(`columns list for table: ${reference.table.name}, context: ${context}, column index:${index}`);
617
+ $log.info(`vis-col for table '${reference.table.name}' in context '${context}' at index '${index}':`);
602
618
  $log.info(message);
619
+ // we could log the object too, but it might be too verbose
603
620
  }
604
621
  return bool;
605
622
  };
606
623
 
607
- // create a map of tableColumns to make it easier to find one
608
- reference.table.columns.all().forEach((c: any) => {
609
- tableColumns[c.name] = true;
610
- });
611
-
612
- // get columns from the input (used when we just want to process the given list of columns)
613
- let columns: unknown = -1;
614
- if (Array.isArray(columnsList)) {
615
- columns = columnsList;
616
- }
617
- // get column orders from annotation
618
- else if (reference.table.annotations.contains(_annotations.VISIBLE_COLUMNS)) {
619
- columns = _getRecursiveAnnotationValue(reference.context, reference.table.annotations.get(_annotations.VISIBLE_COLUMNS).content);
620
- }
621
-
622
624
  // annotation
623
625
  if (columns !== -1 && Array.isArray(columns)) {
624
626
  for (let i = 0; i < columns.length; i++) {
@@ -724,7 +726,7 @@ export function generateColumnsList(reference: Reference | RelatedReference, tup
724
726
  wrapper = new SourceObjectWrapper(col, reference.table, false, undefined, tuple);
725
727
  }
726
728
  } catch (exp: unknown) {
727
- logCol(true, wm.INVALID_SOURCE + ': ' + (exp as Error).message, i);
729
+ logCol(true, (exp as Error).message, i);
728
730
  continue;
729
731
  }
730
732
 
@@ -754,7 +756,7 @@ export function generateColumnsList(reference: Reference | RelatedReference, tup
754
756
  i,
755
757
  ) ||
756
758
  logCol(wrapper.hasAggregate && isEntry, wm.NO_AGG_IN_ENTRY, i) ||
757
- logCol(wrapper.isUniqueFiltered, wm.FILTER_NO_PATH_NOT_ALLOWED) ||
759
+ logCol(wrapper.isUniqueFiltered, wm.FILTER_NO_PATH_NOT_ALLOWED, i) ||
758
760
  logCol(
759
761
  isEntry && wrapper.hasPath && (wrapper.hasInbound || wrapper.isFiltered || wrapper.foreignKeyPathLength > 1),
760
762
  wm.NO_PATH_IN_ENTRY,
@@ -1043,13 +1045,16 @@ export function generateColumnsList(reference: Reference | RelatedReference, tup
1043
1045
  export function generateFacetColumns(
1044
1046
  reference: Reference,
1045
1047
  skipMappingEntityChoices: boolean,
1046
- ): Promise<{ facetColumns: FacetColumn[]; issues: UnsupportedFilters | null }> | { facetColumns: FacetColumn[]; issues: UnsupportedFilters | null } {
1048
+ ):
1049
+ | Promise<{ facetColumns: FacetColumn[]; issues: UnsupportedFilters | null; facetColumnsStructure: Array<number | FacetGroup> }>
1050
+ | { facetColumns: FacetColumn[]; issues: UnsupportedFilters | null; facetColumnsStructure: Array<number | FacetGroup> } {
1047
1051
  const andOperator = _FacetsLogicalOperators.AND;
1052
+ const addedGroups: Record<string, boolean> = {};
1048
1053
  let searchTerm = reference.location.searchTerm;
1049
1054
  const helpers = _facetColumnHelpers;
1050
1055
 
1051
1056
  // if location has facet or filter, we should honor it and we should not add preselected facets in annotation
1052
- const hasFilterOrFacet = reference.location.facets || reference.location.filter || reference.location.customFacets;
1057
+ const hasFilterOrFacet = !!reference.location.facets || !!reference.location.filter || !!reference.location.customFacets;
1053
1058
 
1054
1059
  const andFilters = reference.location.facets ? reference.location.facets.andFilters : [];
1055
1060
  // change filters to facet NOTE can be optimized to actually merge instead of just appending to list
@@ -1060,7 +1065,7 @@ export function generateFacetColumns(
1060
1065
 
1061
1066
  let annotationCols: any = -1;
1062
1067
  let usedAnnotation = false;
1063
- const facetObjectWrappers: any[] = [];
1068
+ const facetObjectWrappers: Array<SourceObjectWrapper | FacetObjectGroupWrapper> = [];
1064
1069
 
1065
1070
  // get column orders from annotation
1066
1071
  if (reference.table.annotations.contains(_annotations.VISIBLE_COLUMNS)) {
@@ -1072,10 +1077,21 @@ export function generateFacetColumns(
1072
1077
  }
1073
1078
  }
1074
1079
 
1080
+ const wm = _warningMessages;
1081
+ const logError = (message: string, index: number) => {
1082
+ $log.info(`vis-col for table '${reference.table.name}' in context 'filter' at index '${index}':`);
1083
+ $log.info(message);
1084
+ };
1085
+
1075
1086
  if (annotationCols !== -1) {
1076
1087
  usedAnnotation = true;
1077
1088
  // NOTE We're allowing duplicates in annotation.
1078
1089
  annotationCols.forEach((obj: any, objIndex: number) => {
1090
+ if (!isObjectAndNotNull(obj)) {
1091
+ logError(wm.INVALID_FACET_ENTRY, objIndex);
1092
+ return;
1093
+ }
1094
+
1079
1095
  // if we have filters in the url, we will get the filters only from url
1080
1096
  if (obj.sourcekey === _specialSourceDefinitions.SEARCH_BOX && andFilters.length === 0) {
1081
1097
  if (!searchTerm) {
@@ -1087,36 +1103,23 @@ export function generateFacetColumns(
1087
1103
  // make sure it's not referring to the annotation object.
1088
1104
  obj = simpleDeepCopy(obj);
1089
1105
 
1090
- let sd: any, wrapper: any;
1091
1106
  try {
1092
- // if both source and sourcekey are defined, ignore the source and use sourcekey
1093
- if (obj.sourcekey) {
1094
- sd = reference.table.sourceDefinitions.getSource(obj.sourcekey);
1095
- if (!sd || !sd.processFilterNodes(undefined).success) return;
1096
-
1097
- wrapper = sd.clone(obj, reference.table, true);
1107
+ if ('and' in obj) {
1108
+ const fow = new FacetObjectGroupWrapper(obj, reference.table, hasFilterOrFacet);
1109
+ // avoid duplicate groups
1110
+ if (fow.displayname.unformatted! in addedGroups) {
1111
+ throw new Error(`Duplicate facet group name: ${fow.displayname.unformatted}`);
1112
+ }
1113
+ addedGroups[fow.displayname.unformatted!] = true;
1114
+ facetObjectWrappers.push(fow);
1098
1115
  } else {
1099
- wrapper = new SourceObjectWrapper(obj, reference.table, true);
1116
+ const wrapper = helpers.sourceDefToFacetObjectWrapper(obj, reference.table, hasFilterOrFacet);
1117
+ facetObjectWrappers.push(wrapper);
1100
1118
  }
1101
1119
  } catch (exp) {
1102
- $log.info(`facet definition index=${objIndex}: ` + exp);
1103
- // invalid definition or not unsupported
1104
- // TODO could show the error message
1120
+ logError((exp as Error).message, objIndex);
1105
1121
  return;
1106
1122
  }
1107
-
1108
- const supported = helpers.checkFacetObjectWrapper(wrapper);
1109
- if (!supported) return;
1110
-
1111
- // if we have filters in the url, we will get the filters only from url
1112
- if (hasFilterOrFacet) {
1113
- delete wrapper.sourceObject.not_null;
1114
- delete wrapper.sourceObject.choices;
1115
- delete wrapper.sourceObject.search;
1116
- delete wrapper.sourceObject.ranges;
1117
- }
1118
-
1119
- facetObjectWrappers.push(wrapper);
1120
1123
  });
1121
1124
  }
1122
1125
  // annotation didn't exist, so we are going to use the
@@ -1164,20 +1167,39 @@ export function generateFacetColumns(
1164
1167
  }
1165
1168
 
1166
1169
  // if we have filters in the url, we should just get the structure from annotation
1167
- const finalize = (res: any) => {
1168
- const facetColumns: any[] = [];
1170
+ const finalize = (res: ValidatedFacetFilters) => {
1171
+ const facetColumns: FacetColumn[] = [];
1172
+ const facetColumnsStructure: Array<number | FacetGroup> = [];
1169
1173
 
1170
1174
  // turn facetObjectWrappers into facetColumn
1171
- res.facetObjectWrappers.forEach((fo: any, index: number) => {
1172
- // if the function returns false, it couldn't handle that case,
1173
- // and therefore we are ignoring it.
1174
- // it might change the fo
1175
- if (!helpers.checkForAlternative(fo, usedAnnotation, reference.table)) return;
1176
- facetColumns.push(new FacetColumn(reference, index, fo));
1175
+ res.facetObjectWrappers.forEach((fo) => {
1176
+ // if it's a group, we should create object for all children and add their index to the group
1177
+ if ('children' in fo) {
1178
+ // TODO should this be a proper check for FacetObjectGroupWrapper?
1179
+ const structureIndex = facetColumnsStructure.length;
1180
+ const childIndexes: number[] = [];
1181
+ fo.children.forEach((child) => {
1182
+ // if the function returns false, it couldn't handle that case, and therefore we are ignoring it.
1183
+ if (!helpers.checkForAlternative(child, usedAnnotation, reference.table)) return;
1184
+ const usedIndex = facetColumns.length;
1185
+ facetColumns.push(new FacetColumn(reference, usedIndex, child, structureIndex));
1186
+ childIndexes.push(usedIndex);
1187
+ });
1188
+ if (childIndexes.length === 0) return; // if there wasn't any valid child, ignore the group
1189
+ facetColumnsStructure.push(new FacetGroup(reference, structureIndex, fo, childIndexes));
1190
+ }
1191
+ // individual facet column
1192
+ else {
1193
+ // if the function returns false, it couldn't handle that case, and therefore we are ignoring it.
1194
+ if (!helpers.checkForAlternative(fo, usedAnnotation, reference.table)) return;
1195
+ const usedIndex = facetColumns.length;
1196
+ facetColumns.push(new FacetColumn(reference, usedIndex, fo));
1197
+ facetColumnsStructure.push(usedIndex);
1198
+ }
1177
1199
  });
1178
1200
 
1179
1201
  // get the existing facets on the columns (coming from annotation)
1180
- facetColumns.forEach((fc: any) => {
1202
+ facetColumns.forEach((fc) => {
1181
1203
  if (fc.filters.length !== 0) {
1182
1204
  res.newFilters.push(fc.toJSON());
1183
1205
  }
@@ -1190,24 +1212,26 @@ export function generateFacetColumns(
1190
1212
  reference.location.facets = null;
1191
1213
  }
1192
1214
 
1193
- return facetColumns;
1215
+ return { facetColumns, facetColumnsStructure };
1194
1216
  };
1195
1217
 
1196
1218
  // if we don't want to map entity choices, then function will work in sync mode
1197
1219
  if (skipMappingEntityChoices) {
1198
1220
  const res = validateFacetsFilters(reference, andFilters, facetObjectWrappers, searchTerm, skipMappingEntityChoices) as ValidatedFacetFilters;
1199
- const facetColumns = finalize(res);
1221
+ const finalizedRes = finalize(res);
1200
1222
  return {
1201
- facetColumns,
1223
+ facetColumns: finalizedRes.facetColumns,
1224
+ facetColumnsStructure: finalizedRes.facetColumnsStructure,
1202
1225
  issues: res.issues,
1203
1226
  };
1204
1227
  } else {
1205
1228
  return new Promise((resolve, reject) => {
1206
1229
  (validateFacetsFilters(reference, andFilters, facetObjectWrappers, searchTerm, skipMappingEntityChoices) as Promise<ValidatedFacetFilters>)
1207
1230
  .then((res) => {
1208
- const facetColumns = finalize(res);
1231
+ const finalizedRes = finalize(res);
1209
1232
  resolve({
1210
- facetColumns,
1233
+ facetColumns: finalizedRes.facetColumns,
1234
+ facetColumnsStructure: finalizedRes.facetColumnsStructure,
1211
1235
  issues: res.issues,
1212
1236
  });
1213
1237
  })
@@ -1234,14 +1258,14 @@ export function generateFacetColumns(
1234
1258
  export function validateFacetsFilters(
1235
1259
  reference: Reference,
1236
1260
  facetAndFilters: any,
1237
- facetObjectWrappers: Array<SourceObjectWrapper>,
1261
+ facetObjectWrappers: Array<SourceObjectWrapper | FacetObjectGroupWrapper>,
1238
1262
  searchTerm?: string,
1239
1263
  skipMappingEntityChoices?: boolean,
1240
1264
  changeLocation?: boolean,
1241
1265
  ): ValidatedFacetFilters | Promise<ValidatedFacetFilters> {
1242
1266
  const helpers = _facetColumnHelpers;
1243
1267
  const promises: any[] = [];
1244
- const checkedObjects: { [key: number]: boolean } = {};
1268
+ const checkedObjects: { [key: string]: boolean } = {};
1245
1269
  let j: number;
1246
1270
  const facetLen = facetObjectWrappers.length;
1247
1271
  let andFilterObject: SourceObjectWrapper;
@@ -1422,21 +1446,42 @@ export function validateFacetsFilters(
1422
1446
  // find the facet corresponding to the filter
1423
1447
  let found = false;
1424
1448
  for (j = 0; j < facetLen; j++) {
1425
- // it can be merged only once, since in a facet the filter is
1426
- // `or` and outside it's `and`.
1427
- if (checkedObjects[j]) continue;
1428
-
1429
- // we want to make sure these two sources are exactly the same
1430
- // so we can compare their hashnames
1431
- if (res.facetObjectWrappers[j].name === resp.andFilterObject.name) {
1432
- checkedObjects[j] = true;
1433
- found = true;
1434
- // merge facet objects
1435
- helpers.mergeFacetObjects(res.facetObjectWrappers[j].sourceObject, resp.andFilterObject.sourceObject);
1436
-
1437
- // make sure the page object is stored
1438
- if (resp.andFilterObject.entityChoiceFilterTuples) {
1439
- res.facetObjectWrappers[j].entityChoiceFilterTuples = resp.andFilterObject.entityChoiceFilterTuples;
1449
+ const curr = res.facetObjectWrappers[j];
1450
+
1451
+ if (curr instanceof FacetObjectGroupWrapper) {
1452
+ curr.children.forEach((child, childIndex) => {
1453
+ if (checkedObjects[`${j}_${childIndex}`]) return;
1454
+ if (child.name === resp.andFilterObject.name) {
1455
+ checkedObjects[`${j}_${childIndex}`] = true;
1456
+ found = true;
1457
+ // merge facet objects
1458
+ helpers.mergeFacetObjects(child.sourceObject, resp.andFilterObject.sourceObject);
1459
+
1460
+ // make sure the page object is stored
1461
+ if (resp.andFilterObject.entityChoiceFilterTuples) {
1462
+ child.entityChoiceFilterTuples = resp.andFilterObject.entityChoiceFilterTuples;
1463
+ }
1464
+ }
1465
+ });
1466
+ } else {
1467
+ // it can be merged only once, since in a facet the filter is
1468
+ // `or` and outside it's `and`.
1469
+ if (checkedObjects[j]) continue;
1470
+
1471
+ const facetObjectWrapper = curr as SourceObjectWrapper;
1472
+
1473
+ // we want to make sure these two sources are exactly the same
1474
+ // so we can compare their hashnames
1475
+ if (facetObjectWrapper.name === resp.andFilterObject.name) {
1476
+ checkedObjects[j] = true;
1477
+ found = true;
1478
+ // merge facet objects
1479
+ helpers.mergeFacetObjects(facetObjectWrapper.sourceObject, resp.andFilterObject.sourceObject);
1480
+
1481
+ // make sure the page object is stored
1482
+ if (resp.andFilterObject.entityChoiceFilterTuples) {
1483
+ facetObjectWrapper.entityChoiceFilterTuples = resp.andFilterObject.entityChoiceFilterTuples;
1484
+ }
1440
1485
  }
1441
1486
  }
1442
1487
  }
package/vite.config.mts CHANGED
@@ -51,7 +51,7 @@ export default defineConfig(async (): Promise<UserConfig> => {
51
51
  'process.env.NODE_ENV': JSON.stringify(mode),
52
52
  },
53
53
  build: {
54
- minify: 'terser',
54
+ minify: isDev ? false : 'terser',
55
55
  reportCompressedSize: false,
56
56
  sourcemap: isDev,
57
57
  lib: {