@memberjunction/ng-record-changes 5.27.1 → 5.28.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/README.md +220 -0
- package/dist/__tests__/restore-version.test.js +102 -152
- package/dist/__tests__/restore-version.test.js.map +1 -1
- package/dist/lib/__tests__/record-changes.test.js +21 -12
- package/dist/lib/__tests__/record-changes.test.js.map +1 -1
- package/dist/lib/ng-record-changes.component.d.ts +171 -49
- package/dist/lib/ng-record-changes.component.d.ts.map +1 -1
- package/dist/lib/ng-record-changes.component.js +645 -416
- package/dist/lib/ng-record-changes.component.js.map +1 -1
- package/dist/lib/ng-record-changes.module.d.ts +6 -5
- package/dist/lib/ng-record-changes.module.d.ts.map +1 -1
- package/dist/lib/ng-record-changes.module.js +9 -4
- package/dist/lib/ng-record-changes.module.js.map +1 -1
- package/dist/lib/restore-preview-panel/restore-preview-panel.component.d.ts +267 -0
- package/dist/lib/restore-preview-panel/restore-preview-panel.component.d.ts.map +1 -0
- package/dist/lib/restore-preview-panel/restore-preview-panel.component.js +748 -0
- package/dist/lib/restore-preview-panel/restore-preview-panel.component.js.map +1 -0
- package/dist/public-api.d.ts +1 -0
- package/dist/public-api.d.ts.map +1 -1
- package/dist/public-api.js +2 -1
- package/dist/public-api.js.map +1 -1
- package/package.json +8 -7
package/README.md
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# @memberjunction/ng-record-changes
|
|
2
|
+
|
|
3
|
+
Angular components for browsing and restoring a record's change history. Renders the `RecordChange` timeline as a slide-in panel with type-aware diffs, version-label chips, restore lineage, and a reusable preview panel for the actual restore operation.
|
|
4
|
+
|
|
5
|
+
```mermaid
|
|
6
|
+
flowchart LR
|
|
7
|
+
subgraph Timeline["RecordChangesComponent"]
|
|
8
|
+
TL[Slide-in panel] --> CARDS[Date-grouped change cards]
|
|
9
|
+
CARDS --> DIFFS[Type-aware diffs<br/>boolean / date / number / text]
|
|
10
|
+
CARDS --> CHIP[Restored-from lineage chip]
|
|
11
|
+
FILTERS[Conditional filter pills] --> POP[Overflow popover<br/>when 3+ pills]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
subgraph Restore["RestorePreviewPanelComponent"]
|
|
15
|
+
PV[Slide-in preview] --> ROWS[Field-by-field rows<br/>with checkboxes]
|
|
16
|
+
ROWS --> MODE{Mode}
|
|
17
|
+
MODE -->|live| DIFF[current vs snapshot]
|
|
18
|
+
MODE -->|undelete| SNAP[snapshot only]
|
|
19
|
+
ROWS --> COMMIT[BeforeRestoreCommit<br/>cancelable]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
Timeline -- "user clicks Restore" --> Restore
|
|
23
|
+
Restore -- "RestoreConfirmed event<br/>(host applies + saves)" --> HOST[Host component]
|
|
24
|
+
|
|
25
|
+
style Timeline fill:#264FAF,stroke:#1e3f8c,color:#fff
|
|
26
|
+
style Restore fill:#7c3aed,stroke:#5b21b6,color:#fff
|
|
27
|
+
style HOST fill:#16a34a,stroke:#15803d,color:#fff
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install @memberjunction/ng-record-changes
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
### Import the Module
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import { RecordChangesModule } from '@memberjunction/ng-record-changes';
|
|
42
|
+
|
|
43
|
+
@NgModule({
|
|
44
|
+
imports: [RecordChangesModule]
|
|
45
|
+
})
|
|
46
|
+
export class MyModule { }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Timeline + Restore (the typical case)
|
|
50
|
+
|
|
51
|
+
```html
|
|
52
|
+
<mj-record-changes
|
|
53
|
+
[record]="myEntity"
|
|
54
|
+
[AllowRestore]="true"
|
|
55
|
+
(dialogClosed)="showHistory = false"
|
|
56
|
+
(RestoreRequested)="onRestoreRequested($event)">
|
|
57
|
+
</mj-record-changes>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
async onRestoreRequested(event: RestoreVersionEvent) {
|
|
62
|
+
// Apply each selected snapshot field
|
|
63
|
+
for (const fv of event.FieldValues) {
|
|
64
|
+
this.myEntity.Set(fv.FieldName, fv.Value);
|
|
65
|
+
}
|
|
66
|
+
// Mark the next save as a restore so the provider populates lineage columns
|
|
67
|
+
this.myEntity.SetRestoreContext(event.SourceChangeID, event.Reason);
|
|
68
|
+
try {
|
|
69
|
+
await this.myEntity.Save();
|
|
70
|
+
} finally {
|
|
71
|
+
this.myEntity.ClearRestoreContext();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The host is responsible for the actual save so consumers can intercept (custom approval, audit logging, etc.). `record-form-container` already wires this end-to-end.
|
|
77
|
+
|
|
78
|
+
### Standalone Restore Preview (un-delete from a Recycle Bin)
|
|
79
|
+
|
|
80
|
+
```html
|
|
81
|
+
<mj-restore-preview-panel
|
|
82
|
+
[Visible]="showPreview"
|
|
83
|
+
[Mode]="'undelete'"
|
|
84
|
+
[RecordChange]="deletedChange"
|
|
85
|
+
[EntityName]="'Customers'"
|
|
86
|
+
(RestoreConfirmed)="onUndelete($event)"
|
|
87
|
+
(RestoreCancelled)="showPreview = false">
|
|
88
|
+
</mj-restore-preview-panel>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## API Reference
|
|
92
|
+
|
|
93
|
+
### `RecordChangesComponent` (`mj-record-changes`)
|
|
94
|
+
|
|
95
|
+
The slide-in timeline of all changes to a single record. Hosts the reusable `RestorePreviewPanelComponent` for the actual restore confirmation flow.
|
|
96
|
+
|
|
97
|
+
#### Inputs
|
|
98
|
+
|
|
99
|
+
| Input | Type | Default | Description |
|
|
100
|
+
|-------|------|---------|-------------|
|
|
101
|
+
| `record` | `BaseEntity` | — | **Required.** The live record whose change history will be displayed. |
|
|
102
|
+
| `AllowRestore` | `boolean` | `false` | When true, renders a "Restore record to this version" button on each change card and exposes the `RestoreRequested` event. |
|
|
103
|
+
|
|
104
|
+
#### Outputs
|
|
105
|
+
|
|
106
|
+
| Output | Event Type | Description |
|
|
107
|
+
|--------|------------|-------------|
|
|
108
|
+
| `dialogClosed` | `void` | Emitted when the user closes the slide-in. |
|
|
109
|
+
| `RestoreRequested` | `RestoreVersionEvent` | Emitted after the user confirms a restore in the preview panel. The host is responsible for applying the snapshot and saving. |
|
|
110
|
+
|
|
111
|
+
#### `RestoreVersionEvent`
|
|
112
|
+
|
|
113
|
+
| Property | Type | Description |
|
|
114
|
+
|----------|------|-------------|
|
|
115
|
+
| `SourceChangeID` | `string` | ID of the historical RecordChange row whose state is being restored. Pass to `BaseEntity.SetRestoreContext()`. |
|
|
116
|
+
| `ChangedAt` | `Date` | When the historical change was made. |
|
|
117
|
+
| `ChangedByUser` | `string` | Display name / email of who made the historical change. |
|
|
118
|
+
| `Reason` | `string \| null` | Optional user-entered reason. Pass to `BaseEntity.SetRestoreContext()`. |
|
|
119
|
+
| `FieldValues` | `Array<{ FieldName; Value }>` | Selected snapshot field values, ready to pass to `BaseEntity.Set()`. |
|
|
120
|
+
|
|
121
|
+
#### Conditional filter pills
|
|
122
|
+
|
|
123
|
+
The filter bar renders only chips for change types/sources that actually exist in the loaded data — empty types never show. When more than two conditional chips would render they collapse into a "More filters ▾" popover with checkboxes. The "All" chip is always present.
|
|
124
|
+
|
|
125
|
+
#### Lineage chip
|
|
126
|
+
|
|
127
|
+
Change rows where `RestoredFromID` is populated render a violet "Restored from {time} by {user}" chip. Clicking the chip scrolls to and highlights the source row in the same timeline.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
### `RestorePreviewPanelComponent` (`mj-restore-preview-panel`)
|
|
132
|
+
|
|
133
|
+
Reusable slide-in that previews a restore operation against a historical `MJRecordChangeEntity` and lets the user confirm with field-level granularity. Used by both `RecordChangesComponent` (for live-record restores) and `RecycleBinComponent` (for un-delete inserts).
|
|
134
|
+
|
|
135
|
+
#### Inputs
|
|
136
|
+
|
|
137
|
+
| Input | Type | Default | Description |
|
|
138
|
+
|-------|------|---------|-------------|
|
|
139
|
+
| `Visible` | `boolean` | `false` | Controls panel visibility. |
|
|
140
|
+
| `Mode` | `'live' \| 'undelete'` | `'live'` | `live` shows current-vs-snapshot diff; `undelete` shows snapshot only (the live record no longer exists). |
|
|
141
|
+
| `RecordChange` | `MJRecordChangeEntity` | `null` | **Required.** The historical change row whose state will be restored. The component reads `FullRecordJSON` to determine the target state. Any `Type` (`Create`, `Update`, `Delete`, `Snapshot`) is a valid restore source. |
|
|
142
|
+
| `LiveRecord` | `BaseEntity \| null` | `null` | The current live record to diff against. Required in `live` mode, ignored in `undelete` mode. |
|
|
143
|
+
| `EntityName` | `string \| null` | `null` | Required in `undelete` mode (where there's no `LiveRecord` to read it from). Optional in `live` mode. |
|
|
144
|
+
| `RequireReason` | `boolean` | `false` | When true, the Restore button disables until the user enters a non-empty reason. |
|
|
145
|
+
| `HideReason` | `boolean` | `false` | When true, hides the optional reason text area entirely. |
|
|
146
|
+
|
|
147
|
+
#### Outputs
|
|
148
|
+
|
|
149
|
+
| Output | Event Type | Description |
|
|
150
|
+
|--------|------------|-------------|
|
|
151
|
+
| `BeforeRestoreCommit` | `BeforeRestoreCommitEvent` | **Cancelable.** Fires when the user clicks Restore but before `RestoreConfirmed`. Set `cancel = true` to abort. |
|
|
152
|
+
| `RestoreConfirmed` | `RestoreCommitEvent` | Fires after the user confirms (and `BeforeRestoreCommit` was not cancelled). Host applies the field values and saves. |
|
|
153
|
+
| `RestoreCancelled` | `void` | Fires when the user dismisses the preview without restoring. |
|
|
154
|
+
|
|
155
|
+
#### `RestoreCommitEvent`
|
|
156
|
+
|
|
157
|
+
| Property | Type | Description |
|
|
158
|
+
|----------|------|-------------|
|
|
159
|
+
| `SourceChangeID` | `string` | ID of the source RecordChange row. |
|
|
160
|
+
| `Reason` | `string \| null` | Optional user-entered reason. |
|
|
161
|
+
| `FieldValues` | `Array<{ FieldName; Value }>` | Selected field values, ready for `BaseEntity.Set()`. |
|
|
162
|
+
| `AllRows` | `RestoreFieldRow[]` | Full preview rows including unselected, for audit/logging. |
|
|
163
|
+
| `Mode` | `'live' \| 'undelete'` | The mode the panel was operating in. |
|
|
164
|
+
|
|
165
|
+
## Semantic correctness
|
|
166
|
+
|
|
167
|
+
The preview compares the **full snapshot** captured in the source change's `FullRecordJSON` to the current live record (or to nothing in undelete mode). It does NOT roll back a single delta — restoring `v2` means *"make the record look like it did at v2"*, not *"undo v3's changes"*.
|
|
168
|
+
|
|
169
|
+
This also means any change row is a valid restore target: `Create` (the original state), `Update` (post-update state), `Snapshot` (an explicit point-in-time capture from the Version Label system), or `Delete` (state before deletion — used by the Recycle Bin).
|
|
170
|
+
|
|
171
|
+
## Type Exports
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
import {
|
|
175
|
+
// Timeline
|
|
176
|
+
RecordChangesComponent,
|
|
177
|
+
RecordChangesModule,
|
|
178
|
+
RestoreVersionEvent,
|
|
179
|
+
FieldChangeInfo,
|
|
180
|
+
DateGroup,
|
|
181
|
+
FilterPill,
|
|
182
|
+
|
|
183
|
+
// Restore Preview
|
|
184
|
+
RestorePreviewPanelComponent,
|
|
185
|
+
RestorePreviewMode,
|
|
186
|
+
RestoreFieldRow,
|
|
187
|
+
RestoreCommitEvent,
|
|
188
|
+
BeforeRestoreCommitEvent,
|
|
189
|
+
} from '@memberjunction/ng-record-changes';
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Dependencies
|
|
193
|
+
|
|
194
|
+
### Runtime Dependencies
|
|
195
|
+
|
|
196
|
+
| Package | Description |
|
|
197
|
+
|---------|-------------|
|
|
198
|
+
| `@memberjunction/core` | Core framework (BaseEntity, Metadata, RunView) |
|
|
199
|
+
| `@memberjunction/core-entities` | `MJRecordChangeEntity` definition |
|
|
200
|
+
| `@memberjunction/ng-shared-generic` | Shared generic components (mj-loading) |
|
|
201
|
+
| `@memberjunction/ng-versions` | Provides `mj-slide-panel` and version label create wizard |
|
|
202
|
+
| `@memberjunction/ng-notifications` | Toast notifications |
|
|
203
|
+
| `diff` | Word/character text diff for the timeline |
|
|
204
|
+
|
|
205
|
+
### Peer Dependencies
|
|
206
|
+
|
|
207
|
+
- `@angular/common` ^21.x
|
|
208
|
+
- `@angular/core` ^21.x
|
|
209
|
+
- `@angular/forms` ^21.x
|
|
210
|
+
|
|
211
|
+
## Build
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
cd packages/Angular/Generic/record-changes
|
|
215
|
+
npm run build
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
ISC
|
|
@@ -1,173 +1,123 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for restore version
|
|
2
|
+
* Tests for restore version event shape and conditional filter pill logic.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Pure logic tests — no Angular TestBed. The deeper preview-building logic
|
|
5
|
+
* lives in RestorePreviewPanelComponent and is tested via its own tests
|
|
6
|
+
* (which exercise the reusable component independently).
|
|
6
7
|
*/
|
|
7
8
|
import { describe, it, expect } from 'vitest';
|
|
8
|
-
// ── Extracted pure logic mirrors from RecordChangesComponent ──
|
|
9
|
-
/** Mirrors RecordChangesComponent.parseFieldChanges */
|
|
10
|
-
function parseFieldChanges(changesJSON) {
|
|
11
|
-
const fieldChanges = {};
|
|
12
|
-
try {
|
|
13
|
-
const changesJson = JSON.parse(changesJSON || '{}');
|
|
14
|
-
for (const key of Object.keys(changesJson)) {
|
|
15
|
-
const entry = changesJson[key];
|
|
16
|
-
const fieldName = entry.field || key;
|
|
17
|
-
fieldChanges[fieldName] = {
|
|
18
|
-
OldValue: entry.oldValue,
|
|
19
|
-
NewValue: entry.newValue,
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
// Return empty on parse failure
|
|
25
|
-
}
|
|
26
|
-
return fieldChanges;
|
|
27
|
-
}
|
|
28
|
-
/** Mirrors RecordChangesComponent.buildRestorePreviewFields (simplified without EntityInfo) */
|
|
29
|
-
function buildRestorePreviewFields(changesJSON, currentValues) {
|
|
30
|
-
let changesJson;
|
|
31
|
-
try {
|
|
32
|
-
changesJson = JSON.parse(changesJSON || '{}');
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return [];
|
|
36
|
-
}
|
|
37
|
-
const diffs = [];
|
|
38
|
-
for (const key of Object.keys(changesJson)) {
|
|
39
|
-
const entry = changesJson[key];
|
|
40
|
-
const fieldName = entry.field || key;
|
|
41
|
-
const versionValue = String(entry.oldValue ?? '');
|
|
42
|
-
const currentValue = currentValues[fieldName] ?? '';
|
|
43
|
-
diffs.push({
|
|
44
|
-
FieldName: fieldName,
|
|
45
|
-
DisplayName: fieldName,
|
|
46
|
-
VersionValue: versionValue,
|
|
47
|
-
CurrentValue: currentValue,
|
|
48
|
-
IsChanged: versionValue !== currentValue,
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
return diffs;
|
|
52
|
-
}
|
|
53
9
|
// ═══════════════════════════════════════════
|
|
54
|
-
// RestoreVersionEvent
|
|
10
|
+
// RestoreVersionEvent shape
|
|
55
11
|
// ═══════════════════════════════════════════
|
|
56
|
-
describe('
|
|
57
|
-
it('should
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
});
|
|
74
|
-
const result = parseFieldChanges(json);
|
|
75
|
-
expect(result['Status']).toEqual({ OldValue: 'Active', NewValue: 'Inactive' });
|
|
12
|
+
describe('RestoreVersionEvent', () => {
|
|
13
|
+
it('should have the correct shape with FieldValues array', () => {
|
|
14
|
+
const event = {
|
|
15
|
+
SourceChangeID: 'abc-123',
|
|
16
|
+
ChangedAt: new Date('2025-01-15T10:00:00Z'),
|
|
17
|
+
ChangedByUser: 'user@example.com',
|
|
18
|
+
Reason: 'Reverting incorrect Q2 entries',
|
|
19
|
+
FieldValues: [
|
|
20
|
+
{ FieldName: 'Name', Value: 'Old Name' },
|
|
21
|
+
{ FieldName: 'Status', Value: 'Active' },
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
expect(event.SourceChangeID).toBe('abc-123');
|
|
25
|
+
expect(event.ChangedByUser).toBe('user@example.com');
|
|
26
|
+
expect(event.Reason).toBe('Reverting incorrect Q2 entries');
|
|
27
|
+
expect(event.FieldValues).toHaveLength(2);
|
|
28
|
+
expect(event.FieldValues[0]).toEqual({ FieldName: 'Name', Value: 'Old Name' });
|
|
76
29
|
});
|
|
77
|
-
it('should
|
|
78
|
-
const
|
|
79
|
-
|
|
30
|
+
it('should support null Reason for restores without explanation', () => {
|
|
31
|
+
const event = {
|
|
32
|
+
SourceChangeID: 'xyz',
|
|
33
|
+
ChangedAt: new Date(),
|
|
34
|
+
ChangedByUser: '',
|
|
35
|
+
Reason: null,
|
|
36
|
+
FieldValues: [],
|
|
37
|
+
};
|
|
38
|
+
expect(event.Reason).toBeNull();
|
|
39
|
+
expect(event.FieldValues).toEqual([]);
|
|
80
40
|
});
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
41
|
+
});
|
|
42
|
+
function computePillKeys(changes) {
|
|
43
|
+
const counts = {};
|
|
44
|
+
let restoreCount = 0;
|
|
45
|
+
for (const c of changes) {
|
|
46
|
+
counts[c.Type] = (counts[c.Type] ?? 0) + 1;
|
|
47
|
+
if (c.Source === 'Restore' || c.RestoredFromID != null)
|
|
48
|
+
restoreCount++;
|
|
49
|
+
}
|
|
50
|
+
const keys = [];
|
|
51
|
+
if ((counts['Update'] ?? 0) > 0)
|
|
52
|
+
keys.push('Update');
|
|
53
|
+
if ((counts['Create'] ?? 0) > 0)
|
|
54
|
+
keys.push('Create');
|
|
55
|
+
if ((counts['Delete'] ?? 0) > 0)
|
|
56
|
+
keys.push('Delete');
|
|
57
|
+
if ((counts['Snapshot'] ?? 0) > 0)
|
|
58
|
+
keys.push('Snapshot');
|
|
59
|
+
if (restoreCount > 0)
|
|
60
|
+
keys.push('Restore');
|
|
61
|
+
return keys;
|
|
62
|
+
}
|
|
63
|
+
describe('rebuildConditionalPills (logic)', () => {
|
|
64
|
+
it('emits no conditional pills for empty data', () => {
|
|
65
|
+
expect(computePillKeys([])).toEqual([]);
|
|
84
66
|
});
|
|
85
|
-
it('
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
expect(
|
|
91
|
-
OldValue: null,
|
|
92
|
-
NewValue: 'New description',
|
|
93
|
-
});
|
|
67
|
+
it('emits only Update when only updates are loaded', () => {
|
|
68
|
+
const changes = [
|
|
69
|
+
{ Type: 'Update', Source: 'Internal' },
|
|
70
|
+
{ Type: 'Update', Source: 'Internal' },
|
|
71
|
+
];
|
|
72
|
+
expect(computePillKeys(changes)).toEqual(['Update']);
|
|
94
73
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const json = JSON.stringify({
|
|
102
|
-
'0': { field: 'Name', oldValue: 'Old Name', newValue: 'Current Name' },
|
|
103
|
-
'1': { field: 'Status', oldValue: 'Active', newValue: 'Inactive' },
|
|
104
|
-
});
|
|
105
|
-
const currentValues = {
|
|
106
|
-
Name: 'Current Name',
|
|
107
|
-
Status: 'Inactive',
|
|
108
|
-
};
|
|
109
|
-
const diffs = buildRestorePreviewFields(json, currentValues);
|
|
110
|
-
expect(diffs).toHaveLength(2);
|
|
111
|
-
const nameDiff = diffs.find((d) => d.FieldName === 'Name');
|
|
112
|
-
expect(nameDiff.VersionValue).toBe('Old Name');
|
|
113
|
-
expect(nameDiff.CurrentValue).toBe('Current Name');
|
|
114
|
-
expect(nameDiff.IsChanged).toBe(true);
|
|
115
|
-
const statusDiff = diffs.find((d) => d.FieldName === 'Status');
|
|
116
|
-
expect(statusDiff.VersionValue).toBe('Active');
|
|
117
|
-
expect(statusDiff.CurrentValue).toBe('Inactive');
|
|
118
|
-
expect(statusDiff.IsChanged).toBe(true);
|
|
74
|
+
it('emits Update and Create when both types are present', () => {
|
|
75
|
+
const changes = [
|
|
76
|
+
{ Type: 'Create', Source: 'Internal' },
|
|
77
|
+
{ Type: 'Update', Source: 'Internal' },
|
|
78
|
+
];
|
|
79
|
+
expect(computePillKeys(changes)).toEqual(['Update', 'Create']);
|
|
119
80
|
});
|
|
120
|
-
it('
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
};
|
|
127
|
-
const diffs = buildRestorePreviewFields(json, currentValues);
|
|
128
|
-
expect(diffs[0].IsChanged).toBe(false);
|
|
81
|
+
it('emits Restore when any change has Source=Restore', () => {
|
|
82
|
+
const changes = [
|
|
83
|
+
{ Type: 'Update', Source: 'Internal' },
|
|
84
|
+
{ Type: 'Update', Source: 'Restore', RestoredFromID: 'abc' },
|
|
85
|
+
];
|
|
86
|
+
expect(computePillKeys(changes)).toContain('Restore');
|
|
129
87
|
});
|
|
130
|
-
it('
|
|
131
|
-
const
|
|
132
|
-
|
|
88
|
+
it('emits Restore when RestoredFromID is set even if Source is not Restore', () => {
|
|
89
|
+
const changes = [
|
|
90
|
+
{ Type: 'Update', Source: 'Internal', RestoredFromID: 'abc' },
|
|
91
|
+
];
|
|
92
|
+
expect(computePillKeys(changes)).toContain('Restore');
|
|
133
93
|
});
|
|
134
|
-
it('
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
expect(
|
|
140
|
-
expect(diffs[0].VersionValue).toBe('some value');
|
|
141
|
-
expect(diffs[0].IsChanged).toBe(true);
|
|
94
|
+
it('does not emit Restore when no change has restore lineage', () => {
|
|
95
|
+
const changes = [
|
|
96
|
+
{ Type: 'Update', Source: 'Internal' },
|
|
97
|
+
{ Type: 'Create', Source: 'External' },
|
|
98
|
+
];
|
|
99
|
+
expect(computePillKeys(changes)).not.toContain('Restore');
|
|
142
100
|
});
|
|
143
|
-
it('
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
expect(diffs[0].VersionValue).toBe('');
|
|
149
|
-
expect(diffs[0].CurrentValue).toBe('');
|
|
150
|
-
expect(diffs[0].IsChanged).toBe(false);
|
|
101
|
+
it('emits Snapshot when Type=Snapshot rows exist', () => {
|
|
102
|
+
const changes = [
|
|
103
|
+
{ Type: 'Snapshot', Source: 'Internal' },
|
|
104
|
+
];
|
|
105
|
+
expect(computePillKeys(changes)).toEqual(['Snapshot']);
|
|
151
106
|
});
|
|
152
107
|
});
|
|
153
108
|
// ═══════════════════════════════════════════
|
|
154
|
-
//
|
|
109
|
+
// Overflow threshold logic
|
|
155
110
|
// ═══════════════════════════════════════════
|
|
156
|
-
describe('
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
};
|
|
167
|
-
expect(event.VersionID).toBe('abc-123');
|
|
168
|
-
expect(event.ChangedByUser).toBe('user@example.com');
|
|
169
|
-
expect(Object.keys(event.FieldChanges)).toHaveLength(2);
|
|
170
|
-
expect(event.FieldChanges['Name'].OldValue).toBe('Old');
|
|
111
|
+
describe('overflow threshold', () => {
|
|
112
|
+
const OVERFLOW_THRESHOLD = 2;
|
|
113
|
+
it('does not overflow at 1 conditional pill', () => {
|
|
114
|
+
expect(1 > OVERFLOW_THRESHOLD).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
it('does not overflow at 2 conditional pills', () => {
|
|
117
|
+
expect(2 > OVERFLOW_THRESHOLD).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
it('overflows at 3 conditional pills', () => {
|
|
120
|
+
expect(3 > OVERFLOW_THRESHOLD).toBe(true);
|
|
171
121
|
});
|
|
172
122
|
});
|
|
173
123
|
//# sourceMappingURL=restore-version.test.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"restore-version.test.js","sourceRoot":"","sources":["../../src/__tests__/restore-version.test.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Tests for restore version logic extracted from RecordChangesComponent.\n *\n * We test pure logic functions directly to avoid Angular TestBed complexity.\n * These mirror the component's parseFieldChanges, buildRestorePreviewFields, etc.\n */\nimport { describe, it, expect } from 'vitest';\nimport { RestoreFieldDiff, RestoreVersionEvent } from '../lib/ng-record-changes.component';\n\n// ── Extracted pure logic mirrors from RecordChangesComponent ──\n\n/** Mirrors RecordChangesComponent.parseFieldChanges */\nfunction parseFieldChanges(\n changesJSON: string\n): Record<string, { OldValue: unknown; NewValue: unknown }> {\n const fieldChanges: Record<string, { OldValue: unknown; NewValue: unknown }> = {};\n try {\n const changesJson = JSON.parse(changesJSON || '{}') as Record<\n string,\n { field?: string; oldValue?: unknown; newValue?: unknown }\n >;\n for (const key of Object.keys(changesJson)) {\n const entry = changesJson[key];\n const fieldName = entry.field || key;\n fieldChanges[fieldName] = {\n OldValue: entry.oldValue,\n NewValue: entry.newValue,\n };\n }\n } catch {\n // Return empty on parse failure\n }\n return fieldChanges;\n}\n\n/** Mirrors RecordChangesComponent.buildRestorePreviewFields (simplified without EntityInfo) */\nfunction buildRestorePreviewFields(\n changesJSON: string,\n currentValues: Record<string, string>\n): RestoreFieldDiff[] {\n let changesJson: Record<string, { field?: string; oldValue?: unknown; newValue?: unknown }>;\n try {\n changesJson = JSON.parse(changesJSON || '{}');\n } catch {\n return [];\n }\n\n const diffs: RestoreFieldDiff[] = [];\n for (const key of Object.keys(changesJson)) {\n const entry = changesJson[key];\n const fieldName = entry.field || key;\n const versionValue = String(entry.oldValue ?? '');\n const currentValue = currentValues[fieldName] ?? '';\n\n diffs.push({\n FieldName: fieldName,\n DisplayName: fieldName,\n VersionValue: versionValue,\n CurrentValue: currentValue,\n IsChanged: versionValue !== currentValue,\n });\n }\n return diffs;\n}\n\n// ═══════════════════════════════════════════\n// RestoreVersionEvent emission logic\n// ═══════════════════════════════════════════\ndescribe('parseFieldChanges', () => {\n it('should parse ChangesJSON into field change map', () => {\n const json = JSON.stringify({\n '0': { field: 'FirstName', oldValue: 'John', newValue: 'Jonathan' },\n '1': { field: 'Email', oldValue: 'old@example.com', newValue: 'new@example.com' },\n });\n\n const result = parseFieldChanges(json);\n expect(Object.keys(result)).toHaveLength(2);\n expect(result['FirstName']).toEqual({ OldValue: 'John', NewValue: 'Jonathan' });\n expect(result['Email']).toEqual({\n OldValue: 'old@example.com',\n NewValue: 'new@example.com',\n });\n });\n\n it('should use key as field name when field property is missing', () => {\n const json = JSON.stringify({\n Status: { oldValue: 'Active', newValue: 'Inactive' },\n });\n\n const result = parseFieldChanges(json);\n expect(result['Status']).toEqual({ OldValue: 'Active', NewValue: 'Inactive' });\n });\n\n it('should return empty object for invalid JSON', () => {\n const result = parseFieldChanges('not valid json');\n expect(Object.keys(result)).toHaveLength(0);\n });\n\n it('should return empty object for empty string', () => {\n const result = parseFieldChanges('');\n expect(Object.keys(result)).toHaveLength(0);\n });\n\n it('should handle null/undefined old and new values', () => {\n const json = JSON.stringify({\n '0': { field: 'Description', oldValue: null, newValue: 'New description' },\n });\n\n const result = parseFieldChanges(json);\n expect(result['Description']).toEqual({\n OldValue: null,\n NewValue: 'New description',\n });\n });\n});\n\n// ═══════════════════════════════════════════\n// Diff computation between version and current\n// ═══════════════════════════════════════════\ndescribe('buildRestorePreviewFields', () => {\n it('should mark fields as changed when version differs from current', () => {\n const json = JSON.stringify({\n '0': { field: 'Name', oldValue: 'Old Name', newValue: 'Current Name' },\n '1': { field: 'Status', oldValue: 'Active', newValue: 'Inactive' },\n });\n const currentValues: Record<string, string> = {\n Name: 'Current Name',\n Status: 'Inactive',\n };\n\n const diffs = buildRestorePreviewFields(json, currentValues);\n expect(diffs).toHaveLength(2);\n\n const nameDiff = diffs.find((d) => d.FieldName === 'Name');\n expect(nameDiff!.VersionValue).toBe('Old Name');\n expect(nameDiff!.CurrentValue).toBe('Current Name');\n expect(nameDiff!.IsChanged).toBe(true);\n\n const statusDiff = diffs.find((d) => d.FieldName === 'Status');\n expect(statusDiff!.VersionValue).toBe('Active');\n expect(statusDiff!.CurrentValue).toBe('Inactive');\n expect(statusDiff!.IsChanged).toBe(true);\n });\n\n it('should mark fields as unchanged when version matches current', () => {\n const json = JSON.stringify({\n '0': { field: 'Name', oldValue: 'Same Value', newValue: 'Different' },\n });\n const currentValues: Record<string, string> = {\n Name: 'Same Value',\n };\n\n const diffs = buildRestorePreviewFields(json, currentValues);\n expect(diffs[0].IsChanged).toBe(false);\n });\n\n it('should return empty array for invalid JSON', () => {\n const diffs = buildRestorePreviewFields('bad json', {});\n expect(diffs).toHaveLength(0);\n });\n\n it('should handle missing current values (treats as empty string)', () => {\n const json = JSON.stringify({\n '0': { field: 'NewField', oldValue: 'some value', newValue: '' },\n });\n\n const diffs = buildRestorePreviewFields(json, {});\n expect(diffs[0].CurrentValue).toBe('');\n expect(diffs[0].VersionValue).toBe('some value');\n expect(diffs[0].IsChanged).toBe(true);\n });\n\n it('should treat null oldValue as empty string for comparison', () => {\n const json = JSON.stringify({\n '0': { field: 'Notes', oldValue: null, newValue: 'Added notes' },\n });\n\n const diffs = buildRestorePreviewFields(json, { Notes: '' });\n expect(diffs[0].VersionValue).toBe('');\n expect(diffs[0].CurrentValue).toBe('');\n expect(diffs[0].IsChanged).toBe(false);\n });\n});\n\n// ═══════════════════════════════════════════\n// RestoreVersionEvent interface shape\n// ═══════════════════════════════════════════\ndescribe('RestoreVersionEvent', () => {\n it('should have the correct shape', () => {\n const event: RestoreVersionEvent = {\n VersionID: 'abc-123',\n ChangedAt: new Date('2025-01-15T10:00:00Z'),\n ChangedByUser: 'user@example.com',\n FieldChanges: {\n Name: { OldValue: 'Old', NewValue: 'New' },\n Status: { OldValue: 'Active', NewValue: 'Inactive' },\n },\n };\n\n expect(event.VersionID).toBe('abc-123');\n expect(event.ChangedByUser).toBe('user@example.com');\n expect(Object.keys(event.FieldChanges)).toHaveLength(2);\n expect(event.FieldChanges['Name'].OldValue).toBe('Old');\n });\n});\n"]}
|
|
1
|
+
{"version":3,"file":"restore-version.test.js","sourceRoot":"","sources":["../../src/__tests__/restore-version.test.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Tests for restore version event shape and conditional filter pill logic.\n *\n * Pure logic tests — no Angular TestBed. The deeper preview-building logic\n * lives in RestorePreviewPanelComponent and is tested via its own tests\n * (which exercise the reusable component independently).\n */\nimport { describe, it, expect } from 'vitest';\nimport { RestoreVersionEvent } from '../lib/ng-record-changes.component';\n\n// ═══════════════════════════════════════════\n// RestoreVersionEvent shape\n// ═══════════════════════════════════════════\ndescribe('RestoreVersionEvent', () => {\n it('should have the correct shape with FieldValues array', () => {\n const event: RestoreVersionEvent = {\n SourceChangeID: 'abc-123',\n ChangedAt: new Date('2025-01-15T10:00:00Z'),\n ChangedByUser: 'user@example.com',\n Reason: 'Reverting incorrect Q2 entries',\n FieldValues: [\n { FieldName: 'Name', Value: 'Old Name' },\n { FieldName: 'Status', Value: 'Active' },\n ],\n };\n\n expect(event.SourceChangeID).toBe('abc-123');\n expect(event.ChangedByUser).toBe('user@example.com');\n expect(event.Reason).toBe('Reverting incorrect Q2 entries');\n expect(event.FieldValues).toHaveLength(2);\n expect(event.FieldValues[0]).toEqual({ FieldName: 'Name', Value: 'Old Name' });\n });\n\n it('should support null Reason for restores without explanation', () => {\n const event: RestoreVersionEvent = {\n SourceChangeID: 'xyz',\n ChangedAt: new Date(),\n ChangedByUser: '',\n Reason: null,\n FieldValues: [],\n };\n\n expect(event.Reason).toBeNull();\n expect(event.FieldValues).toEqual([]);\n });\n});\n\n// ═══════════════════════════════════════════\n// Conditional pill computation\n// ═══════════════════════════════════════════\n//\n// Mirrors the logic in RecordChangesComponent.rebuildConditionalPills.\n// Pills only appear for change types/sources that are actually present\n// in the loaded data — the bar should never advertise a filter that\n// would yield zero results.\n\ninterface MockChange {\n Type: string;\n Source?: string;\n RestoredFromID?: string | null;\n}\n\nfunction computePillKeys(changes: MockChange[]): string[] {\n const counts: Record<string, number> = {};\n let restoreCount = 0;\n\n for (const c of changes) {\n counts[c.Type] = (counts[c.Type] ?? 0) + 1;\n if (c.Source === 'Restore' || c.RestoredFromID != null) restoreCount++;\n }\n\n const keys: string[] = [];\n if ((counts['Update'] ?? 0) > 0) keys.push('Update');\n if ((counts['Create'] ?? 0) > 0) keys.push('Create');\n if ((counts['Delete'] ?? 0) > 0) keys.push('Delete');\n if ((counts['Snapshot'] ?? 0) > 0) keys.push('Snapshot');\n if (restoreCount > 0) keys.push('Restore');\n return keys;\n}\n\ndescribe('rebuildConditionalPills (logic)', () => {\n it('emits no conditional pills for empty data', () => {\n expect(computePillKeys([])).toEqual([]);\n });\n\n it('emits only Update when only updates are loaded', () => {\n const changes: MockChange[] = [\n { Type: 'Update', Source: 'Internal' },\n { Type: 'Update', Source: 'Internal' },\n ];\n expect(computePillKeys(changes)).toEqual(['Update']);\n });\n\n it('emits Update and Create when both types are present', () => {\n const changes: MockChange[] = [\n { Type: 'Create', Source: 'Internal' },\n { Type: 'Update', Source: 'Internal' },\n ];\n expect(computePillKeys(changes)).toEqual(['Update', 'Create']);\n });\n\n it('emits Restore when any change has Source=Restore', () => {\n const changes: MockChange[] = [\n { Type: 'Update', Source: 'Internal' },\n { Type: 'Update', Source: 'Restore', RestoredFromID: 'abc' },\n ];\n expect(computePillKeys(changes)).toContain('Restore');\n });\n\n it('emits Restore when RestoredFromID is set even if Source is not Restore', () => {\n const changes: MockChange[] = [\n { Type: 'Update', Source: 'Internal', RestoredFromID: 'abc' },\n ];\n expect(computePillKeys(changes)).toContain('Restore');\n });\n\n it('does not emit Restore when no change has restore lineage', () => {\n const changes: MockChange[] = [\n { Type: 'Update', Source: 'Internal' },\n { Type: 'Create', Source: 'External' },\n ];\n expect(computePillKeys(changes)).not.toContain('Restore');\n });\n\n it('emits Snapshot when Type=Snapshot rows exist', () => {\n const changes: MockChange[] = [\n { Type: 'Snapshot', Source: 'Internal' },\n ];\n expect(computePillKeys(changes)).toEqual(['Snapshot']);\n });\n});\n\n// ═══════════════════════════════════════════\n// Overflow threshold logic\n// ═══════════════════════════════════════════\n\ndescribe('overflow threshold', () => {\n const OVERFLOW_THRESHOLD = 2;\n\n it('does not overflow at 1 conditional pill', () => {\n expect(1 > OVERFLOW_THRESHOLD).toBe(false);\n });\n\n it('does not overflow at 2 conditional pills', () => {\n expect(2 > OVERFLOW_THRESHOLD).toBe(false);\n });\n\n it('overflows at 3 conditional pills', () => {\n expect(3 > OVERFLOW_THRESHOLD).toBe(true);\n });\n});\n"]}
|
|
@@ -147,17 +147,26 @@ describe('RecordChangesComponent utility methods', () => {
|
|
|
147
147
|
expect(component.getUserDisplayName(null)).toBe('Unknown');
|
|
148
148
|
});
|
|
149
149
|
});
|
|
150
|
-
describe('
|
|
151
|
-
it('
|
|
150
|
+
describe('Conditional pill filters', () => {
|
|
151
|
+
it('starts in "All" mode (no conditional pills selected)', () => {
|
|
152
152
|
component.viewData = [];
|
|
153
|
-
component.
|
|
154
|
-
expect(component.selectedType).toBe('Update');
|
|
153
|
+
expect(component.IsAllSelected).toBe(true);
|
|
155
154
|
});
|
|
156
|
-
it('
|
|
155
|
+
it('TogglePill toggles a pill on then off', () => {
|
|
157
156
|
component.viewData = [];
|
|
158
|
-
component.
|
|
159
|
-
component.
|
|
160
|
-
expect(component.
|
|
157
|
+
component.ChipSelections = { Update: false };
|
|
158
|
+
component.TogglePill('Update');
|
|
159
|
+
expect(component.ChipSelections['Update']).toBe(true);
|
|
160
|
+
component.TogglePill('Update');
|
|
161
|
+
expect(component.ChipSelections['Update']).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
it('SelectAllPill clears every conditional selection', () => {
|
|
164
|
+
component.viewData = [];
|
|
165
|
+
component.ChipSelections = { Update: true, Create: true };
|
|
166
|
+
component.SelectAllPill();
|
|
167
|
+
expect(component.ChipSelections['Update']).toBe(false);
|
|
168
|
+
expect(component.ChipSelections['Create']).toBe(false);
|
|
169
|
+
expect(component.IsAllSelected).toBe(true);
|
|
161
170
|
});
|
|
162
171
|
});
|
|
163
172
|
describe('formatRelativeTime', () => {
|
|
@@ -213,13 +222,13 @@ describe('RecordChangesComponent utility methods', () => {
|
|
|
213
222
|
describe('ClearFilters', () => {
|
|
214
223
|
it('should reset all filter state', () => {
|
|
215
224
|
component.searchTerm = 'test';
|
|
216
|
-
component.
|
|
217
|
-
component.selectedSource = 'Internal';
|
|
225
|
+
component.ChipSelections = { Update: true, Restore: true };
|
|
218
226
|
component.viewData = [];
|
|
219
227
|
component.ClearFilters();
|
|
220
228
|
expect(component.searchTerm).toBe('');
|
|
221
|
-
expect(component.
|
|
222
|
-
expect(component.
|
|
229
|
+
expect(component.ChipSelections['Update']).toBe(false);
|
|
230
|
+
expect(component.ChipSelections['Restore']).toBe(false);
|
|
231
|
+
expect(component.IsAllSelected).toBe(true);
|
|
223
232
|
});
|
|
224
233
|
});
|
|
225
234
|
describe('getChangeSummary', () => {
|