@isrd-isi-edu/ermrestjs 2.6.0 → 2.7.0

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/js/core.js CHANGED
@@ -1741,9 +1741,16 @@ import {
1741
1741
  $log.info("condition definition, table =" + self.name + ", condition=" + cKey + ": must be an object.");
1742
1742
  continue;
1743
1743
  }
1744
- // must have source or sourcekey
1745
- if (!condDef.source && !isStringAndNotEmpty(condDef.sourcekey)) {
1746
- $log.info("condition definition, table =" + self.name + ", condition=" + cKey + ": must have `source` or `sourcekey`.");
1744
+ var hasSource = !!condDef.source || isStringAndNotEmpty(condDef.sourcekey);
1745
+ var hasPattern = isStringAndNotEmpty(condDef.condition_pattern);
1746
+ // must have source/sourcekey OR a no-source condition_pattern
1747
+ if (!hasSource && !hasPattern) {
1748
+ $log.info("condition definition, table =" + self.name + ", condition=" + cKey + ": must have `source`/`sourcekey` or `condition_pattern`.");
1749
+ continue;
1750
+ }
1751
+ // wait_for requires source/sourcekey (no-source conditions cannot coordinate secondary fetches)
1752
+ if (!hasSource && condDef.wait_for !== undefined) {
1753
+ $log.info("condition definition, table =" + self.name + ", condition=" + cKey + ": `wait_for` requires `source` or `sourcekey`.");
1747
1754
  continue;
1748
1755
  }
1749
1756
  // if sourcekey, it must exist in the sources map
@@ -9,6 +9,7 @@ import SourceObjectNode from '@isrd-isi-edu/ermrestjs/src/models/source-object-n
9
9
  import SourceObjectWrapper from '@isrd-isi-edu/ermrestjs/src/models/source-object-wrapper';
10
10
  import PathPrefixAliasMapping from '@isrd-isi-edu/ermrestjs/src/models/path-prefix-alias-mapping';
11
11
  import { Tuple, Reference } from '@isrd-isi-edu/ermrestjs/src/models/reference';
12
+ import ActiveListCondition from '@isrd-isi-edu/ermrestjs/src/models/active-list-condition';
12
13
 
13
14
  // services
14
15
  import CatalogService from '@isrd-isi-edu/ermrestjs/src/services/catalog';
@@ -901,25 +902,56 @@ import { parse, _convertSearchTermToFilter } from '@isrd-isi-edu/ermrestjs/js/pa
901
902
  *
902
903
  *
903
904
  * @param {any} obj the source definition object
904
- * @param {Table} table the table that this source definition is based on
905
+ * @param {Reference} reference the reference that this source definition is defined on
905
906
  * @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.
906
907
  *
907
908
  * @throws {Error} if the source definition is invalid for facet
908
909
  *
909
910
  * @returns {SourceObjectWrapper} the source object wrapper that can be used as a facet object.
910
911
  */
911
- sourceDefToFacetObjectWrapper: function (obj, table, hasFilterOrFacet) {
912
+ sourceDefToFacetObjectWrapper: function (obj, reference, hasFilterOrFacet) {
913
+
914
+ // evaluate the condition, and throw an error only if the condition is no-source and evaluates to hidden.
915
+ let condDef, condKey;
916
+ if (isStringAndNotEmpty(obj.condition_key)) {
917
+ condKey = obj.condition_key;
918
+ condDef = reference.table.sourceDefinitions.getCondition(condKey);
919
+ if (!condDef) {
920
+ $log.info('condition_key `' + condKey + '` not found in source-definitions conditions');
921
+ }
922
+ } else if (isObjectAndNotNull(obj.condition)) {
923
+ condDef = obj.condition;
924
+ }
925
+ if (condDef) {
926
+ let shouldShow = true;
927
+ try {
928
+ const cond = new ActiveListCondition(condDef, reference, undefined, condKey);
929
+ if (cond.column === null) {
930
+ // no-source: evaluate synchronously
931
+ shouldShow = cond.evaluateCondition({}, null).shouldShow;
932
+ } else {
933
+ // with-source conditions are not honored in filter context
934
+ $log.info('condition with `source`/`sourcekey` not honored in `filter` context');
935
+ }
936
+ } catch (e) {
937
+ $log.info('failed to evaluate condition: ' + (e instanceof Error ? e.message : String(e)));
938
+ }
939
+ if (!shouldShow) {
940
+ throw new Error('no-source condition evaluated to hidden');
941
+ }
942
+ }
943
+
912
944
  let wrapper;
913
945
  // if both source and sourcekey are defined, ignore the source and use sourcekey
914
946
  if (obj.sourcekey) {
915
- const sd = table.sourceDefinitions.getSource(obj.sourcekey);
947
+ const sd = reference.table.sourceDefinitions.getSource(obj.sourcekey);
916
948
  if (!sd) {
917
949
  throw new Error(_facetingErrors.invalidSourcekey);
918
950
  }
919
951
 
920
- wrapper = sd.clone(obj, table, true);
952
+ wrapper = sd.clone(obj, reference.table, true);
921
953
  } else {
922
- wrapper = new SourceObjectWrapper(obj, table, true);
954
+ wrapper = new SourceObjectWrapper(obj, reference.table, true);
923
955
  }
924
956
 
925
957
  const col = wrapper.column;
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.6.0",
4
+ "version": "2.7.0",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
7
7
  "node": ">= 20.0.0",
@@ -39,7 +39,12 @@
39
39
  "database",
40
40
  "rest",
41
41
  "postgresql",
42
- "library"
42
+ "library",
43
+ "javascript",
44
+ "typescript",
45
+ "client",
46
+ "ermrest",
47
+ "ermrestjs"
43
48
  ],
44
49
  "overrides": {
45
50
  "@microsoft/api-extractor": {
@@ -50,7 +55,7 @@
50
55
  "@types/lodash-es": "^4.17.12",
51
56
  "@types/markdown-it": "^14.1.2",
52
57
  "@types/q": "^1.5.8",
53
- "axios": "1.15.0",
58
+ "axios": "1.16.0",
54
59
  "handlebars": "4.7.9",
55
60
  "lodash-es": "^4.18.1",
56
61
  "lz-string": "^1.5.0",
@@ -16,11 +16,22 @@ import { _processWaitForList } from '@isrd-isi-edu/ermrestjs/js/utils/pseudocolu
16
16
 
17
17
  /**
18
18
  * Encapsulates condition evaluation logic for conditionally visible
19
- * columns and related entities in the record page.
19
+ * columns and related entities.
20
+ *
21
+ * Two forms:
22
+ * - **With source**: `condDef.source` or `condDef.sourcekey` drives a fetch
23
+ * whose data feeds the condition. Only honored in `detailed` context.
24
+ * `column` is a PseudoColumn used by chaise's record-page flow.
25
+ * - **No source**: only `condDef.condition_pattern` is set. Evaluated
26
+ * synchronously against template globals (`$session`, `$catalog`,
27
+ * `isUserInAcl`, etc.). Honored in every context. `column` is `null`.
20
28
  */
21
29
  export default class ActiveListCondition {
22
- /** The PseudoColumn for fetching condition data */
23
- column: VisibleColumn;
30
+ /**
31
+ * The PseudoColumn for fetching condition data.
32
+ * `null` for no-source conditions (pattern-only).
33
+ */
34
+ column: VisibleColumn | null;
24
35
 
25
36
  /** Whether the condition source requires a secondary request (has inbound path or aggregate) */
26
37
  isAsync: boolean;
@@ -47,10 +58,25 @@ export default class ActiveListCondition {
47
58
 
48
59
  const wm = _warningMessages;
49
60
 
50
- if (typeof condDef !== 'object' || (!condDef.source && !isStringAndNotEmpty(condDef.sourcekey))) {
61
+ if (typeof condDef !== 'object' || condDef === null) {
51
62
  throw new Error(wm.CONDITION.INVALID_SOURCE);
52
63
  }
53
64
 
65
+ const hasSource = !!condDef.source || isStringAndNotEmpty(condDef.sourcekey);
66
+
67
+ // no-source branch: pattern-only, evaluated synchronously against template globals
68
+ if (!hasSource) {
69
+ if (!isStringAndNotEmpty(condDef.condition_pattern)) {
70
+ throw new Error(wm.CONDITION.MISSING_PATTERN);
71
+ }
72
+ if (condDef.wait_for !== undefined) {
73
+ throw new Error(wm.CONDITION.NO_SOURCE_WAIT_FOR);
74
+ }
75
+ this.column = null;
76
+ this.isAsync = false;
77
+ return;
78
+ }
79
+
54
80
  let condSourceWrapper: SourceObjectWrapper;
55
81
  try {
56
82
  const sds = this._reference.table.sourceDefinitions;
@@ -101,10 +127,17 @@ export default class ActiveListCondition {
101
127
  */
102
128
  get waitFor(): ReferenceColumn[] {
103
129
  if (this._waitFor === undefined) {
104
- const res = _processWaitForList(this._condDef.wait_for, this._reference, this._reference.table, null, this._tuple, 'condition');
105
- this._waitFor = res.waitForList;
106
- this._hasWaitFor = res.hasWaitFor;
107
- this._hasWaitForAggregate = res.hasWaitForAggregate;
130
+ // no-source conditions don't have a source fetch to wait on
131
+ if (this.column === null) {
132
+ this._waitFor = [];
133
+ this._hasWaitFor = false;
134
+ this._hasWaitForAggregate = false;
135
+ } else {
136
+ const res = _processWaitForList(this._condDef.wait_for, this._reference, this._reference.table, null, this._tuple, 'condition');
137
+ this._waitFor = res.waitForList;
138
+ this._hasWaitFor = res.hasWaitFor;
139
+ this._hasWaitForAggregate = res.hasWaitForAggregate;
140
+ }
108
141
  }
109
142
  return this._waitFor!;
110
143
  }
@@ -112,25 +145,29 @@ export default class ActiveListCondition {
112
145
  /**
113
146
  * Decide whether the conditioned content should be shown.
114
147
  *
115
- * Three internal paths, dispatched on `condition_pattern` and `this.isAsync`:
148
+ * Four internal paths, dispatched on `this.column`, `condition_pattern`, and `this.isAsync`:
116
149
  *
117
- * 1. **`condition_pattern` set.** Render the pattern. `$self` / `$_self` come
118
- * from {@link buildSelfTemplateVariables}, which sources from `mainTuple`
119
- * for sync sources and from `conditionValue` for async sources. Blank
120
- * rendered output empty.
121
- * 2. **No pattern + `isAsync === true`.** Caller already fetched the data;
122
- * emptiness is computed from `conditionValue` by {@link _isConditionValueEmpty}.
123
- * 3. **No pattern + `isAsync === false`.** `conditionValue` is ignored (sync
124
- * sources have no fetched value). The raw value is pulled from `mainTuple`
125
- * via {@link buildSelfTemplateVariables}: scalar sources are empty when
126
- * `$_self` is `null`/`undefined`/`''`; entity-mode sources are empty when
127
- * `$self` is undefined (the joined row didn't load).
150
+ * 0. **No-source (`this.column === null`).** `condition_pattern` is rendered
151
+ * against the caller's `templateVariables` plus the catalog's global
152
+ * template environment (`$session`, `$catalog`, `isUserInAcl`, etc.).
153
+ * `conditionValue` and `mainTuple` are unused.
154
+ * 1. **With-source, `condition_pattern` set.** Render the pattern. `$self` /
155
+ * `$_self` come from {@link buildSelfTemplateVariables}, which sources from
156
+ * `mainTuple` for sync sources and from `conditionValue` for async sources.
157
+ * Blank rendered output empty.
158
+ * 2. **With-source, no pattern + `isAsync === true`.** Caller already fetched
159
+ * the data; emptiness is computed from `conditionValue` by {@link _isConditionValueEmpty}.
160
+ * 3. **With-source, no pattern + `isAsync === false`.** `conditionValue` is
161
+ * ignored (sync sources have no fetched value). The raw value is pulled
162
+ * from `mainTuple` via {@link buildSelfTemplateVariables}: scalar sources
163
+ * are empty when `$_self` is `null`/`undefined`/`''`; entity-mode sources
164
+ * are empty when `$self` is undefined (the joined row didn't load).
128
165
  *
129
166
  * The `on_empty` flag then maps `isEmpty` to "show": `"hide"` (default) shows
130
167
  * when not empty; `"show"` shows when empty.
131
168
  *
132
169
  * @param templateVariables - accumulated `$x`/`$_x` template variables for
133
- * every other source (only consulted by the pattern branch).
170
+ * every other source (consulted by the pattern branches).
134
171
  * @param conditionValue - the fetched value for an async source.
135
172
  * - **entityset** (Reference.read result): a {@link Page} object — has
136
173
  * `length` (used by `_isConditionValueEmpty`) and `templateVariables`
@@ -140,18 +177,24 @@ export default class ActiveListCondition {
140
177
  * scalar; `templateVariables` carries `$self`/`$_self`.
141
178
  * - **null** if the request failed or there's no fetched value (treated
142
179
  * as empty).
143
- * Ignored entirely for sync sources (path 3).
144
- * @param mainTuple - the main record tuple. Always required: the pattern
145
- * branch and the sync no-pattern branch both look up `$self`/`$_self`
146
- * from it.
180
+ * Ignored for no-source (path 0) and sync sources (path 3).
181
+ * @param mainTuple - the main record tuple. Required for paths 1 and 3
182
+ * (both look up `$self`/`$_self` from it). Unused for paths 0 and 2.
147
183
  * @returns whether the conditioned content should be shown.
148
184
  */
149
- evaluateCondition(templateVariables: any, conditionValue: any, mainTuple: Tuple): { shouldShow: boolean } {
185
+ evaluateCondition(templateVariables: any, conditionValue: any, mainTuple?: Tuple): { shouldShow: boolean } {
150
186
  let isEmpty: boolean;
151
187
  const condDef = this._condDef;
152
188
 
153
- if (condDef.condition_pattern) {
154
- const selfTemplateVariables = buildSelfTemplateVariables(this.column as ReferenceColumn, mainTuple, conditionValue);
189
+ if (this.column === null) {
190
+ // no-source: pattern-only, evaluated against caller's templateVariables
191
+ // plus the catalog's global template environment ($session, $catalog, etc.)
192
+ const rendered = _renderTemplate(condDef.condition_pattern as string, templateVariables ?? {}, this._reference.table.schema.catalog, {
193
+ templateEngine: condDef.template_engine,
194
+ });
195
+ isEmpty = !rendered || rendered.trim() === '';
196
+ } else if (condDef.condition_pattern) {
197
+ const selfTemplateVariables = buildSelfTemplateVariables(this.column as ReferenceColumn, mainTuple!, conditionValue);
155
198
  const keyValues: any = {};
156
199
  Object.assign(keyValues, templateVariables, selfTemplateVariables);
157
200
 
@@ -166,7 +209,7 @@ export default class ActiveListCondition {
166
209
  } else {
167
210
  // no pattern, sync: derive the raw value from the tuple via the same
168
211
  // helper sourceFormatPresentation uses, then decide emptiness directly.
169
- const selfVars = buildSelfTemplateVariables(this.column as ReferenceColumn, mainTuple, null);
212
+ const selfVars = buildSelfTemplateVariables(this.column as ReferenceColumn, mainTuple!, null);
170
213
  if ('$_self' in selfVars) {
171
214
  // scalar source (local column or all-outbound scalar)
172
215
  const v = selfVars.$_self;
@@ -49,6 +49,7 @@ import {
49
49
  computeReferenceDisplay,
50
50
  type ReferenceDisplay,
51
51
  generateColumnsList,
52
+ applyNoSourceConditions,
52
53
  } from '@isrd-isi-edu/ermrestjs/src/utils/reference-utils';
53
54
  import { isObject, isObjectAndNotNull, isStringAndNotEmpty, verify } from '@isrd-isi-edu/ermrestjs/src/utils/type-utils';
54
55
  import { fixedEncodeURIComponent, simpleDeepCopy } from '@isrd-isi-edu/ermrestjs/src/utils/value-utils';
@@ -817,7 +818,7 @@ export class Reference {
817
818
  }
818
819
 
819
820
  if (notSorted && this._related.length !== 0) {
820
- return this._related.sort((a, b) => {
821
+ this._related.sort((a, b) => {
821
822
  // displayname
822
823
  if (a.displayname.value !== b.displayname.value) {
823
824
  return (a.displayname.value as string).localeCompare(b.displayname.value as string);
@@ -835,6 +836,8 @@ export class Reference {
835
836
  });
836
837
  }
837
838
 
839
+ applyNoSourceConditions(this._related, (rel) => rel.pseudoColumn?.resolvedCondition);
840
+
838
841
  return this._related;
839
842
  }
840
843
 
@@ -888,7 +891,10 @@ export class Reference {
888
891
  conditionedItem: { column?: boolean; inline?: boolean; related?: boolean; index: number },
889
892
  addToDeps: (deps: Array<ActiveListRequest | ActiveListRelatedEntityRequest>) => void,
890
893
  ): boolean => {
891
- if (!isDetailed || !condition) return false;
894
+ // !condition.column is the no-source marker: those should already be
895
+ // filtered out by generateColumnsList / generateRelatedList, but this is
896
+ // a safety net since ActiveList's processConditionedItem assumes a column.
897
+ if (!isDetailed || !condition || !condition.column) return false;
892
898
  builder.processConditionedItem(condition, conditionedItem, addToDeps);
893
899
  return true;
894
900
  };
@@ -461,6 +461,8 @@ export class ReferenceColumn {
461
461
 
462
462
  /**
463
463
  * The resolved condition for this column, or null if no condition or not applicable.
464
+ * Note: no-source conditions are returned here even after `applyNoSourceConditions`
465
+ * has already handled them. Downstream consumers guard via `!condition.column`.
464
466
  */
465
467
  get resolvedCondition(): ActiveListCondition | null {
466
468
  if (this._resolvedCondition === undefined) {
@@ -472,13 +474,13 @@ export class ReferenceColumn {
472
474
 
473
475
  /**
474
476
  * Resolve the condition definition and source for this column.
477
+ *
478
+ * With-source conditions (drive a secondary fetch tied to the main entity)
479
+ * are only honored in `detailed` context. No-source conditions (pattern-only)
480
+ * are honored in every context — they're evaluated synchronously against
481
+ * the catalog's global template environment.
475
482
  */
476
483
  protected _resolveCondition(): ActiveListCondition | null {
477
- // only applicable in detailed context
478
- if (this._context !== _contexts.DETAILED) {
479
- return null;
480
- }
481
-
482
484
  if (!this.sourceObjectWrapper) {
483
485
  return null;
484
486
  }
@@ -499,6 +501,13 @@ export class ReferenceColumn {
499
501
  return null;
500
502
  }
501
503
 
504
+ // gate with-source conditions to detailed context (no-source falls through)
505
+ const hasSource = !!condDef.source || isStringAndNotEmpty(condDef.sourcekey as string | undefined);
506
+ if (hasSource && this._context !== _contexts.DETAILED) {
507
+ $log.info('condition with `source`/`sourcekey` only honored in `detailed` context; ignoring on column `' + this.name + '`.');
508
+ return null;
509
+ }
510
+
502
511
  let condition: ActiveListCondition;
503
512
  try {
504
513
  condition = new ActiveListCondition(condDef, this._baseReference, this._mainTuple, condKey);
@@ -693,12 +693,12 @@ export class FacetObjectGroupWrapper {
693
693
  * Will throw an error if there was any issues in processing the source object.
694
694
  *
695
695
  * @param sourceObject The source object representing the facet group.
696
- * @param table The table to which the facet group belongs.
696
+ * @param reference The reference to which the facet group belongs.
697
697
  * @param hasFilterOrFacet Indicates if the group has any filters or facets.
698
698
  *
699
699
  * @throws Will throw an error if the source object is invalid or has no valid children.
700
700
  */
701
- constructor(sourceObject: Record<string, unknown>, table: Table, hasFilterOrFacet: boolean) {
701
+ constructor(sourceObject: Record<string, unknown>, reference: Reference, hasFilterOrFacet: boolean) {
702
702
  if (!Array.isArray(sourceObject.and) || sourceObject.and.length === 0) {
703
703
  throw new Error('valid array of children is required');
704
704
  }
@@ -727,10 +727,10 @@ export class FacetObjectGroupWrapper {
727
727
  const children: SourceObjectWrapper[] = [];
728
728
  for (const child of sourceObject.and) {
729
729
  try {
730
- const wrapper = _facetColumnHelpers.sourceDefToFacetObjectWrapper(child, table, hasFilterOrFacet);
730
+ const wrapper = _facetColumnHelpers.sourceDefToFacetObjectWrapper(child, reference, hasFilterOrFacet);
731
731
  children.push(wrapper);
732
732
  } catch (exp: unknown) {
733
- $log.info(`child of facet group "${sourceObject.markdown_name}", index: ${children.length} is invalid:`);
733
+ $log.info(`child of facet group "${sourceObject.markdown_name}", index: ${children.length} is invalid/ignored:`);
734
734
  $log.info((exp as Error).message);
735
735
  }
736
736
  }
@@ -379,6 +379,11 @@ export class ActiveListBuilder {
379
379
  conditionedItem: ActiveListConditionedItem,
380
380
  addToDependent: (dependentRequests: Array<ActiveListRequest | ActiveListRelatedEntityRequest>) => void,
381
381
  ): void {
382
+ // Defense: no-source conditions (column === null) are evaluated synchronously
383
+ // at column-build time and should never reach the ActiveList. Caller
384
+ // (`tryCondition`) already guards this; bail safely if something slipped past.
385
+ if (!condition.column) return;
386
+
382
387
  // dedup: if this condition was already grouped (via condition_key), reuse
383
388
  // that group's dependent list and add this item to its conditionedItems list.
384
389
  if (condition.conditionKey) {
@@ -396,7 +401,7 @@ export class ActiveListBuilder {
396
401
  // this populates `requests` (with `condition: true, index: conditionIndex`
397
402
  // so chaise routes the completion to evaluateCondition); for sync sources
398
403
  // it's a no-op on `requests` but still populates `allOutBounds` / `selfLinks`.
399
- this.addCol(condition.column, false, ActiveListRequestTypes.CONDITION, conditionIndex);
404
+ this.addCol(condition.column!, false, ActiveListRequestTypes.CONDITION, conditionIndex);
400
405
  condition.waitFor.forEach((wf) => {
401
406
  this.addCol(wf, true, ActiveListRequestTypes.CONDITION, conditionIndex);
402
407
  });
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable no-useless-escape */
2
2
  import Handlebars from 'handlebars';
3
+ import { map as lodashMap } from 'lodash-es';
3
4
  import moment from 'moment-timezone';
4
5
 
5
6
  import $log from '@isrd-isi-edu/ermrestjs/src/services/logger';
@@ -419,15 +420,7 @@ export default class HandlebarsService {
419
420
  * @returns a boolean indicating if the user is in any of the given groups
420
421
  */
421
422
  isUserInAcl: function (...args) {
422
- const groups = args.reduce((acc, arg) => {
423
- if (Array.isArray(arg)) {
424
- return acc.concat(arg);
425
- } else if (typeof arg === 'string') {
426
- return acc.concat([arg]);
427
- }
428
- return acc;
429
- }, []);
430
-
423
+ const groups = flattenVariadicArgs(args).filter((g): g is string => typeof g === 'string');
431
424
  return AuthnService.isUserInAcl(groups);
432
425
  },
433
426
 
@@ -591,6 +584,40 @@ export default class HandlebarsService {
591
584
  return Number(arg1) - Number(arg2);
592
585
  },
593
586
  });
587
+
588
+ // list/array helpers
589
+ Handlebars.registerHelper({
590
+ /**
591
+ * {{#if (memberOf scalarCol "a" "b" "c")}}
592
+ * {{#if (memberOf scalarCol someArray)}}
593
+ * {{#if (memberOf scalarCol (pluck $self "values.type"))}}
594
+ *
595
+ * Variadic args; any array argument is flattened one level.
596
+ * @returns true if `value` equals any of the given args
597
+ */
598
+ memberOf: function (value: unknown, ...args: unknown[]) {
599
+ return flattenVariadicArgs(args).includes(value);
600
+ },
601
+
602
+ /**
603
+ * {{#if (hasMember arrayCol "non-polymer")}}
604
+ * @returns true if `array` is an array containing `value`
605
+ */
606
+ hasMember: function (array: unknown, value: unknown) {
607
+ return Array.isArray(array) && array.includes(value);
608
+ },
609
+
610
+ /**
611
+ * {{pluck $self "values.type"}}
612
+ *
613
+ * Preserves length and order; missing paths resolve to undefined.
614
+ * @returns array of values from `array` at the given lodash path
615
+ */
616
+ pluck: function (array: unknown, path: string): unknown[] {
617
+ if (!Array.isArray(array) || typeof path !== 'string' || !path) return [];
618
+ return lodashMap(array, path);
619
+ },
620
+ });
594
621
  }
595
622
  }
596
623
 
@@ -602,6 +629,16 @@ const reduceOp = function (args: any[], reducer: (a: boolean, b: boolean) => boo
602
629
  return args.reduce(reducer, first);
603
630
  };
604
631
 
632
+ /**
633
+ * Drops the trailing Handlebars `options` arg, then flattens any array args
634
+ * one level into a single list. Used by variadic helpers that accept either
635
+ * loose values or arrays (e.g. `(memberOf col "a" "b")` vs `(memberOf col arr)`).
636
+ */
637
+ const flattenVariadicArgs = function (args: unknown[]): unknown[] {
638
+ const rest = args.slice(0, -1); // drop options
639
+ return rest.reduce<unknown[]>((acc, a) => acc.concat(Array.isArray(a) ? a : [a]), []);
640
+ };
641
+
605
642
  const regexpFindAll = function (value: string, regexp: string, flags: string) {
606
643
  const regexpObj = new RegExp(regexp, flags);
607
644
  const matches = value.match(regexpObj);
@@ -294,6 +294,10 @@ export const _handlebarsHelpersList = [
294
294
  // math helpers
295
295
  'add',
296
296
  'subtract',
297
+ // list/array helpers
298
+ 'memberOf',
299
+ 'hasMember',
300
+ 'pluck',
297
301
  ];
298
302
 
299
303
  export const _operationsFlag = Object.freeze({
@@ -407,6 +411,8 @@ export const _warningMessages = Object.freeze({
407
411
  USED_IN_IFRAME_INPUT: 'the column already used in another column mapping.',
408
412
  CONDITION: {
409
413
  INVALID_SOURCE: 'condition definition is invalid. `source` or `sourcekey` is required and must be valid.',
414
+ MISSING_PATTERN: 'condition without `source`/`sourcekey` must define `condition_pattern`.',
415
+ NO_SOURCE_WAIT_FOR: 'condition without `source`/`sourcekey` cannot use `wait_for`.',
410
416
  },
411
417
  });
412
418
 
@@ -19,6 +19,7 @@ import {
19
19
  type Tuple,
20
20
  type VisibleColumn,
21
21
  } from '@isrd-isi-edu/ermrestjs/src/models/reference';
22
+ import ActiveListCondition from '@isrd-isi-edu/ermrestjs/src/models/active-list-condition';
22
23
 
23
24
  // services
24
25
  import $log from '@isrd-isi-edu/ermrestjs/src/services/logger';
@@ -1031,9 +1032,35 @@ export function generateColumnsList(
1031
1032
  }
1032
1033
  }
1033
1034
 
1035
+ applyNoSourceConditions(resultColumns, (col) => col.resolvedCondition);
1036
+
1034
1037
  return resultColumns;
1035
1038
  }
1036
1039
 
1040
+ /**
1041
+ * Splice items whose no-source condition evaluates to "hide". With-source
1042
+ * conditions are left in place (handled by chaise after the main read).
1043
+ * Mutates `items` in place.
1044
+ */
1045
+ export function applyNoSourceConditions<T>(items: T[], getCondition: (item: T) => ActiveListCondition | null | undefined): void {
1046
+ for (let i = 0; i < items.length; i++) {
1047
+ const cond = getCondition(items[i]);
1048
+ if (!cond || cond.column !== null) continue; // no condition, or with-source
1049
+
1050
+ let shouldShow = true;
1051
+ try {
1052
+ shouldShow = cond.evaluateCondition({}, null).shouldShow;
1053
+ } catch (e) {
1054
+ $log.warn('no-source condition evaluation failed; defaulting to show: ' + (e instanceof Error ? e.message : String(e)));
1055
+ }
1056
+
1057
+ if (!shouldShow) {
1058
+ items.splice(i, 1);
1059
+ i--;
1060
+ }
1061
+ }
1062
+ }
1063
+
1037
1064
  /**
1038
1065
  * Generate a list of facetColumns that should be used for the given reference.
1039
1066
  * will also attach _facetColumns to the reference.
@@ -1110,7 +1137,7 @@ export function generateFacetColumns(
1110
1137
 
1111
1138
  try {
1112
1139
  if ('and' in obj) {
1113
- const fow = new FacetObjectGroupWrapper(obj, reference.table, hasFilterOrFacet);
1140
+ const fow = new FacetObjectGroupWrapper(obj, reference, hasFilterOrFacet);
1114
1141
  // avoid duplicate groups
1115
1142
  if (fow.displayname.unformatted! in addedGroups) {
1116
1143
  throw new Error(`Duplicate facet group name: ${fow.displayname.unformatted}`);
@@ -1118,7 +1145,7 @@ export function generateFacetColumns(
1118
1145
  addedGroups[fow.displayname.unformatted!] = true;
1119
1146
  facetObjectWrappers.push(fow);
1120
1147
  } else {
1121
- const wrapper = helpers.sourceDefToFacetObjectWrapper(obj, reference.table, hasFilterOrFacet);
1148
+ const wrapper = helpers.sourceDefToFacetObjectWrapper(obj, reference, hasFilterOrFacet);
1122
1149
  facetObjectWrappers.push(wrapper);
1123
1150
  }
1124
1151
  } catch (exp) {