Files
ExpiryIndicator/src/common/ExpiryModels.ts
Torsten Brendgen 0d6768e24c 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.
2026-07-18 19:52:50 +02:00

399 lines
13 KiB
TypeScript

export type DurationUnit = 'days' | 'months' | 'years';
export type ColumnRuleOperator = 'lessThan' | 'lessOrEqual' | 'equal' | 'greaterOrEqual' | 'greaterThan';
export type ExpiryLogicalOperator = 'and' | 'or';
export interface IExpiryDuration {
value: number;
unit: DurationUnit;
}
export interface IExpiryColumnRule {
daysUntilExpiry: number;
operator: ColumnRuleOperator;
backgroundColor: string;
textColor: string;
label: string;
}
export interface IExpiryBehavior {
lifeTime: IExpiryDuration;
columnRule: IExpiryColumnRule[];
}
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;
default: IExpiryBehavior;
rules: IExpiryValueRule[];
nullText: string;
confirmExtension: boolean;
}
export interface IExpiryEvaluation {
effectiveExpiryDate: Date;
daysUntilExpiry: number;
matchedRule?: IExpiryColumnRule;
wasCalculated: boolean;
}
export interface IItemDateValues {
id: number;
created: Date;
expiry?: Date;
fieldValues: { [fieldName: string]: any };
}
export interface IUpdateResult {
id: number;
succeeded: boolean;
newExpiryDate?: Date;
error?: string;
}
export function createDefaultConfig(): IExpiryConfig {
return {
baseField: 'Created',
expiryField: 'ExpiryDate',
default: {
lifeTime: { value: 2, unit: 'years' },
columnRule: createDefaultColumnRules()
},
rules: [],
nullText: 'Kein Ablaufdatum',
confirmExtension: true
};
}
export function normalizeConfig(value: any): IExpiryConfig {
const defaults: IExpiryConfig = createDefaultConfig();
const config: any = value || {};
const defaultBehavior: any = config.default || {};
return {
baseField: isSafeInternalName(config.baseField) ? config.baseField : defaults.baseField,
expiryField: isSafeInternalName(config.expiryField) ? config.expiryField : defaults.expiryField,
default: {
lifeTime: normalizeDuration(defaultBehavior.lifeTime, defaults.default.lifeTime),
columnRule: normalizeColumnRules(defaultBehavior.columnRule, defaults.default.columnRule)
},
rules: normalizeValueRules(config.rules),
nullText: typeof config.nullText === 'string' ? config.nullText : defaults.nullText,
confirmExtension: typeof config.confirmExtension === 'boolean' ? config.confirmExtension : defaults.confirmExtension
};
}
export function getRuleFieldNames(config: IExpiryConfig): string[] {
const fields: string[] = [];
config.rules.forEach((rule: IExpiryValueRule): void => {
if (rule.condition) {
collectConditionFieldNames(rule.condition, fields);
} else if (rule.columnName && isSafeInternalName(rule.columnName) && fields.indexOf(rule.columnName) < 0) {
fields.push(rule.columnName);
}
});
return fields;
}
export function resolveBehavior(
config: IExpiryConfig,
fieldValues: { [fieldName: string]: any }
): IExpiryBehavior {
for (let index: number = 0; index < config.rules.length; index++) {
const rule: IExpiryValueRule = config.rules[index];
if (ruleMatches(rule, fieldValues)) {
return {
lifeTime: rule.lifeTime,
columnRule: rule.columnRule
};
}
}
return config.default;
}
export function isSafeInternalName(value: string): boolean {
return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}
function createDefaultColumnRules(): IExpiryColumnRule[] {
return [
{
daysUntilExpiry: 0,
operator: 'lessOrEqual',
backgroundColor: '#a4262c',
textColor: '#ffffff',
label: 'Abgelaufen'
},
{
daysUntilExpiry: 30,
operator: 'lessOrEqual',
backgroundColor: '#ffaa44',
textColor: '#000000',
label: 'Läuft bald ab'
},
{
daysUntilExpiry: 90,
operator: 'lessOrEqual',
backgroundColor: '#fff4ce',
textColor: '#000000',
label: 'Beobachten'
}
];
}
function normalizeValueRules(value: any): IExpiryValueRule[] {
if (!Array.isArray(value)) {
return [];
}
const rules: IExpiryValueRule[] = [];
value.forEach((rule: any): void => {
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, []);
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;
}
if (value.unit !== 'days' && value.unit !== 'months' && value.unit !== 'years') {
return fallback;
}
return { value: Math.floor(value.value), unit: value.unit };
}
function normalizeColumnRules(value: any, fallback: IExpiryColumnRule[]): IExpiryColumnRule[] {
if (!Array.isArray(value)) {
return fallback;
}
const rules: IExpiryColumnRule[] = [];
value.forEach((rule: any): void => {
if (!rule || typeof rule.daysUntilExpiry !== 'number' || !isFinite(rule.daysUntilExpiry)) {
return;
}
if (!isColumnRuleOperator(rule.operator) || !isColor(rule.backgroundColor) || !isColor(rule.textColor)) {
return;
}
rules.push({
daysUntilExpiry: Math.floor(rule.daysUntilExpiry),
operator: rule.operator,
backgroundColor: rule.backgroundColor,
textColor: rule.textColor,
label: typeof rule.label === 'string' ? rule.label : ''
});
});
return rules.length > 0 || fallback.length === 0 ? rules : fallback;
}
function valueMatches(rawValue: any, configuredValue: string): boolean {
if (rawValue === null || typeof rawValue === 'undefined') {
return configuredValue === '';
}
let values: any[];
if (Array.isArray(rawValue)) {
values = rawValue;
} else if (rawValue.results && Array.isArray(rawValue.results)) {
values = rawValue.results;
} else {
values = [rawValue];
}
return values.some((value: any): boolean => {
if (value && typeof value === 'object') {
// SharePoint taxonomy values are not consistent across REST versions. In
// SharePoint Server the Label may contain the WssId instead of the term
// label, whereas TermGuid remains stable.
if (typeof value.TermGuid === 'string' && equalsIgnoreCase(value.TermGuid, configuredValue)) {
return true;
}
return String(value.Title || value.LookupValue || value.Label || value.Value || '') === configuredValue;
}
const textValue: string = String(value);
if (textValue === configuredValue) {
return true;
}
// Also support serialized taxonomy values such as "Label|TermGuid" and
// "-1;#Label|TermGuid".
const separatorIndex: number = textValue.lastIndexOf('|');
return separatorIndex >= 0 && equalsIgnoreCase(textValue.substring(separatorIndex + 1), configuredValue);
});
}
function equalsIgnoreCase(left: string, right: string): boolean {
return left.toLowerCase() === right.toLowerCase();
}
function isColumnRuleOperator(value: string): boolean {
return value === 'lessThan' || value === 'lessOrEqual' || value === 'equal' ||
value === 'greaterOrEqual' || value === 'greaterThan';
}
function isColor(value: string): boolean {
return typeof value === 'string' && (
/^#[0-9a-fA-F]{3}$/.test(value) ||
/^#[0-9a-fA-F]{6}$/.test(value) ||
/^rgba?\([0-9.,% ]+\)$/.test(value) ||
/^[a-zA-Z]+$/.test(value)
);
}