diff --git a/lib/core/directives/directive.module.ts b/lib/core/directives/directive.module.ts index c21c80554c..1f9ce7aee4 100644 --- a/lib/core/directives/directive.module.ts +++ b/lib/core/directives/directive.module.ts @@ -32,6 +32,8 @@ import { TooltipCardDirective } from './tooltip-card/tooltip-card.directive'; import { OverlayModule } from '@angular/cdk/overlay'; import { TooltipCardComponent } from './tooltip-card/tooltip-card.component'; import { InfiniteSelectScrollDirective } from './infinite-select-scroll.directive'; +import { LibraryFavoriteDirective } from './library-favorite.directive'; +import { LibraryMembershipDirective } from './library-membership.directive'; @NgModule({ imports: [ @@ -51,7 +53,9 @@ import { InfiniteSelectScrollDirective } from './infinite-select-scroll.directiv VersionCompatibilityDirective, TooltipCardDirective, TooltipCardComponent, - InfiniteSelectScrollDirective + InfiniteSelectScrollDirective, + LibraryFavoriteDirective, + LibraryMembershipDirective ], exports: [ HighlightDirective, @@ -64,7 +68,9 @@ import { InfiniteSelectScrollDirective } from './infinite-select-scroll.directiv UploadDirective, VersionCompatibilityDirective, TooltipCardDirective, - InfiniteSelectScrollDirective + InfiniteSelectScrollDirective, + LibraryFavoriteDirective, + LibraryMembershipDirective ] }) export class DirectiveModule {} diff --git a/lib/core/directives/library-favorite.directive.spec.ts b/lib/core/directives/library-favorite.directive.spec.ts new file mode 100644 index 0000000000..e0d34253ca --- /dev/null +++ b/lib/core/directives/library-favorite.directive.spec.ts @@ -0,0 +1,121 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * 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, ViewChild } from '@angular/core'; +import { LibraryEntity, LibraryFavoriteDirective } from './library-favorite.directive'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { AlfrescoApiService } from '../services/alfresco-api.service'; +import { CoreModule } from '../core.module'; +import { AlfrescoApiServiceMock } from '../mock'; + +@Component({ + selector: 'app-test-component', + template: ` ` +}) +class TestComponent { + @ViewChild('favoriteLibrary', { static: true }) + directive: LibraryFavoriteDirective; + + selection: LibraryEntity = null; +} + +describe('LibraryFavoriteDirective', () => { + let fixture: ComponentFixture; + let api: AlfrescoApiService; + let component: TestComponent; + let selection: LibraryEntity; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), CoreModule.forRoot()], + providers: [ + { provide: AlfrescoApiService, useClass: AlfrescoApiServiceMock } + ], + declarations: [TestComponent, LibraryFavoriteDirective] + }); + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + api = TestBed.inject(AlfrescoApiService); + selection = { entry: { guid: 'guid', id: 'id', title: 'Site', visibility: 'PUBLIC' }, isLibrary: true, isFavorite: false }; + component.selection = selection; + }); + + it('should not check for favorite if no selection exists', () => { + spyOn(api.peopleApi, 'getFavoriteSite'); + fixture.detectChanges(); + + expect(api.peopleApi.getFavoriteSite).not.toHaveBeenCalled(); + }); + + it('should mark selection as favorite', async () => { + spyOn(api.peopleApi, 'getFavoriteSite').and.returnValue(Promise.resolve(null)); + + delete selection.isFavorite; + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(api.peopleApi.getFavoriteSite).toHaveBeenCalled(); + expect(component.directive.isFavorite()).toBe(true); + }); + + it('should mark selection not favorite', async () => { + spyOn(api.peopleApi, 'getFavoriteSite').and.returnValue(Promise.reject()); + + delete selection.isFavorite; + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(api.peopleApi.getFavoriteSite).toHaveBeenCalled(); + expect(component.directive.isFavorite()).toBe(false); + }); + + it('should call addFavorite() on click event when selection is not a favorite', async () => { + spyOn(api.peopleApi, 'getFavoriteSite').and.returnValue(Promise.reject()); + spyOn(api.peopleApi, 'addFavorite').and.returnValue(Promise.resolve(null)); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.directive.isFavorite()).toBeFalsy(); + + fixture.nativeElement.querySelector('button').dispatchEvent(new MouseEvent('click')); + fixture.detectChanges(); + expect(api.peopleApi.addFavorite).toHaveBeenCalled(); + }); + + it('should call removeFavoriteSite() on click event when selection is favorite', async () => { + spyOn(api.peopleApi, 'getFavoriteSite').and.returnValue(Promise.resolve(null)); + spyOn(api.favoritesApi, 'removeFavoriteSite').and.returnValue(Promise.resolve()); + + selection.isFavorite = true; + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.directive.isFavorite()).toBeTruthy(); + + fixture.nativeElement.querySelector('button').dispatchEvent(new MouseEvent('click')); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(api.favoritesApi.removeFavoriteSite).toHaveBeenCalled(); + }); +}); diff --git a/lib/core/directives/library-favorite.directive.ts b/lib/core/directives/library-favorite.directive.ts new file mode 100644 index 0000000000..f511e3014a --- /dev/null +++ b/lib/core/directives/library-favorite.directive.ts @@ -0,0 +1,107 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * 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 { Directive, HostListener, Input, OnChanges, Output, EventEmitter } from '@angular/core'; +import { SiteBody, FavoriteBody, FavoriteEntry, Site } from '@alfresco/js-api'; +import { AlfrescoApiService } from '../services/alfresco-api.service'; + +export interface LibraryEntity { + entry: Site; + isLibrary: boolean; + isFavorite: boolean; +} + +@Directive({ + selector: '[adf-favorite-library]', + exportAs: 'favoriteLibrary' +}) +export class LibraryFavoriteDirective implements OnChanges { + @Input('adf-favorite-library') + library: LibraryEntity = null; + + @Output() toggle = new EventEmitter(); + // tslint:disable-next-line: no-output-native + @Output() error = new EventEmitter(); + + private targetLibrary = null; + + @HostListener('click') + onClick() { + const guid = this.targetLibrary.entry.guid; + + if (this.targetLibrary.isFavorite) { + this.removeFavorite(guid); + } else { + this.addFavorite({ + target: { + site: { + guid + } + } + }); + } + } + + constructor(private alfrescoApiService: AlfrescoApiService) {} + + ngOnChanges(changes) { + if (!changes.library.currentValue) { + this.targetLibrary = null; + return; + } + + this.targetLibrary = changes.library.currentValue; + this.markFavoriteLibrary(changes.library.currentValue); + } + + isFavorite(): boolean { + return this.targetLibrary && this.targetLibrary.isFavorite; + } + + private async markFavoriteLibrary(library: LibraryEntity) { + if (this.targetLibrary.isFavorite === undefined) { + try { + await this.alfrescoApiService.peopleApi.getFavoriteSite('-me-', library.entry.id); + this.targetLibrary.isFavorite = true; + } catch { + this.targetLibrary.isFavorite = false; + } + } else { + this.targetLibrary = library; + } + } + + private addFavorite(favoriteBody: FavoriteBody) { + this.alfrescoApiService.peopleApi + .addFavorite('-me-', favoriteBody) + .then((libraryEntry: FavoriteEntry) => { + this.targetLibrary.isFavorite = true; + this.toggle.emit(libraryEntry); + }) + .catch((error) => this.error.emit(error)); + } + + private removeFavorite(favoriteId: string) { + this.alfrescoApiService.favoritesApi + .removeFavoriteSite('-me-', favoriteId) + .then((libraryBody: SiteBody) => { + this.targetLibrary.isFavorite = false; + this.toggle.emit(libraryBody); + }) + .catch((error) => this.error.emit(error)); + } +} diff --git a/lib/core/directives/library-membership.directive.spec.ts b/lib/core/directives/library-membership.directive.spec.ts new file mode 100644 index 0000000000..0a47982556 --- /dev/null +++ b/lib/core/directives/library-membership.directive.spec.ts @@ -0,0 +1,220 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * 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 { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { AlfrescoApiService, SitesService } from '../services'; +import { LibraryMembershipDirective } from './library-membership.directive'; +import { NO_ERRORS_SCHEMA, SimpleChange } from '@angular/core'; +import { of, throwError, Subject } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { DirectiveModule } from './directive.module'; +import { CoreModule } from '../core.module'; +import { AlfrescoApiServiceMock } from '../mock/alfresco-api.service.mock'; + +describe('LibraryMembershipDirective', () => { + let alfrescoApiService: AlfrescoApiService; + let directive: LibraryMembershipDirective; + let peopleApi: any; + let sitesService: SitesService; + let addMembershipSpy: jasmine.Spy; + let getMembershipSpy: jasmine.Spy; + let deleteMembershipSpy: jasmine.Spy; + let mockSupportedVersion = false; + + let testSiteEntry: any; + let requestedMembershipResponse: any; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), DirectiveModule, CoreModule.forRoot()], + providers: [ + { provide: AlfrescoApiService, useClass: AlfrescoApiServiceMock } + ], + schemas: [NO_ERRORS_SCHEMA] + }); + + testSiteEntry = { + id: 'id-1', + guid: 'site-1', + title: 'aa t m', + visibility: 'MODERATED' + }; + + requestedMembershipResponse = { + id: testSiteEntry.id, + createdAt: '2018-11-14', + site: testSiteEntry + }; + + alfrescoApiService = TestBed.inject(AlfrescoApiService); + sitesService = TestBed.inject(SitesService); + peopleApi = alfrescoApiService.getInstance().core.peopleApi; + directive = new LibraryMembershipDirective(alfrescoApiService, sitesService, { + ecmProductInfo$: new Subject(), + isVersionSupported: () => mockSupportedVersion + } as any); + }); + + describe('markMembershipRequest', () => { + beforeEach(() => { + getMembershipSpy = spyOn(peopleApi, 'getSiteMembershipRequest').and.returnValue(Promise.resolve({ entry: requestedMembershipResponse })); + }); + + it('should not check membership requests if no entry is selected', fakeAsync(() => { + const selection = {}; + const change = new SimpleChange(null, selection, true); + directive.ngOnChanges({ selection: change }); + tick(); + expect(getMembershipSpy).not.toHaveBeenCalled(); + })); + + it('should check if a membership request exists for the selected library', fakeAsync(() => { + const selection = { entry: {} }; + const change = new SimpleChange(null, selection, true); + directive.ngOnChanges({ selection: change }); + tick(); + expect(getMembershipSpy.calls.count()).toBe(1); + })); + + it('should remember when a membership request exists for selected library', fakeAsync(() => { + const selection = { entry: testSiteEntry }; + const change = new SimpleChange(null, selection, true); + directive.ngOnChanges({ selection: change }); + tick(); + expect(directive.targetSite.joinRequested).toBe(true); + })); + + it('should remember when a membership request is not found for selected library', fakeAsync(() => { + getMembershipSpy.and.returnValue(Promise.reject()); + + const selection = { entry: testSiteEntry }; + const change = new SimpleChange(null, selection, true); + directive.ngOnChanges({ selection: change }); + tick(); + expect(directive.targetSite.joinRequested).toBe(false); + })); + }); + + describe('toggleMembershipRequest', () => { + beforeEach(() => { + mockSupportedVersion = false; + getMembershipSpy = spyOn(peopleApi, 'getSiteMembershipRequest').and.returnValue(Promise.resolve({ entry: requestedMembershipResponse })); + addMembershipSpy = spyOn(peopleApi, 'addSiteMembershipRequest').and.returnValue(Promise.resolve({ entry: requestedMembershipResponse })); + deleteMembershipSpy = spyOn(peopleApi, 'removeSiteMembershipRequest').and.returnValue(Promise.resolve({})); + }); + + it('should do nothing if there is no selected library ', fakeAsync(() => { + const selection = {}; + const change = new SimpleChange(null, selection, true); + directive.ngOnChanges({ selection: change }); + tick(); + directive.toggleMembershipRequest(); + tick(); + expect(addMembershipSpy).not.toHaveBeenCalled(); + expect(deleteMembershipSpy).not.toHaveBeenCalled(); + })); + + it('should delete membership request if there is one', fakeAsync(() => { + const selection = { entry: testSiteEntry }; + const change = new SimpleChange(null, selection, true); + directive.ngOnChanges({ selection: change }); + tick(); + directive.toggleMembershipRequest(); + tick(); + expect(deleteMembershipSpy).toHaveBeenCalled(); + })); + + it('should call API to make a membership request if there is none', fakeAsync(() => { + const selection = { entry: { id: 'no-membership-requested' } }; + const change = new SimpleChange(null, selection, true); + directive.ngOnChanges({ selection: change }); + tick(); + directive.toggleMembershipRequest(); + tick(); + expect(addMembershipSpy).toHaveBeenCalledWith('-me-', { id: 'no-membership-requested' }); + expect(deleteMembershipSpy).not.toHaveBeenCalled(); + })); + + it("should add 'workspace' to send appropriate email", fakeAsync(() => { + mockSupportedVersion = true; + const selection = { entry: { id: 'no-membership-requested' } }; + const change = new SimpleChange(null, selection, true); + directive.ngOnChanges({ selection: change }); + tick(); + directive.toggleMembershipRequest(); + tick(); + expect(addMembershipSpy).toHaveBeenCalledWith('-me-', { id: 'no-membership-requested', client: 'workspace' }); + expect(deleteMembershipSpy).not.toHaveBeenCalled(); + })); + + it('should call API to add user to library if admin user', fakeAsync(() => { + const createSiteMembershipSpy = spyOn(sitesService, 'createSiteMembership').and.returnValue(of({} as any)); + const selection = { entry: { id: 'no-membership-requested' } }; + const selectionChange = new SimpleChange(null, selection, true); + directive.isAdmin = true; + directive.ngOnChanges({ selection: selectionChange }); + tick(); + directive.toggleMembershipRequest(); + tick(); + expect(createSiteMembershipSpy).toHaveBeenCalled(); + expect(addMembershipSpy).not.toHaveBeenCalled(); + })); + + it('should emit error when the request to join a library fails', fakeAsync(() => { + spyOn(directive.error, 'emit'); + addMembershipSpy.and.returnValue(throwError('err')); + + const selection = { entry: { id: 'no-membership-requested' } }; + const change = new SimpleChange(null, selection, true); + directive.ngOnChanges({ selection: change }); + tick(); + directive.toggleMembershipRequest(); + tick(); + expect(directive.error.emit).toHaveBeenCalled(); + })); + + it('should emit specific error message on invalid email address server error', fakeAsync(() => { + const emitErrorSpy = spyOn(directive.error, 'emit'); + const selection = { entry: { id: 'no-membership-requested' } }; + const change = new SimpleChange(null, selection, true); + directive.ngOnChanges({ selection: change }); + tick(); + + const testData = [ + { + fixture: 'Failed to resolve sender mail address', + expected: 'APP.MESSAGES.ERRORS.INVALID_SENDER_EMAIL' + }, + { + fixture: 'All recipients for the mail action were invalid', + expected: 'APP.MESSAGES.ERRORS.INVALID_RECEIVER_EMAIL' + } + ]; + + testData.forEach((data) => { + addMembershipSpy.and.returnValue(throwError({ message: data.fixture })); + emitErrorSpy.calls.reset(); + directive.toggleMembershipRequest(); + tick(); + expect(emitErrorSpy).toHaveBeenCalledWith({ + error: { message: data.fixture }, + i18nKey: data.expected + }); + }); + })); + }); +}); diff --git a/lib/core/directives/library-membership.directive.ts b/lib/core/directives/library-membership.directive.ts new file mode 100644 index 0000000000..59c4ba88e2 --- /dev/null +++ b/lib/core/directives/library-membership.directive.ts @@ -0,0 +1,229 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * 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 { Directive, EventEmitter, HostListener, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { SiteEntry, SiteMembershipRequestBody, SiteMemberEntry, SiteMembershipRequestEntry } from '@alfresco/js-api'; +import { BehaviorSubject, from, Observable } from 'rxjs'; +import { AlfrescoApiService } from '../services/alfresco-api.service'; +import { SitesService } from '../services/sites.service'; +import { VersionCompatibilityService } from '../services/version-compatibility.service'; + +export interface LibraryMembershipToggleEvent { + updatedEntry?: any; + shouldReload: boolean; + i18nKey: string; +} + +export interface LibraryMembershipErrorEvent { + error: any; + i18nKey: string; +} + +@Directive({ + selector: '[adf-library-membership]', + exportAs: 'libraryMembership' +}) +export class LibraryMembershipDirective implements OnChanges { + targetSite: any = null; + + isJoinRequested: BehaviorSubject = new BehaviorSubject(false); + + /** Site for which to toggle the membership request. */ + @Input('adf-library-membership') + selection: SiteEntry = null; + + /** Site for which to toggle the membership request. */ + @Input() + isAdmin = false; + + @Output() + toggle = new EventEmitter(); + + // tslint:disable-next-line: no-output-native + @Output() + error = new EventEmitter(); + + @HostListener('click') + onClick() { + this.toggleMembershipRequest(); + } + + constructor( + private alfrescoApiService: AlfrescoApiService, + private sitesService: SitesService, + private versionCompatibilityService: VersionCompatibilityService + ) {} + + ngOnChanges(changes: SimpleChanges) { + if (!changes.selection.currentValue || !changes.selection.currentValue.entry) { + this.targetSite = null; + + return; + } + this.targetSite = changes.selection.currentValue.entry; + this.markMembershipRequest(); + } + + toggleMembershipRequest() { + if (!this.targetSite) { + return; + } + + if (this.targetSite.joinRequested) { + this.cancelJoinRequest().subscribe( + () => { + this.targetSite.joinRequested = false; + this.isJoinRequested.next(false); + const info = { + updatedEntry: this.targetSite, + shouldReload: false, + i18nKey: 'APP.MESSAGES.INFO.JOIN_CANCELED' + }; + this.toggle.emit(info); + }, + (error) => { + const errWithMessage = { + error, + i18nKey: 'APP.MESSAGES.ERRORS.JOIN_CANCEL_FAILED' + }; + this.error.emit(errWithMessage); + } + ); + } + + if (!this.targetSite.joinRequested && !this.isAdmin) { + this.joinLibraryRequest().subscribe( + (createdMembership) => { + this.targetSite.joinRequested = true; + this.isJoinRequested.next(true); + + if (createdMembership.entry && createdMembership.entry.site && createdMembership.entry.site.role) { + const info = { + shouldReload: true, + i18nKey: 'APP.MESSAGES.INFO.JOINED' + }; + this.toggle.emit(info); + } else { + const info = { + updatedEntry: this.targetSite, + shouldReload: false, + i18nKey: 'APP.MESSAGES.INFO.JOIN_REQUESTED' + }; + this.toggle.emit(info); + } + }, + (error) => { + const errWithMessage = { + error, + i18nKey: 'APP.MESSAGES.ERRORS.JOIN_REQUEST_FAILED' + }; + + const senderEmailCheck = 'Failed to resolve sender mail address'; + const receiverEmailCheck = 'All recipients for the mail action were invalid'; + + if (error.message) { + if (error.message.includes(senderEmailCheck)) { + errWithMessage.i18nKey = 'APP.MESSAGES.ERRORS.INVALID_SENDER_EMAIL'; + } else if (error.message.includes(receiverEmailCheck)) { + errWithMessage.i18nKey = 'APP.MESSAGES.ERRORS.INVALID_RECEIVER_EMAIL'; + } + } + + this.error.emit(errWithMessage); + } + ); + } + + if (this.isAdmin) { + this.joinLibrary().subscribe( + (createdMembership: SiteMemberEntry) => { + if (createdMembership.entry && createdMembership.entry.role) { + const info = { + shouldReload: true, + i18nKey: 'APP.MESSAGES.INFO.JOINED' + }; + this.toggle.emit(info); + } + }, + (error) => { + const errWithMessage = { + error, + i18nKey: 'APP.MESSAGES.ERRORS.JOIN_REQUEST_FAILED' + }; + + const senderEmailCheck = 'Failed to resolve sender mail address'; + const receiverEmailCheck = 'All recipients for the mail action were invalid'; + + if (error.message) { + if (error.message.includes(senderEmailCheck)) { + errWithMessage.i18nKey = 'APP.MESSAGES.ERRORS.INVALID_SENDER_EMAIL'; + } else if (error.message.includes(receiverEmailCheck)) { + errWithMessage.i18nKey = 'APP.MESSAGES.ERRORS.INVALID_RECEIVER_EMAIL'; + } + } + + this.error.emit(errWithMessage); + } + ); + } + } + + markMembershipRequest() { + if (!this.targetSite) { + return; + } + + this.getMembershipRequest().subscribe( + (data) => { + if (data.entry.id === this.targetSite.id) { + this.targetSite.joinRequested = true; + this.isJoinRequested.next(true); + } + }, + () => { + this.targetSite.joinRequested = false; + this.isJoinRequested.next(false); + } + ); + } + + private joinLibraryRequest(): Observable { + const memberBody = { + id: this.targetSite.id + } as SiteMembershipRequestBody; + + if (this.versionCompatibilityService.isVersionSupported('7.0.0')) { + memberBody.client = 'workspace'; + } + return from(this.alfrescoApiService.peopleApi.addSiteMembershipRequest('-me-', memberBody)); + } + + private joinLibrary() { + return this.sitesService.createSiteMembership(this.targetSite.id, { + role: 'SiteConsumer', + id: '-me-' + }); + } + + private cancelJoinRequest() { + return from(this.alfrescoApiService.peopleApi.removeSiteMembershipRequest('-me-', this.targetSite.id)); + } + + private getMembershipRequest() { + return from(this.alfrescoApiService.peopleApi.getSiteMembershipRequest('-me-', this.targetSite.id)); + } +} diff --git a/lib/core/directives/public-api.ts b/lib/core/directives/public-api.ts index 9010e85972..cd412ca945 100644 --- a/lib/core/directives/public-api.ts +++ b/lib/core/directives/public-api.ts @@ -26,5 +26,7 @@ export * from './upload.directive'; export * from './version-compatibility.directive'; export * from './tooltip-card/tooltip-card.directive'; export * from './infinite-select-scroll.directive'; +export * from './library-favorite.directive'; +export * from './library-membership.directive'; export * from './directive.module';