@isrd-isi-edu/ermrestjs 2.6.1 → 2.8.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.
@@ -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
  };
@@ -4,7 +4,7 @@ import { DisplayName } from '@isrd-isi-edu/ermrestjs/src/models/display-name';
4
4
 
5
5
  // utils
6
6
  import { fixedEncodeURIComponent, shallowCopy, simpleDeepCopy } from '@isrd-isi-edu/ermrestjs/src/utils/value-utils';
7
- import { isObjectAndNotNull } from '@isrd-isi-edu/ermrestjs/src/utils/type-utils';
7
+ import { isObjectAndNotNull, isStringAndNotEmpty } from '@isrd-isi-edu/ermrestjs/src/utils/type-utils';
8
8
  import { _ERMrestACLs, _contexts, _permissionMessages } from '@isrd-isi-edu/ermrestjs/src/utils/constants';
9
9
 
10
10
  import { isAllOutboundColumn } from '@isrd-isi-edu/ermrestjs/src/utils/column-utils';
@@ -154,6 +154,13 @@ export class Tuple {
154
154
  return this._data;
155
155
  }
156
156
 
157
+ /**
158
+ *
159
+ * @param permission the ermrest permission that should be checked (refer to _ERMrestACLs constant for more details)
160
+ * @param colName the name of the column for which the permission should be checked
161
+ * @param isAssoc whether the permission check is for an association
162
+ * @returns a boolean indicating whether the permission is granted
163
+ */
157
164
  checkPermissions(permission: string, colName?: string, isAssoc = false): boolean {
158
165
  let sum = this._rightsSummary[permission];
159
166
  if (isAssoc) {
@@ -161,6 +168,12 @@ export class Tuple {
161
168
  }
162
169
 
163
170
  if (permission === _ERMrestACLs.COLUMN_UPDATE) {
171
+ // if the column is passed but doesn't exist in the table, return false
172
+ if (isStringAndNotEmpty(colName) && !this._pageRef.table.columns.has(colName!)) {
173
+ return false;
174
+ }
175
+
176
+ // if there aren't any column-level permissions, then allow it
164
177
  if (!isObjectAndNotNull(sum) || typeof sum[colName!] !== 'boolean') return true;
165
178
  return sum[colName!];
166
179
  }
@@ -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';
@@ -11,11 +12,11 @@ import {
11
12
  _addErmrestVarsToTemplate,
12
13
  _addTemplateVars,
13
14
  _escapeMarkdownCharacters,
14
- _formatUtils,
15
15
  _getPath,
16
16
  encodeFacet,
17
17
  encodeFacetString,
18
18
  } from '@isrd-isi-edu/ermrestjs/js/utils/helpers';
19
+ import { _formatUtils } from '@isrd-isi-edu/ermrestjs/src/utils/format-utils';
19
20
  import AuthnService from '@isrd-isi-edu/ermrestjs/src/services/authn';
20
21
  import { isObjectAndNotNull } from '@isrd-isi-edu/ermrestjs/src/utils/type-utils';
21
22
  import { fixedEncodeURIComponent } from '@isrd-isi-edu/ermrestjs/src/utils/value-utils';
@@ -402,6 +403,28 @@ export default class HandlebarsService {
402
403
  return _formatUtils.humanizeBytes(value, mode, precision, tooltip);
403
404
  },
404
405
 
406
+ /**
407
+ * {{datetimeDuration start end}}
408
+ * {{datetimeDuration start end unit="month"}}
409
+ * {{datetimeDuration start end unit="auto" fraction=2}}
410
+ * {{datetimeDuration start end unit="multi"}}
411
+ * {{datetimeDuration start end unit="calendar"}}
412
+ * {{datetimeDuration start end direction="before/after"}}
413
+ * {{datetimeDuration start end unit="month" tooltip=true}}
414
+ * @ignore
415
+ * @returns human-readable duration between `start` and `end`
416
+ */
417
+ datetimeDuration: function (start: any, end: any, options: Handlebars.HelperOptions) {
418
+ let unit, fraction, direction, tooltip;
419
+ if (options && isObjectAndNotNull(options.hash)) {
420
+ unit = options.hash.unit;
421
+ fraction = options.hash.fraction;
422
+ direction = options.hash.direction;
423
+ tooltip = options.hash.tooltip;
424
+ }
425
+ return _formatUtils.datetimeDuration(start, end, unit, fraction, direction, tooltip);
426
+ },
427
+
405
428
  /**
406
429
  * {{stringLength value }}
407
430
  * @ignore
@@ -419,15 +442,7 @@ export default class HandlebarsService {
419
442
  * @returns a boolean indicating if the user is in any of the given groups
420
443
  */
421
444
  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
-
445
+ const groups = flattenVariadicArgs(args).filter((g): g is string => typeof g === 'string');
431
446
  return AuthnService.isUserInAcl(groups);
432
447
  },
433
448
 
@@ -591,6 +606,40 @@ export default class HandlebarsService {
591
606
  return Number(arg1) - Number(arg2);
592
607
  },
593
608
  });
609
+
610
+ // list/array helpers
611
+ Handlebars.registerHelper({
612
+ /**
613
+ * {{#if (memberOf scalarCol "a" "b" "c")}}
614
+ * {{#if (memberOf scalarCol someArray)}}
615
+ * {{#if (memberOf scalarCol (pluck $self "values.type"))}}
616
+ *
617
+ * Variadic args; any array argument is flattened one level.
618
+ * @returns true if `value` equals any of the given args
619
+ */
620
+ memberOf: function (value: unknown, ...args: unknown[]) {
621
+ return flattenVariadicArgs(args).includes(value);
622
+ },
623
+
624
+ /**
625
+ * {{#if (hasMember arrayCol "non-polymer")}}
626
+ * @returns true if `array` is an array containing `value`
627
+ */
628
+ hasMember: function (array: unknown, value: unknown) {
629
+ return Array.isArray(array) && array.includes(value);
630
+ },
631
+
632
+ /**
633
+ * {{pluck $self "values.type"}}
634
+ *
635
+ * Preserves length and order; missing paths resolve to undefined.
636
+ * @returns array of values from `array` at the given lodash path
637
+ */
638
+ pluck: function (array: unknown, path: string): unknown[] {
639
+ if (!Array.isArray(array) || typeof path !== 'string' || !path) return [];
640
+ return lodashMap(array, path);
641
+ },
642
+ });
594
643
  }
595
644
  }
596
645
 
@@ -602,6 +651,16 @@ const reduceOp = function (args: any[], reducer: (a: boolean, b: boolean) => boo
602
651
  return args.reduce(reducer, first);
603
652
  };
604
653
 
654
+ /**
655
+ * Drops the trailing Handlebars `options` arg, then flattens any array args
656
+ * one level into a single list. Used by variadic helpers that accept either
657
+ * loose values or arrays (e.g. `(memberOf col "a" "b")` vs `(memberOf col arr)`).
658
+ */
659
+ const flattenVariadicArgs = function (args: unknown[]): unknown[] {
660
+ const rest = args.slice(0, -1); // drop options
661
+ return rest.reduce<unknown[]>((acc, a) => acc.concat(Array.isArray(a) ? a : [a]), []);
662
+ };
663
+
605
664
  const regexpFindAll = function (value: string, regexp: string, flags: string) {
606
665
  const regexpObj = new RegExp(regexp, flags);
607
666
  const matches = value.match(regexpObj);
@@ -18,12 +18,12 @@ import { isDefinedAndNotNull } from '@isrd-isi-edu/ermrestjs/src/utils/type-util
18
18
 
19
19
  // legacy
20
20
  import {
21
- _formatUtils,
22
21
  _generateRowPresentation,
23
22
  _getRowTemplateVariables,
24
23
  _processColumnOrderList,
25
24
  _renderTemplate,
26
25
  } from '@isrd-isi-edu/ermrestjs/js/utils/helpers';
26
+ import { _formatUtils } from '@isrd-isi-edu/ermrestjs/src/utils/format-utils';
27
27
 
28
28
  /**
29
29
  * Convert the raw value of an aggregate column to a formatted value.
@@ -286,6 +286,7 @@ export const _handlebarsHelpersList = [
286
286
  'toTitleCase',
287
287
  'replace',
288
288
  'humanizeBytes',
289
+ 'datetimeDuration',
289
290
  'printf',
290
291
  'stringLength',
291
292
  'isUserInAcl',
@@ -294,6 +295,10 @@ export const _handlebarsHelpersList = [
294
295
  // math helpers
295
296
  'add',
296
297
  'subtract',
298
+ // list/array helpers
299
+ 'memberOf',
300
+ 'hasMember',
301
+ 'pluck',
297
302
  ];
298
303
 
299
304
  export const _operationsFlag = Object.freeze({
@@ -407,6 +412,8 @@ export const _warningMessages = Object.freeze({
407
412
  USED_IN_IFRAME_INPUT: 'the column already used in another column mapping.',
408
413
  CONDITION: {
409
414
  INVALID_SOURCE: 'condition definition is invalid. `source` or `sourcekey` is required and must be valid.',
415
+ MISSING_PATTERN: 'condition without `source`/`sourcekey` must define `condition_pattern`.',
416
+ NO_SOURCE_WAIT_FOR: 'condition without `source`/`sourcekey` cannot use `wait_for`.',
410
417
  },
411
418
  });
412
419
 
@@ -523,6 +530,83 @@ export const _sourceProperties = Object.freeze({
523
530
 
524
531
  export const _exportKnownAPIs = ['entity', 'attribute', 'attributegroup', 'aggregate'];
525
532
 
533
+ /**
534
+ * Named constants for the `unit` argument of `datetimeDuration`. Source of
535
+ * truth for valid unit values — `_datetimeDuration.VALID_UNITS` and
536
+ * `UNIT_ORDER` are derived from this object below.
537
+ *
538
+ * Includes the three pseudo-units (`AUTO`, `MULTI`, `CALENDAR`) alongside the
539
+ * seven real time units. Use `DurationRealUnit` for the real-unit subset.
540
+ */
541
+ export const DURATION_UNIT = Object.freeze({
542
+ AUTO: 'auto',
543
+ MULTI: 'multi',
544
+ CALENDAR: 'calendar',
545
+ YEAR: 'year',
546
+ MONTH: 'month',
547
+ DAY: 'day',
548
+ HOUR: 'hour',
549
+ MINUTE: 'minute',
550
+ SECOND: 'second',
551
+ MILLISECOND: 'millisecond',
552
+ } as const);
553
+ export type DurationUnit = (typeof DURATION_UNIT)[keyof typeof DURATION_UNIT];
554
+
555
+ /**
556
+ * Named constants for the `direction` argument of `datetimeDuration`. Source
557
+ * of truth for valid direction values — `_datetimeDuration.VALID_DIRECTIONS`
558
+ * is derived from this object below.
559
+ */
560
+ export const DURATION_DIRECTION = Object.freeze({
561
+ SIGN: 'sign',
562
+ BEFORE_AFTER: 'before/after',
563
+ EARLIER_LATER: 'earlier/later',
564
+ UNSIGNED: 'unsigned',
565
+ } as const);
566
+ export type DurationDirection = (typeof DURATION_DIRECTION)[keyof typeof DURATION_DIRECTION];
567
+
568
+ export const _datetimeDuration = Object.freeze({
569
+ // Real units in walking order (largest first). The source of truth for what
570
+ // counts as a "real" (non-pseudo) duration unit.
571
+ UNIT_ORDER: [
572
+ DURATION_UNIT.YEAR,
573
+ DURATION_UNIT.MONTH,
574
+ DURATION_UNIT.DAY,
575
+ DURATION_UNIT.HOUR,
576
+ DURATION_UNIT.MINUTE,
577
+ DURATION_UNIT.SECOND,
578
+ DURATION_UNIT.MILLISECOND,
579
+ ] as const,
580
+ ABBREVIATIONS: {
581
+ [DURATION_UNIT.YEAR]: 'Y',
582
+ [DURATION_UNIT.MONTH]: 'M',
583
+ [DURATION_UNIT.DAY]: 'D',
584
+ [DURATION_UNIT.HOUR]: 'h',
585
+ [DURATION_UNIT.MINUTE]: 'm',
586
+ [DURATION_UNIT.SECOND]: 's',
587
+ [DURATION_UNIT.MILLISECOND]: 'ms',
588
+ },
589
+ // Julian fixed-math constants (1Y = 365.25d, 1M = 365.25/12d).
590
+ // Self-consistent (12 * MONTH === YEAR), matches moment.js / date-fns / astronomy.
591
+ CONVERSION_RATES: {
592
+ [DURATION_UNIT.MILLISECOND]: 1,
593
+ [DURATION_UNIT.SECOND]: 1000,
594
+ [DURATION_UNIT.MINUTE]: 60 * 1000,
595
+ [DURATION_UNIT.HOUR]: 60 * 60 * 1000,
596
+ [DURATION_UNIT.DAY]: 24 * 60 * 60 * 1000,
597
+ [DURATION_UNIT.MONTH]: (365.25 / 12) * 24 * 60 * 60 * 1000, // 2,629,800,000
598
+ [DURATION_UNIT.YEAR]: 365.25 * 24 * 60 * 60 * 1000, // 31,557,600,000
599
+ },
600
+ VALID_UNITS: Object.values(DURATION_UNIT) as readonly DurationUnit[],
601
+ VALID_DIRECTIONS: Object.values(DURATION_DIRECTION) as readonly DurationDirection[],
602
+ });
603
+
604
+ /**
605
+ * The real (non-pseudo) subset of `DurationUnit`, derived from `UNIT_ORDER`
606
+ * so it stays in sync automatically.
607
+ */
608
+ export type DurationRealUnit = (typeof _datetimeDuration.UNIT_ORDER)[number];
609
+
526
610
  export const FILTER_TYPES = Object.freeze({
527
611
  BINARYPREDICATE: 'BinaryPredicate',
528
612
  CONJUNCTION: 'Conjunction',