[ACS-8001] content enrichment menu component

This commit is contained in:
Mykyta Maliarchuk
2024-06-11 13:23:56 +02:00
committed by Mykyta Maliarchuk
parent 548592b329
commit a1cbcfca33
54 changed files with 1134 additions and 183 deletions

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 8.99987L17.75 6.24987L15 4.99987L17.75 3.74987L19 0.999873L20.25 3.74987L23 4.99987L20.25 6.24987L19 8.99987ZM19 22.9999L17.75 20.2499L15 18.9999L17.75 17.7499L19 14.9999L20.25 17.7499L23 18.9999L20.25 20.2499L19 22.9999ZM8.99998 19.9999L6.49998 14.4999L0.999984 11.9999L6.49998 9.49987L8.99998 3.99987L11.5 9.49987L17 11.9999L11.5 14.4999L8.99998 19.9999ZM8.99998 15.1499L9.99998 12.9999L12.15 11.9999L9.99998 10.9999L8.99998 8.84987L7.99998 10.9999L5.84998 11.9999L7.99998 12.9999L8.99998 15.1499Z" fill="#212328" fill-opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 653 B

View File

@@ -43,6 +43,7 @@ import { CardViewKeyValuePairsItemComponent } from './components/card-view-keyva
import { CardViewSelectItemComponent } from './components/card-view-selectitem/card-view-selectitem.component';
import { CardViewArrayItemComponent } from './components/card-view-arrayitem/card-view-arrayitem.component';
import { SelectFilterInputComponent } from './components/card-view-selectitem/select-filter-input/select-filter-input.component';
import { ContentEnrichmentMenuComponent } from '../prediction/components/content-enrichment-menu/content-enrichment-menu.component';
@NgModule({
imports: [
@@ -63,7 +64,8 @@ import { SelectFilterInputComponent } from './components/card-view-selectitem/se
MatCardModule,
MatDatetimepickerModule,
MatNativeDatetimeModule,
MatSlideToggleModule
MatSlideToggleModule,
ContentEnrichmentMenuComponent
],
declarations: [
CardViewComponent,

View File

@@ -40,6 +40,18 @@ export abstract class BaseCardView<T extends CardViewItem> implements OnDestroy
this.property.value = itemModel.value;
}
});
this.cardViewUpdateService.predictionStatusChanged$.pipe(takeUntil(this.destroy$)).subscribe((items) => {
items.map(item => {
if (this.property.key.split('.')[1] === item.key) {
this.property.prediction = null;
if (item.previousValue !== null && item.previousValue !== undefined) {
this.property.value = item.previousValue;
}
}
});
});
}
get isEditable(): boolean {

View File

@@ -1,13 +1,16 @@
<ng-container *ngIf="!property.isEmpty() || isEditable">
<div class="adf-property-value">
<mat-checkbox [attr.data-automation-id]="'card-boolean-' + property.key"
[attr.title]="'CORE.METADATA.ACTIONS.TOGGLE' | translate"
[checked]="property.displayValue"
[disabled]="!isEditable"
color="primary"
(change)="changed($event)">
<div [attr.data-automation-id]="'card-boolean-label-' + property.key"
class="adf-property-label">{{ property.label | translate }}</div>
</mat-checkbox>
<div class="adf-property-value-wrapper">
<adf-content-enrichment-menu matPrefix *ngIf="hasContentEnrichment" [prediction]="property.prediction"></adf-content-enrichment-menu>
<div class="adf-property-value">
<mat-checkbox [attr.data-automation-id]="'card-boolean-' + property.key"
[attr.title]="'CORE.METADATA.ACTIONS.TOGGLE' | translate"
[checked]="property.displayValue"
[disabled]="!isEditable"
color="primary"
(change)="changed($event)">
<div [attr.data-automation-id]="'card-boolean-label-' + property.key"
class="adf-property-label">{{ property.label | translate }}</div>
</mat-checkbox>
</div>
</div>
</ng-container>

View File

@@ -25,10 +25,12 @@ import { BaseCardView } from '../base-card-view';
templateUrl: './card-view-boolitem.component.html',
styles: [
`
.adf-property-value {
padding: 15px 0;
}
`
.adf-property-value {
padding: 15px 0;
display: flex;
align-content: center;
}
`
]
})
@@ -36,6 +38,9 @@ export class CardViewBoolItemComponent extends BaseCardView<CardViewBoolItemMode
@Input()
editable: boolean;
@Input()
hasContentEnrichment = false;
changed(change: MatCheckboxChange) {
this.cardViewUpdateService.update({ ...this.property } as CardViewBoolItemModel, change.checked );
this.property.value = change.checked;

View File

@@ -8,108 +8,62 @@
>
{{ property.label | translate }}
</label>
<div class="adf-property-value" [ngClass]="{ 'adf-property-value-editable': editable, 'adf-property-readonly-value': isReadonlyProperty }">
<span *ngIf="!isEditable && !property.multivalued" [attr.data-automation-id]="'card-' + property.type + '-value-' + property.key">
<span
*ngIf="showProperty"
[attr.data-automation-id]="'card-dateitem-' + property.key"
(dblclick)="copyToClipboard(property.displayValue)"
matTooltipShowDelay="1000"
[title]="'CORE.METADATA.ACTIONS.COPY_TO_CLIPBOARD' | translate"
>{{ property.displayValue }}</span
>
</span>
<div *ngIf="isEditable && !property.multivalued" class="adf-dateitem-editable">
<div class="adf-dateitem-editable-controls">
<div class="adf-property-value-wrapper">
<adf-content-enrichment-menu matPrefix *ngIf="hasContentEnrichment" [prediction]="property.prediction"></adf-content-enrichment-menu>
<div class="adf-property-value" [ngClass]="{ 'adf-property-value-editable': editable, 'adf-property-readonly-value': isReadonlyProperty }">
<span *ngIf="!isEditable && !property.multivalued" [attr.data-automation-id]="'card-' + property.type + '-value-' + property.key">
<span
class="adf-datepicker-toggle"
[attr.data-automation-id]="'datepicker-label-toggle-' + property.key"
(click)="showDatePicker()"
tabindex="0"
role="button"
(keyup.enter)="showDatePicker()"
*ngIf="showProperty"
[attr.data-automation-id]="'card-dateitem-' + property.key"
(dblclick)="copyToClipboard(property.displayValue)"
matTooltipShowDelay="1000"
[title]="'CORE.METADATA.ACTIONS.COPY_TO_CLIPBOARD' | translate"
>{{ property.displayValue }}</span
>
<span *ngIf="showProperty; else elseEmptyValueBlock" [attr.data-automation-id]="'card-' + property.type + '-value-' + property.key">
{{ property.displayValue }}</span
</span>
<div *ngIf="isEditable && !property.multivalued" class="adf-dateitem-editable">
<div class="adf-dateitem-editable-controls">
<span
class="adf-datepicker-toggle"
[attr.data-automation-id]="'datepicker-label-toggle-' + property.key"
(click)="showDatePicker()"
tabindex="0"
role="button"
(keyup.enter)="showDatePicker()"
>
</span>
<span *ngIf="showProperty; else elseEmptyValueBlock" [attr.data-automation-id]="'card-' + property.type + '-value-' + property.key">
{{ property.displayValue }}</span
>
</span>
<mat-icon
*ngIf="showClearAction"
class="adf-date-reset-icon"
(click)="onDateClear()"
[attr.title]="'CORE.METADATA.ACTIONS.CLEAR' | translate"
[attr.data-automation-id]="'datepicker-date-clear-' + property.key"
>
clear
</mat-icon>
<mat-icon
*ngIf="showClearAction"
class="adf-date-reset-icon"
(click)="onDateClear()"
[attr.title]="'CORE.METADATA.ACTIONS.CLEAR' | translate"
[attr.data-automation-id]="'datepicker-date-clear-' + property.key"
>
clear
</mat-icon>
<mat-datetimepicker-toggle
[attr.tabindex]="-1"
[attr.title]="'CORE.METADATA.ACTIONS.EDIT' | translate"
[attr.data-automation-id]="'datepickertoggle-' + property.key"
[for]="datetimePicker"
>
</mat-datetimepicker-toggle>
</div>
<mat-datetimepicker-toggle
[attr.tabindex]="-1"
[attr.title]="'CORE.METADATA.ACTIONS.EDIT' | translate"
[attr.data-automation-id]="'datepickertoggle-' + property.key"
[for]="datetimePicker"
>
</mat-datetimepicker-toggle>
</div>
<input
class="adf-invisible-date-input"
[attr.tabIndex]="-1"
[matDatetimepicker]="datetimePicker"
[value]="valueDate"
(dateChange)="onDateChanged($event)"
[attr.id]="'card-view-dateitem-' + property.key"
/>
<mat-datetimepicker
#datetimePicker
[type]="$any(property).type"
[timeInterval]="5"
[attr.data-automation-id]="'datepicker-' + property.key"
[startAt]="valueDate"
>
</mat-datetimepicker>
</div>
<ng-template #elseEmptyValueBlock>
{{ property.default | translate }}
</ng-template>
<div *ngIf="property.multivalued"
class="adf-property-field adf-dateitem-chip-list-container adf-dateitem-editable">
<mat-chip-listbox #chipList class="adf-textitem-chip-list">
<mat-chip-option
*ngFor="let propertyValue of property.displayValue; let idx = index"
[removable]="isEditable"
(removed)="removeValueFromList(idx)">
{{ propertyValue }}
<mat-icon *ngIf="isEditable" matChipRemove>cancel</mat-icon>
</mat-chip-option>
</mat-chip-listbox>
<div
*ngIf="isEditable"
class="adf-property-field adf-dateitem-editable-controls"
(click)="showDatePicker()"
tabindex="0"
role="button"
(keyup.enter)="showDatePicker()"
>
<input
class="adf-invisible-date-input"
[attr.tabIndex]="-1"
[matDatetimepicker]="datetimePicker"
(dateChange)="addDateToList($event)"
[value]="valueDate"
(dateChange)="onDateChanged($event)"
[attr.id]="'card-view-dateitem-' + property.key"
/>
<mat-datetimepicker-toggle
[attr.tabindex]="-1"
matSuffix
[attr.title]="'CORE.METADATA.ACTIONS.EDIT' | translate"
[attr.data-automation-id]="'datepickertoggle-' + property.key"
[for]="datetimePicker"
>
</mat-datetimepicker-toggle>
<mat-datetimepicker
#datetimePicker
[type]="$any(property).type"
@@ -119,5 +73,54 @@
>
</mat-datetimepicker>
</div>
<ng-template #elseEmptyValueBlock>
{{ property.default | translate }}
</ng-template>
<div *ngIf="property.multivalued" class="adf-property-field adf-dateitem-chip-list-container adf-dateitem-editable">
<mat-chip-list #chipList class="adf-textitem-chip-list">
<mat-chip
*ngFor="let propertyValue of property.displayValue; let idx = index"
[removable]="isEditable"
(removed)="removeValueFromList(idx)"
>
{{ propertyValue }}
<mat-icon *ngIf="isEditable" matChipRemove>cancel</mat-icon>
</mat-chip>
</mat-chip-list>
<div
*ngIf="isEditable"
class="adf-property-field adf-dateitem-editable-controls"
(click)="showDatePicker()"
tabindex="0"
role="button"
(keyup.enter)="showDatePicker()"
>
<input
class="adf-invisible-date-input"
[attr.tabIndex]="-1"
[matDatetimepicker]="datetimePicker"
(dateChange)="addDateToList($event)"
[attr.id]="'card-view-dateitem-' + property.key"
/>
<mat-datetimepicker-toggle
[attr.tabindex]="-1"
matSuffix
[attr.title]="'CORE.METADATA.ACTIONS.EDIT' | translate"
[attr.data-automation-id]="'datepickertoggle-' + property.key"
[for]="datetimePicker"
>
</mat-datetimepicker-toggle>
<mat-datetimepicker
#datetimePicker
[type]="$any(property).type"
[timeInterval]="5"
[attr.data-automation-id]="'datepicker-' + property.key"
[startAt]="valueDate"
>
</mat-datetimepicker>
</div>
</div>
</div>
</div>

View File

@@ -49,6 +49,9 @@ export class CardViewDateItemComponent extends BaseCardView<CardViewDateItemMode
@Input()
displayClearAction = true;
@Input()
hasContentEnrichment = false;
@ViewChild('datetimePicker')
public datepicker: MatDatetimepickerComponent<any>;

View File

@@ -32,27 +32,30 @@ export class CardViewItemDispatcherComponent implements OnChanges {
editable: boolean;
@Input()
displayEmpty: boolean = true;
displayEmpty = true;
@Input()
displayNoneOption: boolean = true;
displayNoneOption = true;
@Input()
displayClearAction: boolean = true;
displayClearAction = true;
@Input()
copyToClipboardAction: boolean = true;
copyToClipboardAction = true;
@Input()
useChipsForMultiValueProperty: boolean = true;
useChipsForMultiValueProperty = true;
@Input()
multiValueSeparator: string = DEFAULT_SEPARATOR;
multiValueSeparator = DEFAULT_SEPARATOR;
@Input()
displayLabelForChips: boolean = false;
displayLabelForChips = false;
private loaded: boolean = false;
@Input()
hasContentEnrichment = false;
private loaded = false;
private componentReference: any = null;
public ngOnInit;
@@ -104,6 +107,7 @@ export class CardViewItemDispatcherComponent implements OnChanges {
this.componentReference.instance.useChipsForMultiValueProperty = this.useChipsForMultiValueProperty;
this.componentReference.instance.multiValueSeparator = this.multiValueSeparator;
this.componentReference.instance.displayLabelForChips = this.displayLabelForChips;
this.componentReference.instance.hasContentEnrichment = this.hasContentEnrichment;
}
private proxy(methodName, ...args) {

View File

@@ -12,9 +12,12 @@
*ngIf="!isEditable"
class="adf-property-value adf-property-read-only"
[attr.data-automation-id]="'select-readonly-value-' + property.key"
data-automation-class="read-only-value">{{ (property.displayValue | async) | translate }}
data-automation-class="read-only-value">
<adf-content-enrichment-menu *ngIf="hasContentEnrichment" [prediction]="property.prediction" class="adf-content-enrichment-trigger"></adf-content-enrichment-menu>
{{ (property.displayValue | async) | translate }}
</div>
<div *ngIf="isEditable">
<div *ngIf="isEditable" class="adf-property-editable">
<adf-content-enrichment-menu matPrefix *ngIf="hasContentEnrichment" [prediction]="property.prediction"></adf-content-enrichment-menu>
<mat-form-field class="adf-property-value" [ngClass]="{'adf-property-value-editable': isEditable}">
<mat-select
[(value)]="value"

View File

@@ -47,6 +47,18 @@
padding: 6px 0;
border-bottom: 1px solid var(--adf-metadata-property-panel-border-color);
color: var(--adf-metadata-property-panel-title-color);
adf-content-enrichment-menu.adf-content-enrichment-trigger {
display: inline-block;
height: 17px;
margin-left: -3px;
margin-right: -7px;
}
}
.adf-property-editable {
display: flex;
align-items: center;
}
.mdc-line-ripple {

View File

@@ -38,10 +38,13 @@ export class CardViewSelectItemComponent extends BaseCardView<CardViewSelectItem
@Input() options$: Observable<CardViewSelectItemOption<string | number>[]>;
@Input()
displayNoneOption: boolean = true;
displayNoneOption = true;
@Input()
displayEmpty: boolean = true;
displayEmpty = true;
@Input()
hasContentEnrichment = false;
value: string | number;
filter$ = new BehaviorSubject<string>('');
@@ -50,6 +53,7 @@ export class CardViewSelectItemComponent extends BaseCardView<CardViewSelectItem
ngOnChanges(): void {
this.value = this.property.value;
this.property.setValue(this.property.value);
}
ngOnInit() {

View File

@@ -37,6 +37,7 @@
[attr.data-automation-id]="'card-textitem-value-' + property.key"
(keydown)="undoText($event)"
/>
<adf-content-enrichment-menu matPrefix *ngIf="hasContentEnrichment" [prediction]="property.prediction"></adf-content-enrichment-menu>
<textarea
matInput
*ngIf="property.multiline"
@@ -119,6 +120,7 @@
>
{{ property.label | translate }}
</mat-label>
<!-- <adf-content-enrichment-menu matPrefix *ngIf="hasContentEnrichment && !isEditable" [prediction]="property.prediction"></adf-content-enrichment-menu>-->
<input
matInput
[type]="property.inputType"

View File

@@ -57,6 +57,9 @@ export class CardViewTextItemComponent extends BaseCardView<CardViewTextItemMode
@Input()
displayLabelForChips = false;
@Input()
hasContentEnrichment = false;
editedValue: string | string[];
errors: CardViewItemValidator[];
templateType: string;

View File

@@ -10,7 +10,8 @@
[copyToClipboardAction]="copyToClipboardAction"
[useChipsForMultiValueProperty]="useChipsForMultiValueProperty"
[multiValueSeparator]="multiValueSeparator"
[displayLabelForChips]="displayLabelForChips">
[displayLabelForChips]="displayLabelForChips"
[hasContentEnrichment]="!!property.prediction">
</adf-card-view-item-dispatcher>
</div>
</div>

View File

@@ -24,6 +24,16 @@
margin-bottom: 12px;
}
.adf-property-value-wrapper {
display: flex;
align-items: center;
border-bottom: 1px solid var(--adf-metadata-property-panel-border-color);
adf-content-enrichment-menu button.adf-ai-button {
margin-left: -10px;
}
}
.adf-property {
.adf-property-field {
width: 100%;
@@ -48,6 +58,7 @@
#{$mat-form--text-field-infix} {
border-top-width: 0;
position: initial;
}
#{$mat-form-field-flex} {

View File

@@ -19,13 +19,16 @@ import { Subject } from 'rxjs';
import { CardViewBaseItemModel } from '../models/card-view-baseitem.model';
import { UpdateNotification } from './update-notification.interface';
import { ClickNotification } from './click-notification.interface';
import { PredictionStatusUpdate } from '../../prediction';
export interface BaseCardViewUpdate {
itemUpdated$: Subject<UpdateNotification>;
itemClicked$: Subject<ClickNotification>;
updateItem$: Subject<CardViewBaseItemModel>;
predictionStatusChanged$: Subject<PredictionStatusUpdate[]>;
update(property: CardViewBaseItemModel, newValue: any);
clicked(property: CardViewBaseItemModel);
updateElement(notification: CardViewBaseItemModel);
onPredictionStatusChanged(notification: PredictionStatusUpdate[]);
}

View File

@@ -16,6 +16,7 @@
*/
import { CardViewItemValidator } from './card-view-item-validator.interface';
import { Prediction } from '@alfresco/js-api';
export interface CardViewItemProperties {
label: string;
@@ -36,4 +37,5 @@ export interface CardViewItemProperties {
parameters?: { [key: string]: any };
}>;
multivalued?: boolean;
prediction?: Prediction;
}

View File

@@ -15,6 +15,8 @@
* limitations under the License.
*/
import { Prediction } from '@alfresco/js-api';
export interface CardViewItem {
label: string;
value: any;
@@ -24,4 +26,5 @@ export interface CardViewItem {
displayValue: any;
editable?: boolean;
icon?: string;
prediction?: Prediction;
}

View File

@@ -17,6 +17,7 @@
import { CardViewItemProperties, CardViewItemValidator } from '../interfaces/card-view.interfaces';
import validatorsMap from '../validators/validators.map';
import { Prediction } from '@alfresco/js-api';
export abstract class CardViewBaseItemModel<T = any> {
label: string;
@@ -31,6 +32,7 @@ export abstract class CardViewBaseItemModel<T = any> {
data?: any;
type?: string;
multivalued?: boolean;
prediction?: Prediction;
constructor(props: CardViewItemProperties) {
this.label = props.label || '';
@@ -44,6 +46,7 @@ export abstract class CardViewBaseItemModel<T = any> {
this.validators = props.validators || [];
this.data = props.data || null;
this.multivalued = !!props.multivalued;
this.prediction = props.prediction || null;
if (props?.constraints?.length ?? 0) {
for (const constraint of props.constraints) {

View File

@@ -57,5 +57,14 @@ describe('CardViewSelectItemModel', () => {
expect(itemModel.displayNoneOption).toBe(false);
}));
it('should update the value when new value is set', (done) => {
const itemModel = new CardViewSelectItemModel(properties);
itemModel.setValue('three');
itemModel.displayValue.subscribe((value) => {
expect(value).toBe(mockData[2].label);
done();
});
});
});
});

View File

@@ -19,8 +19,8 @@ import { CardViewItem } from '../interfaces/card-view-item.interface';
import { DynamicComponentModel } from '../../common/services/dynamic-component-mapper.service';
import { CardViewBaseItemModel } from './card-view-baseitem.model';
import { CardViewSelectItemProperties, CardViewSelectItemOption } from '../interfaces/card-view.interfaces';
import { Observable, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { BehaviorSubject, Observable } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
export class CardViewSelectItemModel<T> extends CardViewBaseItemModel implements CardViewItem, DynamicComponentModel {
type = 'select';
@@ -29,6 +29,8 @@ export class CardViewSelectItemModel<T> extends CardViewBaseItemModel implements
valueFetch$: Observable<string> = null;
private valueSubject = new BehaviorSubject<any>(this.value);
constructor(cardViewSelectItemProperties: CardViewSelectItemProperties<T>) {
super(cardViewSelectItemProperties);
@@ -36,11 +38,13 @@ export class CardViewSelectItemModel<T> extends CardViewBaseItemModel implements
this.options$ = cardViewSelectItemProperties.options$;
this.valueFetch$ = this.options$.pipe(
switchMap((options) => {
const option = options.find((o) => o.key === this.value?.toString());
return of(option ? option.label : '');
})
this.valueFetch$ = this.valueSubject.pipe(
switchMap((value) => this.options$.pipe(
map((options) => {
const option = options.find((o) => o.key === value?.toString());
return option ? option.label : '';
})
))
);
}
@@ -50,5 +54,6 @@ export class CardViewSelectItemModel<T> extends CardViewBaseItemModel implements
setValue(value: any) {
this.value = value;
this.valueSubject.next(value);
}
}

View File

@@ -18,6 +18,7 @@
import { fakeAsync, TestBed } from '@angular/core/testing';
import { CardViewBaseItemModel } from '../models/card-view-baseitem.model';
import { CardViewUpdateService, transformKeyToObject } from './card-view-update.service';
import { PredictionStatusUpdate } from '@alfresco/adf-core';
describe('CardViewUpdateService', () => {
@@ -82,5 +83,14 @@ describe('CardViewUpdateService', () => {
);
cardViewUpdateService.clicked(property);
}));
it('should emit predictionStatusChanged$ when onPredictionStatusChanged is called', (done) => {
const mockPredictionStatusUpdate: PredictionStatusUpdate[] = [{ key: 'test', previousValue: 'value' }];
cardViewUpdateService.predictionStatusChanged$.subscribe((value) => {
expect(value).toEqual(mockPredictionStatusUpdate);
done();
});
cardViewUpdateService.onPredictionStatusChanged(mockPredictionStatusUpdate);
});
});
});

View File

@@ -21,6 +21,7 @@ import { BaseCardViewUpdate } from '../interfaces/base-card-view-update.interfac
import { ClickNotification } from '../interfaces/click-notification.interface';
import { UpdateNotification } from '../interfaces/update-notification.interface';
import { CardViewBaseItemModel } from '../models/card-view-baseitem.model';
import { PredictionStatusUpdate } from '../../prediction/interfaces/prediction-status-update.interface';
export const transformKeyToObject = (key: string, value): any => {
const objectLevels: string[] = key.split('.').reverse();
@@ -36,6 +37,7 @@ export class CardViewUpdateService implements BaseCardViewUpdate {
itemUpdated$ = new Subject<UpdateNotification>();
itemClicked$ = new Subject<ClickNotification>();
updateItem$ = new Subject<CardViewBaseItemModel>();
predictionStatusChanged$ = new Subject<PredictionStatusUpdate[]>();
update(property: CardViewBaseItemModel, newValue: any) {
this.itemUpdated$.next({
@@ -59,4 +61,7 @@ export class CardViewUpdateService implements BaseCardViewUpdate {
this.updateItem$.next(notification);
}
onPredictionStatusChanged(notification: PredictionStatusUpdate[]) {
this.predictionStatusChanged$.next(notification);
}
}

View File

@@ -158,7 +158,8 @@ export class ThumbnailService {
'save-as': './assets/images/save-as.svg',
save: './assets/images/save.svg',
task: './assets/images/task.svg',
'multipart/related': './assets/images/ft_ic_website.svg'
'multipart/related': './assets/images/ft_ic_website.svg',
'ai-sparkles': './assets/images/ai-sparkles.svg'
};
constructor(matIconRegistry: MatIconRegistry, sanitizer: DomSanitizer) {

View File

@@ -248,6 +248,19 @@
"DATEPICKER": "Use the arrow keys to navigate between dates. Up and down move to the next or previous week but on the same day. Left and right move to the next or previous day. Press Enter or Return to select a date.",
"COPY_TO_CLIPBOARD_MESSAGE": "Value copied to clipboard",
"EDIT_ASPECTS": "Edit Aspect"
},
"CONTENT_ENRICHMENT": {
"ACCURACY_LEVEL": "Accuracy Level",
"ACCURACY_DESCRIPTION": "This score represents the quality of the suggestion.",
"LAST_VALUE": "Last user value",
"CURRENT_VALUE": "Current Value",
"ACTIONS": {
"REVERT": "Revert",
"CONFIRM": "Confirm"
},
"ARIA-LABEL": {
"OPEN-MENU": "Open content enrichment menu"
}
}
},
"SEARCH": {

View File

@@ -0,0 +1,49 @@
<button mat-icon-button [attr.aria-label]="'CORE.METADATA.CONTENT_ENRICHMENT.ACTIONS.CONFIRM' | translate" #menuTrigger="matMenuTrigger" class="adf-ai-button" [matMenuTriggerFor]="menu" (menuOpened)="onMenuOpen()">
<adf-icon [value]="'adf:ai-sparkles'"></adf-icon>
</button>
<mat-menu #menu="matMenu" class="adf-ai-mat-menu" (closed)="onClosed()">
<div class="adf-content-enrichment-menu" #menuContainer (click)="$event.stopPropagation()" (keydown.tab)="$event.stopPropagation()" (keydown.shift.tab)="$event.stopPropagation()" tabindex="-1">
<div class="adf-content-enrichment-menu__accuracy">
<div class="adf-content-enrichment-menu__accuracy__title">
<span>{{ 'CORE.METADATA.CONTENT_ENRICHMENT.ACCURACY_LEVEL' | translate }}</span>
<p>{{ 'CORE.METADATA.CONTENT_ENRICHMENT.ACCURACY_DESCRIPTION' | translate }}</p>
</div>
<div class="adf-content-enrichment-menu__accuracy__level">
<span>{{ confidencePercentage }}</span>
<mat-progress-spinner
class="adf-accuracy-level-spinner"
diameter="49"
title="{{ 'CORE.METADATA.CONTENT_ENRICHMENT.ACCURACY_LEVEL' | translate }}"
color="primary"
mode="determinate"
[value]="confidencePercentage">
</mat-progress-spinner>
</div>
</div>
<div class="adf-content-enrichment-menu__content">
<div class="adf-content-enrichment-menu__content__last">
<p class="adf-content-enrichment-menu__content__label">{{ 'CORE.METADATA.CONTENT_ENRICHMENT.LAST_VALUE' | translate }}</p>
<div class="adf-content-enrichment-menu__content__last__value">{{ previousValue || 'None' }}</div>
</div>
<mat-icon>arrow_downward</mat-icon>
<div class="adf-content-enrichment-menu__content__current">
<p class="adf-content-enrichment-menu__content__label">{{ 'CORE.METADATA.CONTENT_ENRICHMENT.CURRENT_VALUE' | translate }}
<span>({{ predictionDateTime | adfLocalizedDate: 'MM/dd/yyyy' }})</span>
</p>
<div class="adf-content-enrichment-menu__content__current__value">
<adf-icon [value]="'adf:ai-sparkles'"></adf-icon>
<span>{{ predictionValue }}</span>
</div>
</div>
</div>
<div class="adf-content-enrichment-menu__footer">
<button mat-flat-button (click)="onRevert()">
{{ 'CORE.METADATA.CONTENT_ENRICHMENT.ACTIONS.REVERT' | translate }}
</button>
<button mat-button color="primary" (click)="onConfirm()" class="adf-confirm-button">
{{ 'CORE.METADATA.CONTENT_ENRICHMENT.ACTIONS.CONFIRM' | translate }}
</button>
</div>
</div>
</mat-menu>

View File

@@ -0,0 +1,136 @@
adf-content-enrichment-menu {
svg {
height: 17px;
width: 17px;
}
}
.adf-ai-mat-menu {
border: 1px solid transparent;
border-radius: 6px;
background-image: linear-gradient(var(--adf-theme-background-dialog-color), var(--adf-theme-background-dialog-color)),
var(--adf-ai-border-gradient);
background-origin: border-box;
background-clip: content-box, border-box;
}
.adf-content-enrichment-menu {
padding: 5px 10px;
&__accuracy {
display: flex;
align-items: center;
margin-bottom: 18px;
justify-content: space-between;
&__title {
span {
font-size: 14px;
font-weight: 600;
line-height: 16px;
letter-spacing: 0.5px;
text-align: left;
}
p {
font-size: 12px;
font-weight: 400;
line-height: 16px;
letter-spacing: 0.5px;
text-align: left;
margin: 0;
}
}
&__level {
display: flex;
align-items: center;
span {
width: 0;
position: relative;
left: 14px;
font-size: 17.96px;
font-weight: 700;
line-height: 25.66px;
letter-spacing: 0.1924px;
}
.adf-accuracy-level-spinner circle {
stroke: var(--theme-accent-color);
}
}
}
&__content {
padding: 18px 10px;
border: 1px solid var(--adf-metadata-property-panel-border-color);
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
&__label {
font-size: 14px;
font-weight: 600;
line-height: 20px;
letter-spacing: 0.25px;
text-align: left;
margin: 0 0 4px;
span {
font-size: 10px;
font-weight: 500;
line-height: 16px;
letter-spacing: 0.2px;
text-align: left;
}
}
&__last {
width: 100%;
&__value {
background: var(--adf-theme-foreground-text-color-007);
border-radius: 6px;
padding: 7px;
min-height: 19px;
}
}
&__current {
width: 100%;
&__value {
padding: 7px;
border: 2px dashed var(--adf-metadata-property-panel-border-color);
border-radius: 6px;
display: flex;
align-items: center;
mat-icon {
width: 17px;
height: 17px;
margin-right: 10px;
}
}
}
mat-icon {
color: var(--adf-theme-foreground-text-color-054);
}
}
&__footer {
margin-top: 18px;
text-align: end;
.adf-confirm-button{
margin-left: 8px;
color: var(--adf-theme-foreground-base-color);
background-color: var(--theme-grey-text-background-color);
}
}
}

View File

@@ -0,0 +1,172 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { By } from '@angular/platform-browser';
import { of } from 'rxjs';
import { ContentEnrichmentMenuComponent } from './content-enrichment-menu.component';
import { PredictionService } from '../../services';
import { CoreTestingModule } from '../../../testing/core.testing.module';
import { LocalizedDatePipe } from '../../../pipes/localized-date.pipe';
import { Prediction, ReviewStatus } from '@alfresco/js-api';
describe('ContentEnrichmentMenuComponent', () => {
let component: ContentEnrichmentMenuComponent;
let fixture: ComponentFixture<ContentEnrichmentMenuComponent>;
let predictionService: PredictionService;
let localizedDatePipe: LocalizedDatePipe;
const predictionMock: Prediction = {
confidenceLevel: 0.9,
predictionDateTime: new Date(2022, 1, 1),
modelId: 'test-model-id',
property: 'test:test',
id: 'test-prediction-id',
previousValue: 'previous value',
predictionValue: 'new value',
updateType: 'AUTOCORRECT',
reviewStatus: ReviewStatus.UNREVIEWED
};
const openMenu = () => {
const button = fixture.debugElement.query(By.css('.adf-ai-button')).nativeElement;
button.click();
fixture.detectChanges();
};
beforeEach(async () => {
const predictionServiceMock = {
reviewPrediction: jasmine.createSpy('reviewPrediction').and.returnValue(of(null)),
predictionStatusUpdated$: {next: jasmine.createSpy('next')}
};
await TestBed.configureTestingModule({
imports: [ContentEnrichmentMenuComponent, CoreTestingModule, MatProgressSpinnerModule],
providers: [{provide: PredictionService, useValue: predictionServiceMock}]
})
.compileComponents();
fixture = TestBed.createComponent(ContentEnrichmentMenuComponent);
component = fixture.componentInstance;
component.prediction = {...predictionMock};
predictionService = TestBed.inject(PredictionService);
localizedDatePipe = TestBed.inject(LocalizedDatePipe);
fixture.detectChanges();
});
it('should initialize properties when prediction is provided', () => {
expect(component.confidencePercentage).toBe(90);
expect(component.previousValue).toBe('previous value');
expect(component.predictionValue).toBe('new value');
expect(component.predictionDateTime).toEqual(predictionMock.predictionDateTime);
});
it('should initialize properties with default values when prediction is not provided', () => {
component.prediction = null;
component.ngOnInit();
expect(component.confidencePercentage).toBe(0);
expect(component.previousValue).toBe('');
expect(component.predictionValue).toBe('');
expect(component.predictionDateTime).toBeNull();
});
it('should correctly set boolean values', () => {
component.prediction.previousValue = false;
component.prediction.predictionValue = true;
component.ngOnInit();
expect(component.previousValue).toBeFalse();
expect(component.predictionValue).toBeTrue();
});
it('should call reviewPrediction with REJECTED on revert', () => {
component.onRevert();
expect(predictionService.reviewPrediction).toHaveBeenCalledWith(component.prediction.id, ReviewStatus.REJECTED);
});
it('should call reviewPrediction with CONFIRMED on confirm', () => {
component.onConfirm();
expect(predictionService.reviewPrediction).toHaveBeenCalledWith(component.prediction.id, ReviewStatus.CONFIRMED);
});
it('should emit predictionStatusUpdated$ on confirm', () => {
component.onConfirm();
expect(predictionService.predictionStatusUpdated$.next).toHaveBeenCalledWith({key: component.prediction.property});
});
it('should emit predictionStatusUpdated$ on revert', () => {
component.onRevert();
expect(predictionService.predictionStatusUpdated$.next).toHaveBeenCalledWith({
key: component.prediction.property,
previousValue: component.previousValue
});
});
it('should close the menu on revert', () => {
const menuTriggerSpy = spyOn(component.menuTrigger, 'closeMenu');
component.onRevert();
expect(menuTriggerSpy).toHaveBeenCalled();
});
it('should close the menu on confirm', () => {
const menuTriggerSpy = spyOn(component.menuTrigger, 'closeMenu');
component.onConfirm();
expect(menuTriggerSpy).toHaveBeenCalled();
});
it('should open the menu on button click', () => {
spyOn(component, 'onMenuOpen');
openMenu();
expect(component.onMenuOpen).toHaveBeenCalled();
});
it('should correctly format predictionDateTime using adfLocalizedDate', () => {
const formattedDate = localizedDatePipe.transform(component.predictionDateTime, 'MM/dd/yyyy');
openMenu();
const dateElement = fixture.debugElement.query(By.css('.adf-content-enrichment-menu__content__label span')).nativeElement;
expect(dateElement.textContent).toContain(formattedDate);
});
it('should render progress spinner with correct value', () => {
openMenu();
const spinnerElement = fixture.debugElement.query(By.css('.adf-accuracy-level-spinner'));
expect(spinnerElement.nativeElement).toBeDefined();
expect(spinnerElement.componentInstance.value).toEqual(component.confidencePercentage);
});
it('should render previous human entered value', () => {
openMenu();
const previousValueElement = fixture.debugElement.query(By.css('.adf-content-enrichment-menu__content__last__value')).nativeElement;
expect(previousValueElement.textContent).toContain(component.previousValue);
});
it('should display "None" as previous value when previousValue is not defined', () => {
component.prediction.previousValue = null;
component.ngOnInit();
openMenu();
const previousValueElement = fixture.debugElement.query(By.css('.adf-content-enrichment-menu__content__last__value')).nativeElement;
expect(previousValueElement.textContent).toContain('None');
});
it('should render predicted value', () => {
openMenu();
const predictionValueElement = fixture.debugElement.query(By.css('.adf-content-enrichment-menu__content__current__value span')).nativeElement;
expect(predictionValueElement.textContent).toContain(component.predictionValue);
});
});

View File

@@ -0,0 +1,93 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, ElementRef, Input, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { ConfigurableFocusTrap, ConfigurableFocusTrapFactory } from '@angular/cdk/a11y';
import { TranslateModule } from '@ngx-translate/core';
import { Prediction, ReviewStatus } from '@alfresco/js-api';
import { IconComponent } from '../../../icon';
import { LocalizedDatePipe } from '../../../pipes';
import { PredictionService } from '../../services';
@Component({
standalone: true,
selector: 'adf-content-enrichment-menu',
templateUrl: './content-enrichment-menu.component.html',
styleUrls: ['./content-enrichment-menu.component.scss'],
encapsulation: ViewEncapsulation.None,
imports: [MatProgressSpinnerModule, MatTooltipModule, MatButtonModule, IconComponent, MatMenuModule, MatIconModule, TranslateModule, LocalizedDatePipe]
})
export class ContentEnrichmentMenuComponent implements OnInit {
@Input()
prediction: Prediction;
@ViewChild('menuContainer', { static: false })
menuContainer: ElementRef;
@ViewChild('menuTrigger', { static: false })
menuTrigger: MatMenuTrigger;
focusTrap: ConfigurableFocusTrap;
confidencePercentage: number;
previousValue: any;
predictionValue: any;
predictionDateTime: Date;
constructor(private predictionService: PredictionService, private focusTrapFactory: ConfigurableFocusTrapFactory) {}
ngOnInit() {
this.confidencePercentage = this.prediction?.confidenceLevel * 100 || 0;
this.previousValue = this.isDefined(this.prediction?.previousValue) ? this.prediction.previousValue : '';
this.predictionValue = this.isDefined(this.prediction?.predictionValue) ? this.prediction.predictionValue : '';
this.predictionDateTime = this.prediction?.predictionDateTime || null;
}
onMenuOpen() {
if (this.menuContainer && !this.focusTrap) {
this.focusTrap = this.focusTrapFactory.create(this.menuContainer.nativeElement);
}
}
onRevert() {
this.predictionService.reviewPrediction(this.prediction.id, ReviewStatus.REJECTED).subscribe(() => {
this.predictionService.predictionStatusUpdated$.next({key: this.prediction.property, previousValue: this.previousValue});
this.menuTrigger.closeMenu();
});
}
onConfirm() {
this.predictionService.reviewPrediction(this.prediction.id, ReviewStatus.CONFIRMED).subscribe(() => {
this.predictionService.predictionStatusUpdated$.next({key: this.prediction.property});
this.menuTrigger.closeMenu();
});
}
onClosed() {
this.focusTrap.destroy();
this.focusTrap = null;
}
private isDefined(value: string): boolean {
return value !== undefined && value !== null;
}
}

View File

@@ -0,0 +1,18 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './public-api';

View File

@@ -0,0 +1,21 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface PredictionStatusUpdate {
key: string;
previousValue?: any;
}

View File

@@ -0,0 +1,19 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './services';
export * from './interfaces/prediction-status-update.interface';

View File

@@ -0,0 +1,18 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './prediction.service';

View File

@@ -0,0 +1,54 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PredictionService } from './prediction.service';
import { CoreTestingModule } from '../../testing/core.testing.module';
import { TestBed } from '@angular/core/testing';
import { Prediction, PredictionEntry, PredictionPaging, PredictionPagingList, ReviewStatus } from '@alfresco/js-api';
describe('PredictionService', () => {
let service: PredictionService;
const mockPredictionPaging = (): PredictionPaging => {
const prediction = new Prediction();
prediction.id = 'test id';
const predictionEntry = new PredictionEntry({ entry: prediction });
const predictionPagingList = new PredictionPagingList({ entries: [predictionEntry] });
return new PredictionPaging({ list: predictionPagingList });
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CoreTestingModule]
});
service = TestBed.inject(PredictionService);
});
it('should call getPredictions on PredictionsApi with nodeId', () => {
spyOn(service.predictionsApi, 'getPredictions').and.returnValue(Promise.resolve(mockPredictionPaging()));
service.getPredictions('test id');
expect(service.predictionsApi.getPredictions).toHaveBeenCalledWith('test id');
});
it('should call reviewPrediction on PredictionsApi with predictionId and reviewStatus', () => {
spyOn(service.predictionsApi, 'reviewPrediction').and.returnValue(Promise.resolve());
service.reviewPrediction('test id', ReviewStatus.CONFIRMED);
expect(service.predictionsApi.reviewPrediction).toHaveBeenCalledWith('test id', ReviewStatus.CONFIRMED);
});
});

View File

@@ -0,0 +1,60 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { AlfrescoApiService } from '../../services/alfresco-api.service';
import { PredictionsApi, PredictionPaging, ReviewStatus } from '@alfresco/js-api';
import { from, Observable, Subject } from 'rxjs';
import { PredictionStatusUpdate } from '../interfaces/prediction-status-update.interface';
@Injectable({ providedIn: 'root' })
export class PredictionService {
private _predictionsApi: PredictionsApi;
/**
* Gets emitted when prediction status updated
*/
predictionStatusUpdated$ = new Subject<PredictionStatusUpdate>();
get predictionsApi(): PredictionsApi {
this._predictionsApi = this._predictionsApi ?? new PredictionsApi(this.apiService.getInstance());
return this._predictionsApi;
}
constructor(private apiService: AlfrescoApiService) {}
/**
* Get predictions for a given node
*
* @param nodeId The identifier of node.
* @returns Observable<PredictionPaging>
*/
getPredictions(nodeId: string): Observable<PredictionPaging> {
return from(this.predictionsApi.getPredictions(nodeId));
}
/**
* Review a prediction
*
* @param predictionId The identifier of prediction.
* @param reviewStatus Review status to apply.
* @returns Observable<void>
*/
reviewPrediction(predictionId: string, reviewStatus: ReviewStatus): Observable<void> {
return from(this.predictionsApi.reviewPrediction(predictionId, reviewStatus));
}
}

View File

@@ -89,7 +89,8 @@
--adf-danger-button-background: $adf-danger-button-background,
--adf-secondary-button-background: $adf-secondary-button-background,
--adf-display-external-property-widget-preview-selection-color: mat.get-color-from-palette($foreground, secondary-text)
--adf-display-external-property-widget-preview-selection-color: mat.get-color-from-palette($foreground, secondary-text),
--adf-ai-border-gradient: $adf-ref-ai-border-gradient
);
// propagates SCSS variables into the CSS variables scope

View File

@@ -27,3 +27,4 @@ $adf-ref-header-icon-color: inherit;
$adf-ref-header-icon-border-radius: 50%;
$adf-danger-button-background: #ba1b1b;
$adf-secondary-button-background: #2121210d;
$adf-ref-ai-border-gradient: linear-gradient(180deg, #94B7FF 0%, #9E00FF 100%);

View File

@@ -49,6 +49,7 @@ export * from './lib/blank-page/index';
export * from './lib/search-text/index';
export * from './lib/snackbar-content/index';
export * from './lib/translation/index';
export * from './lib/prediction/index';
export * from './lib/common/utils/index';
export * from './lib/interface/index';