diff --git a/e2e/playwright/navigation/src/tests/single-click.spec.ts b/e2e/playwright/navigation/src/tests/single-click.spec.ts index 6d48acc27c..9fc476a56c 100644 --- a/e2e/playwright/navigation/src/tests/single-click.spec.ts +++ b/e2e/playwright/navigation/src/tests/single-click.spec.ts @@ -56,7 +56,7 @@ test.describe('Single click on item name', () => { }); test.afterAll(async ({ nodesApiAction }) => { - await nodesApiAction.deleteNodes([deletedFolder1Id, deletedFile1Id], true); + await nodesApiAction.deleteNodes([deletedFolder1Id, deletedFile1Id, folder1Id, folderSearchId], true); }); test('[C284899] Hyperlink does not appear for items in the Trash', async ({ trashPage }) => { @@ -65,17 +65,11 @@ test.describe('Single click on item name', () => { expect(await trashPage.dataTable.getCellLinkByName(deletedFolder1).isVisible(), 'Link on name is present').toBe(false); }); - test.describe('on Personal Files', () => { - test.afterAll(async ({ nodesApiAction }) => { - await nodesApiAction.deleteNodes([folder1Id, folderSearchId], true); - }); - - test('[C280034] Navigate inside the folder when clicking the hyperlink on Personal Files', async ({ personalFiles }) => { - await personalFiles.navigate(); - await personalFiles.dataTable.getCellLinkByName(folder1).click(); - await personalFiles.dataTable.spinnerWaitForReload(); - expect(await personalFiles.breadcrumb.currentItem.innerText()).toBe(folder1); - }); + test('[C280034] Navigate inside the folder when clicking the hyperlink on Personal Files', async ({ personalFiles }) => { + await personalFiles.navigate(); + await personalFiles.dataTable.getCellLinkByName(folder1).click(); + await personalFiles.dataTable.spinnerWaitForReload(); + expect(await personalFiles.breadcrumb.currentItem.innerText()).toBe(folder1); }); test('[C284902] Navigate inside the library when clicking the hyperlink on File Libraries', async ({ myLibrariesPage }) => { diff --git a/e2e/playwright/viewer/exclude.tests.json b/e2e/playwright/viewer/exclude.tests.json index 0967ef424b..14b8e54beb 100644 --- a/e2e/playwright/viewer/exclude.tests.json +++ b/e2e/playwright/viewer/exclude.tests.json @@ -1 +1,3 @@ -{} +{ + "C286379": "https://alfresco.atlassian.net/browse/ACS-5601" +} diff --git a/e2e/playwright/viewer/src/tests/viewer-action.spec.ts b/e2e/playwright/viewer/src/tests/viewer-action.spec.ts index 5a778a24ab..7f45bb51b5 100644 --- a/e2e/playwright/viewer/src/tests/viewer-action.spec.ts +++ b/e2e/playwright/viewer/src/tests/viewer-action.spec.ts @@ -102,7 +102,6 @@ test.describe('viewer action file', () => { const download = await downloadPromise; expect(download.suggestedFilename(), 'File should found in download location').toBe(fileForEditOffline); expect(await personalFiles.viewer.isViewerOpened(), 'Viewer is closed after pressing Full screen').toBe(true); - await personalFiles.reload({ waitUntil: 'domcontentloaded' }); await personalFiles.acaHeader.clickViewerMoreActions(); expect(await personalFiles.matMenu.isMenuItemVisible('Cancel Editing'), 'Cancel Editing menu should be visible').toBe(true); }); @@ -112,7 +111,6 @@ test.describe('viewer action file', () => { await personalFiles.viewer.waitForViewerToOpen(); await personalFiles.acaHeader.clickViewerMoreActions(); await personalFiles.matMenu.clickMenuItem('Cancel Editing'); - await personalFiles.reload({ waitUntil: 'domcontentloaded' }); await personalFiles.acaHeader.clickViewerMoreActions(); expect(await personalFiles.matMenu.isMenuItemVisible('Edit Offline'), 'Edit offline menu should be visible').toBe(true); }); diff --git a/extension.schema.json b/extension.schema.json index 36f68a1481..f946bfb8fd 100644 --- a/extension.schema.json +++ b/extension.schema.json @@ -640,6 +640,34 @@ "$ref": "node_modules/@alfresco/adf-core/app.config.schema.json#/definitions/search-configuration" } ] + }, + "badges": { + "description": "List of badges to display in the name column", + "type": "array", + "items": { "$ref": "#/definitions/badge" }, + "minItems": 1 + }, + "badge": { + "type": "object", + "required": ["id", "icon", "tooltip"], + "properties": { + "id": { + "description": "Unique identifier. Must be in the format '[namespace]:[name]'.", + "type": "string" + }, + "icon": { + "description": "Badge icon to display.", + "type": "string" + }, + "tooltip": { + "description": "Badge tooltip to display on hover.", + "type": "string" + }, + "component": { + "description": "Custom component id to display", + "type": "string" + } + } } }, diff --git a/projects/aca-content/src/lib/components/dl-custom-components/name-column/name-column.component.html b/projects/aca-content/src/lib/components/dl-custom-components/name-column/name-column.component.html index cdb2cc3fe3..7981cb8599 100644 --- a/projects/aca-content/src/lib/components/dl-custom-components/name-column/name-column.component.html +++ b/projects/aca-content/src/lib/components/dl-custom-components/name-column/name-column.component.html @@ -1,25 +1,30 @@ -
- - {{ displayText$ | async }} - +
+
+ + {{ displayText$ | async }} + - - - + + + +
+
+ + + + + + +
diff --git a/projects/aca-content/src/lib/components/dl-custom-components/name-column/name-column.component.scss b/projects/aca-content/src/lib/components/dl-custom-components/name-column/name-column.component.scss index 2adc67c0fb..6bc28dfef6 100644 --- a/projects/aca-content/src/lib/components/dl-custom-components/name-column/name-column.component.scss +++ b/projects/aca-content/src/lib/components/dl-custom-components/name-column/name-column.component.scss @@ -1,6 +1,16 @@ .aca-custom-name-column { - display: block; + display: flex; align-items: center; + justify-content: space-between; + width: 100%; + + .aca-name-column-badges { + display: flex; + + .adf-datatable-cell-badge { + color: var(--theme-contrast-gray); + } + } .aca-name-column-container { aca-locked-by { @@ -17,3 +27,11 @@ } } } + +.adf-datatable-content-cell.adf-name-column.aca-custom-name-column { + position: unset; +} + +.adf-datatable-list .adf-datatable-link:hover .aca-name-column-badges { + color: var(--adf-theme-foreground-text-color); +} diff --git a/projects/aca-content/src/lib/components/dl-custom-components/name-column/name-column.component.spec.ts b/projects/aca-content/src/lib/components/dl-custom-components/name-column/name-column.component.spec.ts index 5daf9ddacf..fde887f474 100644 --- a/projects/aca-content/src/lib/components/dl-custom-components/name-column/name-column.component.spec.ts +++ b/projects/aca-content/src/lib/components/dl-custom-components/name-column/name-column.component.spec.ts @@ -28,19 +28,44 @@ import { StoreModule } from '@ngrx/store'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { HttpClientModule } from '@angular/common/http'; import { TranslateModule } from '@ngx-translate/core'; +import { AppExtensionService } from '@alfresco/aca-shared'; +import { of } from 'rxjs'; +import { ContentActionType } from '@alfresco/adf-extensions'; +import { By } from '@angular/platform-browser'; describe('CustomNameColumnComponent', () => { let fixture: ComponentFixture; let component: CustomNameColumnComponent; + let appExtensionService: AppExtensionService; beforeEach(() => { TestBed.configureTestingModule({ - imports: [HttpClientModule, TranslateModule.forRoot(), CustomNameColumnComponent, StoreModule.forRoot({ app: () => {} }, { initialState: {} })], + imports: [ + HttpClientModule, + TranslateModule.forRoot(), + CustomNameColumnComponent, + StoreModule.forRoot( + { app: (state) => state }, + { + initialState: { + app: { + selection: { + nodes: [], + libraries: [], + isEmpty: true, + count: 0 + } + } + } + } + ) + ], providers: [Actions] }); fixture = TestBed.createComponent(CustomNameColumnComponent); component = fixture.componentInstance; + appExtensionService = TestBed.inject(AppExtensionService); }); it('should not render lock element if file is not locked', () => { @@ -114,4 +139,55 @@ describe('CustomNameColumnComponent', () => { component.onLinkClick(event); expect(event.stopPropagation).toHaveBeenCalled(); }); + + describe('Name column badges', () => { + beforeEach(() => { + component.context = { + row: { + node: { + entry: { + isFile: true, + id: 'nodeId' + } + }, + getValue: (key: string) => key + } + }; + }); + + it('should get badges when component initializes', () => { + spyOn(appExtensionService, 'getBadges').and.returnValue( + of([{ id: 'test', type: ContentActionType.custom, icon: 'warning', tooltip: 'test tooltip' }]) + ); + component.ngOnInit(); + fixture.detectChanges(); + const badges = fixture.debugElement.queryAll(By.css('.adf-datatable-cell-badge')).map((badge) => badge.nativeElement); + expect(appExtensionService.getBadges).toHaveBeenCalled(); + expect(badges.length).toBe(1); + expect(badges[0].innerText).toBe('warning'); + expect(badges[0].attributes['title'].value).toBe('test tooltip'); + }); + + it('should call provided handler on click', () => { + spyOn(appExtensionService, 'runActionById'); + spyOn(appExtensionService, 'getBadges').and.returnValue( + of([{ id: 'test', type: ContentActionType.custom, icon: 'warning', tooltip: 'test tooltip', actions: { click: 'test' } }]) + ); + component.ngOnInit(); + fixture.detectChanges(); + const badges = fixture.debugElement.queryAll(By.css('.adf-datatable-cell-badge')).map((badge) => badge.nativeElement); + badges[0].click(); + expect(appExtensionService.runActionById).toHaveBeenCalledWith('test', component.context.row.node); + }); + + it('should render dynamic component when badge has one provided', () => { + spyOn(appExtensionService, 'getBadges').and.returnValue( + of([{ id: 'test', type: ContentActionType.custom, icon: 'warning', tooltip: 'test tooltip', component: 'test-id' }]) + ); + component.ngOnInit(); + fixture.detectChanges(); + const dynamicComponent = fixture.debugElement.query(By.css('adf-dynamic-component')).nativeElement; + expect(dynamicComponent).toBeDefined(); + }); + }); }); diff --git a/projects/aca-content/src/lib/components/dl-custom-components/name-column/name-column.component.ts b/projects/aca-content/src/lib/components/dl-custom-components/name-column/name-column.component.ts index e3a1e87a7d..dfeefcc7bb 100644 --- a/projects/aca-content/src/lib/components/dl-custom-components/name-column/name-column.component.ts +++ b/projects/aca-content/src/lib/components/dl-custom-components/name-column/name-column.component.ts @@ -28,13 +28,15 @@ import { Actions, ofType } from '@ngrx/effects'; import { Subject } from 'rxjs'; import { filter, takeUntil } from 'rxjs/operators'; import { NodeActionTypes } from '@alfresco/aca-shared/store'; -import { LockedByComponent, isLocked } from '@alfresco/aca-shared'; +import { LockedByComponent, isLocked, AppExtensionService, Badge } from '@alfresco/aca-shared'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; +import { IconModule } from '@alfresco/adf-core'; +import { ExtensionsModule } from '@alfresco/adf-extensions'; @Component({ standalone: true, - imports: [CommonModule, TranslateModule, LockedByComponent, ContentPipeModule], + imports: [CommonModule, TranslateModule, LockedByComponent, ContentPipeModule, IconModule, ExtensionsModule], selector: 'aca-custom-name-column', templateUrl: './name-column.component.html', styleUrls: ['./name-column.component.scss'], @@ -48,8 +50,15 @@ export class CustomNameColumnComponent extends NameColumnComponent implements On isFile: boolean; isFileWriteLocked: boolean; + badges: Badge[]; - constructor(element: ElementRef, private cd: ChangeDetectorRef, private actions$: Actions, private nodesService: NodesApiService) { + constructor( + element: ElementRef, + private cd: ChangeDetectorRef, + private actions$: Actions, + private nodesService: NodesApiService, + private appExtensionService: AppExtensionService + ) { super(element, nodesService); } @@ -86,6 +95,13 @@ export class CustomNameColumnComponent extends NameColumnComponent implements On this.isFileWriteLocked = isLocked(this.node); this.cd.detectChanges(); }); + + this.appExtensionService + .getBadges(this.node) + .pipe(takeUntil(this.onDestroy$$)) + .subscribe((badges) => { + this.badges = badges; + }); } onLinkClick(event: Event) { @@ -99,4 +115,8 @@ export class CustomNameColumnComponent extends NameColumnComponent implements On this.onDestroy$$.next(true); this.onDestroy$$.complete(); } + + onBadgeClick(badge: Badge) { + this.appExtensionService.runActionById(badge.actions?.click, this.node); + } } diff --git a/projects/aca-content/src/lib/ui/theme.scss b/projects/aca-content/src/lib/ui/theme.scss index 8afd57585c..08d30fa30c 100644 --- a/projects/aca-content/src/lib/ui/theme.scss +++ b/projects/aca-content/src/lib/ui/theme.scss @@ -4,14 +4,12 @@ @import 'variables/variables'; @include custom-theme($custom-theme); -$contrast-gray: #646569; - .mat-toolbar { color: var(--theme-text-color, rgba(0, 0, 0, 0.54)); } .adf-name-location-cell-location.adf-datatable-cell-value { - color: $contrast-gray; + color: var(--theme-contrast-gray); } .mat-tab-list { @@ -29,14 +27,14 @@ $contrast-gray: #646569; .mat-checkbox-label, mat-toolbar.mat-toolbar.mat-toolbar-multiple-row, mat-toolbar.mat-toolbar.mat-toolbar-single-row { - color: $contrast-gray; + color: var(--theme-contrast-gray); opacity: 1; } .adf-upload-dialog { &__header, &__content { - color: $contrast-gray; + color: var(--theme-contrast-gray); } } @@ -44,7 +42,7 @@ mat-toolbar.mat-toolbar.mat-toolbar-single-row { .adf-version-list-item { &-comment, &-date { - color: $contrast-gray; + color: var(--theme-contrast-gray); opacity: 1; } } @@ -52,19 +50,19 @@ mat-toolbar.mat-toolbar.mat-toolbar-single-row { .mat-chip.mat-standard-chip { background-color: #efefef; - color: $contrast-gray; + color: var(--theme-contrast-gray); } .adf-property-field { .adf-textitem-edit-icon.mat-icon { - color: $contrast-gray; + color: var(--theme-contrast-gray); } } .adf-property-field.adf-card-textitem-field:hover .adf-property-clear-value { - color: $contrast-gray; + color: var(--theme-contrast-gray); } .adf-empty-content__icon { - color: $contrast-gray; + color: var(--theme-contrast-gray); } diff --git a/projects/aca-content/src/lib/ui/variables/variables.scss b/projects/aca-content/src/lib/ui/variables/variables.scss index c68a38a8fc..8a11c08041 100644 --- a/projects/aca-content/src/lib/ui/variables/variables.scss +++ b/projects/aca-content/src/lib/ui/variables/variables.scss @@ -35,6 +35,7 @@ $action-button-text-color: rgba(33, 35, 40, 0.7); $page-layout-header-background-color: #fff; $search-chip-icon-color: #757575; $disabled-chip-background-color: #f5f5f5; +$contrast-gray: #646569; // CSS Variables $defaults: ( @@ -74,7 +75,8 @@ $defaults: ( --theme-action-button-text-color: $action-button-text-color, --theme-page-layout-header-background-color: $page-layout-header-background-color, --theme-search-chip-icon-color: $search-chip-icon-color, - --theme-disabled-chip-background-color: $disabled-chip-background-color + --theme-disabled-chip-background-color: $disabled-chip-background-color, + --theme-contrast-gray: $contrast-gray ); // propagates SCSS variables into the CSS variables scope diff --git a/projects/aca-playwright-shared/src/page-objects/components/dataTable/data-table.component.ts b/projects/aca-playwright-shared/src/page-objects/components/dataTable/data-table.component.ts index eb66e89699..e6e2292624 100644 --- a/projects/aca-playwright-shared/src/page-objects/components/dataTable/data-table.component.ts +++ b/projects/aca-playwright-shared/src/page-objects/components/dataTable/data-table.component.ts @@ -65,7 +65,7 @@ export class DataTableComponent extends BaseComponent { * * @returns reference to cell element which contains link. */ - getCellLinkByName = (name: string): Locator => this.getChild('.adf-datatable-cell-value[role="link"]', { hasText: name }); + getCellLinkByName = (name: string): Locator => this.getChild('.adf-cell-value span', { hasText: name }); /** * Method used in cases where we want to localize the element by [aria-label] diff --git a/projects/aca-shared/src/lib/models/types.ts b/projects/aca-shared/src/lib/models/types.ts index 76e6ab5d1d..ae8ac84bf3 100644 --- a/projects/aca-shared/src/lib/models/types.ts +++ b/projects/aca-shared/src/lib/models/types.ts @@ -22,6 +22,7 @@ * from Hyland Software. If not, see . */ +import { ContentActionRef } from '@alfresco/adf-extensions'; import { Route } from '@angular/router'; export interface SettingsGroupRef { @@ -45,3 +46,7 @@ export interface SettingsParameterRef { export interface ExtensionRoute extends Route { parentRoute?: string; } + +export interface Badge extends ContentActionRef { + tooltip: string; +} diff --git a/projects/aca-shared/src/lib/services/app.extension.service.spec.ts b/projects/aca-shared/src/lib/services/app.extension.service.spec.ts index e934155d32..966b8865ba 100644 --- a/projects/aca-shared/src/lib/services/app.extension.service.spec.ts +++ b/projects/aca-shared/src/lib/services/app.extension.service.spec.ts @@ -43,6 +43,7 @@ import { provideMockStore } from '@ngrx/store/testing'; import { hasQuickShareEnabled } from '@alfresco/aca-shared/rules'; import { MatIconRegistry } from '@angular/material/icon'; import { DomSanitizer } from '@angular/platform-browser'; +import { NodeEntry } from '@alfresco/js-api'; describe('AppExtensionService', () => { let service: AppExtensionService; @@ -1677,4 +1678,62 @@ describe('AppExtensionService', () => { done(); }); }); + + it('should get badges from config', (done) => { + extensions.setEvaluators({ + 'action.enabled': () => true + }); + + applyConfig({ + $id: 'test', + $name: 'test', + $version: '1.0.0', + $license: 'MIT', + $vendor: 'Good company', + $runtime: '1.5.0', + features: { + badges: [ + { + id: 'action1-id', + icon: 'warning', + tooltip: 'test tooltip', + type: 'custom', + rules: { + visible: 'action.enabled' + } + }, + { + id: 'action2-id', + icon: 'settings', + tooltip: 'test tooltip2', + type: 'custom', + rules: { + visible: 'action.enabled' + } + } + ] + } + }); + + const node: NodeEntry = { + entry: { + id: 'testId', + name: 'testName', + nodeType: 'test', + isFile: true, + isFolder: false, + modifiedAt: undefined, + createdAt: undefined, + modifiedByUser: undefined, + createdByUser: undefined + } + }; + + service.getBadges(node).subscribe((badges) => { + expect(badges.length).toBe(2); + expect(badges[0].id).toEqual('action1-id'); + expect(badges[1].id).toEqual('action2-id'); + done(); + }); + }); }); diff --git a/projects/aca-shared/src/lib/services/app.extension.service.ts b/projects/aca-shared/src/lib/services/app.extension.service.ts index bb32e031ab..393c0339e7 100644 --- a/projects/aca-shared/src/lib/services/app.extension.service.ts +++ b/projects/aca-shared/src/lib/services/app.extension.service.ts @@ -53,10 +53,9 @@ import { AppConfigService, AuthenticationService, LogService } from '@alfresco/a import { BehaviorSubject, Observable } from 'rxjs'; import { RepositoryInfo, NodeEntry } from '@alfresco/js-api'; import { ViewerRules } from '../models/viewer.rules'; -import { SettingsGroupRef } from '../models/types'; +import { Badge, SettingsGroupRef } from '../models/types'; import { NodePermissionService } from '../services/node-permission.service'; import { filter, map } from 'rxjs/operators'; -import { ModalConfiguration } from '../models/modal-configuration'; @Injectable({ providedIn: 'root' @@ -80,6 +79,7 @@ export class AppExtensionService implements RuleContext { private _createActions = new BehaviorSubject>([]); private _mainActions = new BehaviorSubject(null); private _sidebarActions = new BehaviorSubject>([]); + private _badges = new BehaviorSubject>([]); private _filesDocumentListPreset = new BehaviorSubject>([]); documentListPresets: { @@ -158,6 +158,7 @@ export class AppExtensionService implements RuleContext { this._openWithActions.next(this.loader.getContentActions(config, 'features.viewer.openWith')); this._createActions.next(this.loader.getElements(config, 'features.create')); this._mainActions.next(this.loader.getFeatures(config).mainAction); + this._badges.next(this.loader.getElements(config, 'features.badges')); this._filesDocumentListPreset.next(this.getDocumentListPreset(config, 'files')); this.navbar = this.loadNavBar(config); @@ -370,6 +371,10 @@ export class AppExtensionService implements RuleContext { ); } + getBadges(node: NodeEntry): Observable> { + return this._badges.pipe(map((badges) => badges.filter((badge) => this.evaluateRule(badge.rules.visible, node)))); + } + private buildMenu(actionRef: ContentActionRef): ContentActionRef { if (actionRef.type === ContentActionType.menu && actionRef.children && actionRef.children.length > 0) { const children = actionRef.children.filter((action) => this.filterVisible(action)).map((action) => this.buildMenu(action)); @@ -492,7 +497,7 @@ export class AppExtensionService implements RuleContext { return false; } - runActionById(id: string, additionalPayload?: ModalConfiguration) { + runActionById(id: string, additionalPayload?: any) { const action = this.extensions.getActionById(id); if (action) { const { type, payload } = action;