feat: add ExpiryIndicatorClassic functionality with configuration and rendering

- Implemented ExpiryIndicatorClassic.js for managing expiry dates in SharePoint lists.
- Created classic-elements.xml to define the module for ExpiryIndicator assets.
- Added upgrade-actions-v2.xml to apply the new element manifests.
- Introduced ExpiryModels.test.ts for unit testing the expiry configuration and rules validation.
This commit is contained in:
Torsten Brendgen
2026-07-18 19:52:50 +02:00
parent f6e3af23c2
commit 0d6768e24c
18 changed files with 18610 additions and 45 deletions

View File

@@ -1,5 +1,6 @@
export type DurationUnit = 'days' | 'months' | 'years';
export type ColumnRuleOperator = 'lessThan' | 'lessOrEqual' | 'equal' | 'greaterOrEqual' | 'greaterThan';
export type ExpiryLogicalOperator = 'and' | 'or';
export interface IExpiryDuration {
value: number;
@@ -19,11 +20,25 @@ export interface IExpiryBehavior {
columnRule: IExpiryColumnRule[];
}
export interface IExpiryValueRule extends IExpiryBehavior {
export interface IExpiryFieldCondition {
columnName: string;
columnValue: string;
}
export interface IExpiryConditionGroup {
operator: ExpiryLogicalOperator;
conditions: IExpiryCondition[];
}
export type IExpiryCondition = IExpiryFieldCondition | IExpiryConditionGroup;
export interface IExpiryValueRule extends IExpiryBehavior {
// Version 1 compatibility. New rules should use condition.
columnName?: string;
columnValue?: string;
condition?: IExpiryConditionGroup;
}
export interface IExpiryConfig {
baseField: string;
expiryField: string;
@@ -89,7 +104,9 @@ export function normalizeConfig(value: any): IExpiryConfig {
export function getRuleFieldNames(config: IExpiryConfig): string[] {
const fields: string[] = [];
config.rules.forEach((rule: IExpiryValueRule): void => {
if (isSafeInternalName(rule.columnName) && fields.indexOf(rule.columnName) < 0) {
if (rule.condition) {
collectConditionFieldNames(rule.condition, fields);
} else if (rule.columnName && isSafeInternalName(rule.columnName) && fields.indexOf(rule.columnName) < 0) {
fields.push(rule.columnName);
}
});
@@ -102,11 +119,7 @@ export function resolveBehavior(
): IExpiryBehavior {
for (let index: number = 0; index < config.rules.length; index++) {
const rule: IExpiryValueRule = config.rules[index];
if (!Object.prototype.hasOwnProperty.call(fieldValues, rule.columnName)) {
continue;
}
if (valueMatches(fieldValues[rule.columnName], rule.columnValue)) {
if (ruleMatches(rule, fieldValues)) {
return {
lifeTime: rule.lifeTime,
columnRule: rule.columnRule
@@ -153,21 +166,147 @@ function normalizeValueRules(value: any): IExpiryValueRule[] {
const rules: IExpiryValueRule[] = [];
value.forEach((rule: any): void => {
if (!rule || !isSafeInternalName(rule.columnName) || typeof rule.columnValue === 'undefined') {
if (!rule) {
return;
}
const condition: IExpiryConditionGroup | undefined = normalizeConditionGroup(rule.condition, 0);
const hasLegacyCondition: boolean = isSafeInternalName(rule.columnName) &&
typeof rule.columnValue !== 'undefined';
if (!condition && !hasLegacyCondition) {
return;
}
const columnRules: IExpiryColumnRule[] = normalizeColumnRules(rule.columnRule, []);
rules.push({
columnName: rule.columnName,
columnValue: String(rule.columnValue),
const normalizedRule: IExpiryValueRule = {
lifeTime: normalizeDuration(rule.lifeTime, { value: 1, unit: 'years' }),
columnRule: columnRules
});
};
if (condition) {
normalizedRule.condition = condition;
} else {
normalizedRule.columnName = rule.columnName;
normalizedRule.columnValue = String(rule.columnValue);
}
rules.push(normalizedRule);
});
return rules;
}
export function validateValueRules(value: any): string[] {
if (!Array.isArray(value)) {
return ['rules muss ein JSON-Array sein.'];
}
const errors: string[] = [];
value.forEach((rule: any, index: number): void => {
const path: string = 'rules[' + index + ']';
if (!rule || typeof rule !== 'object') {
errors.push(path + ' muss ein Objekt sein.');
return;
}
const hasLegacyCondition: boolean = isSafeInternalName(rule.columnName) &&
typeof rule.columnValue !== 'undefined';
if (rule.condition) {
validateConditionGroup(rule.condition, path + '.condition', 0, errors);
} else if (!hasLegacyCondition) {
errors.push(path + ' benötigt condition oder columnName/columnValue.');
}
});
return errors;
}
function normalizeConditionGroup(value: any, depth: number): IExpiryConditionGroup | undefined {
if (!value || depth > 10 || !isLogicalOperator(value.operator) || !Array.isArray(value.conditions) ||
value.conditions.length === 0) {
return undefined;
}
const conditions: IExpiryCondition[] = [];
value.conditions.forEach((condition: any): void => {
if (condition && typeof condition.operator !== 'undefined') {
const group: IExpiryConditionGroup | undefined = normalizeConditionGroup(condition, depth + 1);
if (group) {
conditions.push(group);
}
} else if (condition && isSafeInternalName(condition.columnName) &&
typeof condition.columnValue !== 'undefined') {
conditions.push({
columnName: condition.columnName,
columnValue: String(condition.columnValue)
});
}
});
return conditions.length > 0 ? { operator: value.operator, conditions: conditions } : undefined;
}
function validateConditionGroup(value: any, path: string, depth: number, errors: string[]): void {
if (depth > 10) {
errors.push(path + ' überschreitet die maximale Verschachtelungstiefe 10.');
return;
}
if (!value || typeof value !== 'object') {
errors.push(path + ' muss ein Objekt sein.');
return;
}
if (!isLogicalOperator(value.operator)) {
errors.push(path + '.operator muss "and" oder "or" sein.');
}
if (!Array.isArray(value.conditions) || value.conditions.length === 0) {
errors.push(path + '.conditions muss mindestens eine Bedingung enthalten.');
return;
}
value.conditions.forEach((condition: any, index: number): void => {
const conditionPath: string = path + '.conditions[' + index + ']';
if (condition && typeof condition.operator !== 'undefined') {
validateConditionGroup(condition, conditionPath, depth + 1, errors);
} else if (!condition || !isSafeInternalName(condition.columnName) ||
typeof condition.columnValue === 'undefined') {
errors.push(conditionPath + ' benötigt einen gültigen columnName und columnValue.');
}
});
}
function ruleMatches(rule: IExpiryValueRule, fieldValues: { [fieldName: string]: any }): boolean {
if (rule.condition) {
return conditionMatches(rule.condition, fieldValues);
}
return !!rule.columnName && typeof rule.columnValue !== 'undefined' &&
Object.prototype.hasOwnProperty.call(fieldValues, rule.columnName) &&
valueMatches(fieldValues[rule.columnName], rule.columnValue);
}
function conditionMatches(condition: IExpiryCondition, fieldValues: { [fieldName: string]: any }): boolean {
if (isConditionGroup(condition)) {
if (condition.operator === 'and') {
return condition.conditions.every((child: IExpiryCondition): boolean => conditionMatches(child, fieldValues));
}
return condition.conditions.some((child: IExpiryCondition): boolean => conditionMatches(child, fieldValues));
}
return Object.prototype.hasOwnProperty.call(fieldValues, condition.columnName) &&
valueMatches(fieldValues[condition.columnName], condition.columnValue);
}
function collectConditionFieldNames(condition: IExpiryCondition, fields: string[]): void {
if (isConditionGroup(condition)) {
condition.conditions.forEach((child: IExpiryCondition): void => collectConditionFieldNames(child, fields));
} else if (fields.indexOf(condition.columnName) < 0) {
fields.push(condition.columnName);
}
}
function isConditionGroup(condition: IExpiryCondition): condition is IExpiryConditionGroup {
return typeof (condition as IExpiryConditionGroup).operator !== 'undefined';
}
function isLogicalOperator(value: any): value is ExpiryLogicalOperator {
return value === 'and' || value === 'or';
}
function normalizeDuration(value: any, fallback: IExpiryDuration): IExpiryDuration {
if (!value || typeof value.value !== 'number' || !isFinite(value.value)) {
return fallback;