From e6619ec9fbaad689da7de5c33deffbb7c9fc2730 Mon Sep 17 00:00:00 2001 From: Manuel Rojas Date: Fri, 19 May 2023 11:18:28 -0600 Subject: [PATCH 01/63] Fix #24916 favorite pages unable to create bookmark image for members page - Fix for Firefox (#24993) * Fixing issue with Firefox * Adding firefox fix --- .../dot-html-to-image/dot-html-to-image.tsx | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/core-web/libs/dotcms-webcomponents/src/components/dot-html-to-image/dot-html-to-image.tsx b/core-web/libs/dotcms-webcomponents/src/components/dot-html-to-image/dot-html-to-image.tsx index 3ac372b0675b..68906863e34f 100644 --- a/core-web/libs/dotcms-webcomponents/src/components/dot-html-to-image/dot-html-to-image.tsx +++ b/core-web/libs/dotcms-webcomponents/src/components/dot-html-to-image/dot-html-to-image.tsx @@ -1,6 +1,11 @@ import { Component, Prop, h, Host, Event, EventEmitter, State } from '@stencil/core'; import '@material/mwc-circular-progress'; +type HtmlIframeDoc = { + doc: Document; + iframe: HTMLIFrameElement; +}; + @Component({ tag: 'dot-html-to-image', styleUrl: 'dot-html-to-image.scss', @@ -21,8 +26,6 @@ export class DotHtmlToImage { error?: string; }>; @State() previewImg: string; - @State() iframe: HTMLIFrameElement; - @State() doc: Document; boundOnMessageHandler = null; iframeId = `iframe_${Math.floor(Date.now() / 1000).toString()}`; @@ -53,24 +56,23 @@ export class DotHtmlToImage { ;`; componentDidLoad() { + const { doc } = this.getIframeDocument(); try { - this.doc.open(); - this.doc.write(this.value); - this.doc.close(); + doc.open(); + doc.write(this.value); + doc.close(); } catch (error) { this.pageThumbnail.emit({ file: null, error }); } } private onLoad() { + const { doc, iframe } = this.getIframeDocument(); try { - this.iframe = document.querySelector(`#${this.iframeId}`) as HTMLIFrameElement; - this.doc = this.iframe.contentDocument || this.iframe.contentWindow.document; - const scriptLib = document.createElement('script') as HTMLScriptElement; scriptLib.src = '/html/js/html2canvas/html2canvas.min.js'; scriptLib.type = 'text/javascript'; - this.doc.body.appendChild(scriptLib); + doc.body.appendChild(scriptLib); scriptLib.onload = () => { const script: HTMLScriptElement = document.createElement('script'); @@ -81,9 +83,9 @@ export class DotHtmlToImage { .replace(/IMG_WIDTH/g, this.width) : this.loadScript; - this.doc.body.appendChild(script); + doc.body.appendChild(script); - this.boundOnMessageHandler = this.onMessageHandler.bind(null, this.iframe, this); + this.boundOnMessageHandler = this.onMessageHandler.bind(null, iframe, this); window.addEventListener('message', this.boundOnMessageHandler); }; } catch (error) { @@ -91,6 +93,13 @@ export class DotHtmlToImage { } } + private getIframeDocument(): HtmlIframeDoc { + const iframe = document.querySelector(`#${this.iframeId}`) as HTMLIFrameElement; + const doc = iframe.contentDocument || iframe.contentWindow.document; + + return { doc, iframe }; + } + render() { const iframeStyle = { width: `${this.width}px`, height: `${this.height}px`, opacity: '0' }; return ( From 1026bcf2185382177d6c985f68e8b3cf69bb1d64 Mon Sep 17 00:00:00 2001 From: Rafael Velazco Date: Fri, 19 May 2023 13:19:02 -0400 Subject: [PATCH 02/63] Fix #24985 UI - Favorite Page: Contentlets infinite scroll table on Pages Portlet (#24985) * fix table #23792 * remove: unnecessary styles * remove: unnecessary styles v2 --- .../dot-pages-listing-panel.component.html | 4 ++-- .../dot-pages-listing-panel.component.scss | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.html index c8e377212a34..c7f702bc94dc 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.html @@ -78,7 +78,6 @@ {{ rowData['title'] }} @@ -133,7 +133,7 @@ - + diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.scss b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.scss index 2b7b99c0a71c..af7983661bed 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.scss +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.scss @@ -11,6 +11,7 @@ overflow: hidden; text-overflow: ellipsis; max-width: 0; + padding: 0 $spacing-2; &:last-child { padding-right: $spacing-2; @@ -24,10 +25,6 @@ padding: $spacing-3; } - .p-datatable .p-datatable-tbody tr.dot-pages-listing-content__row { - height: 47px; - } - .dot-pages-listing-header__language-input .p-dropdown-label.p-inputtext { min-width: 115px; } From 6904e71f3d96a4629fe5b2a44f71caeb68abe227 Mon Sep 17 00:00:00 2001 From: alfredo-dotcms <37185433+alfredo-dotcms@users.noreply.github.com> Date: Fri, 19 May 2023 11:20:38 -0600 Subject: [PATCH 03/63] dotCMS/core#24911 fix DotFavorites-set-collapsed-panel-on-localstorage (#24981) --- .../dot-pages-favorite-panel.component.html | 144 +++++++++--------- ...dot-pages-favorite-panel.component.spec.ts | 21 +++ .../dot-pages-favorite-panel.component.ts | 10 ++ .../dot-pages-store/dot-pages.store.spec.ts | 27 +++- .../dot-pages-store/dot-pages.store.ts | 63 +++++++- 5 files changed, 187 insertions(+), 78 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.html index 0a4e7f0d9abc..93f4592f5880 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.html @@ -1,74 +1,76 @@ - - - - + + + + + - - - + + + - -
- -
- {{ 'favoritePage.listing.empty.header' | dm }} + +
+ +
+ {{ 'favoritePage.listing.empty.header' | dm }} +
+

-

-
- - + + + diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.spec.ts index 462efa3dbbcc..fcc4df41109b 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.spec.ts @@ -106,6 +106,9 @@ describe('DotPagesFavoritePanelComponent', () => { } }); } + setLocalStorageFavoritePanelCollapsedParams(_collapsed: boolean): void { + /* */ + } } describe('Empty state', () => { @@ -150,6 +153,18 @@ describe('DotPagesFavoritePanelComponent', () => { ).toBeTruthy(); }); + it('should set panel collapsed state', () => { + spyOn(store, 'setLocalStorageFavoritePanelCollapsedParams'); + component.toggleFavoritePagesPanel( + new Event('myevent', { + bubbles: true, + cancelable: true, + composed: false + }) + ); + expect(store.setLocalStorageFavoritePanelCollapsedParams).toHaveBeenCalledTimes(1); + }); + it('should load empty pages cards container', () => { expect( de @@ -193,6 +208,9 @@ describe('DotPagesFavoritePanelComponent', () => { getFavoritePages(_itemsPerPage: number): void { /* */ } + setLocalStorageFavoritePanelCollapsedParams(_collapsed: boolean): void { + /* */ + } } beforeEach(() => { TestBed.configureTestingModule({ @@ -382,6 +400,9 @@ describe('DotPagesFavoritePanelComponent', () => { limitFavoritePages(_limit: number): void { /* */ } + setLocalStorageFavoritePanelCollapsedParams(_collapsed: boolean): void { + /* */ + } } beforeEach(() => { TestBed.configureTestingModule({ diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.ts index de7dd8269ba5..007c86ee5172 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.ts @@ -64,6 +64,16 @@ export class DotPagesFavoritePanelComponent { this.currentLimitSize = FAVORITE_PAGE_LIMIT; } + /** + * Event to collapse or not Favorite Page panel + * + * @param {Event} event + * @memberof DotPagesComponent + */ + toggleFavoritePagesPanel($event: Event): void { + this.store.setLocalStorageFavoritePanelCollapsedParams($event['collapsed']); + } + /** * Event that opens dialog to edit/delete Favorite Page * diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts index fdb94293b87a..33a06ebe1ff8 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts @@ -24,6 +24,7 @@ import { DotEventsService, DotLanguagesService, DotLicenseService, + DotLocalstorageService, DotPageTypesService, DotPageWorkflowsActionsService, DotRenderMode, @@ -61,7 +62,11 @@ import { mockWorkflowsActions } from '@dotcms/utils-testing'; -import { DotPageStore, SESSION_STORAGE_FAVORITES_KEY } from './dot-pages.store'; +import { + DotPageStore, + LOCAL_STORAGE_FAVORITES_PANEL_KEY, + SESSION_STORAGE_FAVORITES_KEY +} from './dot-pages.store'; import { contentTypeDataMock } from '../../dot-edit-page/components/dot-palette/dot-palette-content-type/dot-palette-content-type.component.spec'; import { DotLicenseServiceMock } from '../../dot-edit-page/content/services/html/dot-edit-content-toolbar-html.service.spec'; @@ -104,6 +109,7 @@ describe('DotPageStore', () => { let dotWorkflowActionsFireService: DotWorkflowActionsFireService; let dotHttpErrorManagerService: DotHttpErrorManagerService; let dotFavoritePageService: DotFavoritePageService; + let dotLocalstorageService: DotLocalstorageService; beforeEach(() => { TestBed.configureTestingModule({ @@ -122,6 +128,7 @@ describe('DotPageStore', () => { LoggerService, StringUtils, DotFavoritePageService, + DotLocalstorageService, { provide: DialogService, useClass: DialogServiceMock }, { provide: DotcmsEventsService, useClass: DotcmsEventsServiceMock }, { provide: CoreWebService, useClass: CoreWebServiceMock }, @@ -147,10 +154,12 @@ describe('DotPageStore', () => { dotPageWorkflowsActionsService = TestBed.inject(DotPageWorkflowsActionsService); dotWorkflowActionsFireService = TestBed.inject(DotWorkflowActionsFireService); dotFavoritePageService = TestBed.inject(DotFavoritePageService); + dotLocalstorageService = TestBed.inject(DotLocalstorageService); spyOn(dialogService, 'open').and.callThrough(); spyOn(dotHttpErrorManagerService, 'handle'); + spyOn(dotLocalstorageService, 'getItem').and.returnValue(`true`); dotPageStore.setInitialStateData(5); dotPageStore.setKeyword('test'); @@ -257,6 +266,12 @@ describe('DotPageStore', () => { }); }); + it('should get isFavoritePanelCollaped Params', () => { + dotPageStore.isFavoritePanelCollaped$.subscribe((data) => { + expect(data).toEqual(true); + }); + }); + it('should get pages loading status', () => { dotPageStore.isPagesLoading$.subscribe((data) => { expect(data).toEqual(true); @@ -314,6 +329,15 @@ describe('DotPageStore', () => { ); }); + it('should update Local Storage Panel Collapsed Params', () => { + spyOn(dotLocalstorageService, 'setItem').and.callThrough(); + dotPageStore.setLocalStorageFavoritePanelCollapsedParams(true); + expect(dotLocalstorageService.setItem).toHaveBeenCalledWith( + LOCAL_STORAGE_FAVORITES_PANEL_KEY, + 'true' + ); + }); + it('should update Pages Status', () => { dotPageStore.setPagesStatus(ComponentStatus.LOADING); dotPageStore.state$.subscribe((data) => { @@ -374,6 +398,7 @@ describe('DotPageStore', () => { expect(data.favoritePages.items).toEqual(expectedInputArray); expect(data.favoritePages.showLoadMoreButton).toEqual(true); expect(data.favoritePages.total).toEqual(expectedInputArray.length); + expect(data.favoritePages.collapsed).toEqual(undefined); }); expect(dotFavoritePageService.get).toHaveBeenCalledTimes(1); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts index a09f48a7e9a1..a3a03f81d169 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts @@ -32,6 +32,7 @@ import { DotEventsService, DotLanguagesService, DotLicenseService, + DotLocalstorageService, DotMessageService, DotPageTypesService, DotPageWorkflowsActionsService, @@ -61,6 +62,7 @@ import { DotPagesCreatePageDialogComponent } from '../dot-pages-create-page-dial export interface DotPagesState { favoritePages: { + collapsed?: boolean; items: DotCMSContentlet[]; showLoadMoreButton: boolean; total: number; @@ -95,6 +97,8 @@ export interface DotSessionStorageFilter { export const FAVORITE_PAGE_LIMIT = 5; +export const LOCAL_STORAGE_FAVORITES_PANEL_KEY = 'FavoritesPanelCollapsed'; + export const SESSION_STORAGE_FAVORITES_KEY = 'FavoritesSearchTerms'; @Injectable() @@ -109,6 +113,10 @@ export class DotPageStore extends ComponentStore { }; }); + readonly isFavoritePanelCollaped$: Observable = this.select((state) => { + return state.favoritePages.collapsed; + }); + readonly isPagesLoading$: Observable = this.select( (state) => state.pages.status === ComponentStatus.LOADING || @@ -520,6 +528,7 @@ export class DotPageStore extends ComponentStore { readonly vm$: Observable = this.select( this.state$, + this.isFavoritePanelCollaped$, this.isPagesLoading$, this.isPortletLoading$, this.languageOptions$, @@ -538,6 +547,7 @@ export class DotPageStore extends ComponentStore { pages, portletStatus }, + isFavoritePanelCollaped, isPagesLoading, isPortletLoading, languageOptions, @@ -554,6 +564,7 @@ export class DotPageStore extends ComponentStore { loggedUser, pages, portletStatus, + isFavoritePanelCollaped, isPagesLoading, isPortletLoading, languageOptions, @@ -677,6 +688,14 @@ export class DotPageStore extends ComponentStore { return of(params); } + private getLocalStorageFavoritePanelParams(): Observable { + const collapsed = JSON.parse( + this.dotLocalstorageService.getItem(LOCAL_STORAGE_FAVORITES_PANEL_KEY) + ); + + return of(collapsed); + } + private getSelectActions( actions: DotCMSWorkflowAction[], item: DotCMSContentlet, @@ -829,7 +848,8 @@ export class DotPageStore extends ComponentStore { private dotEventsService: DotEventsService, private pushPublishService: PushPublishService, private siteService: SiteService, - private dotFavoritePageService: DotFavoritePageService + private dotFavoritePageService: DotFavoritePageService, + private dotLocalstorageService: DotLocalstorageService ) { super(null); } @@ -848,7 +868,8 @@ export class DotPageStore extends ComponentStore { this.pushPublishService .getEnvironments() .pipe(map((environments: DotEnvironment[]) => !!environments.length)), - this.getSessionStorageFilterParams() + this.getSessionStorageFilterParams(), + this.getLocalStorageFavoritePanelParams() ]) .pipe( take(1), @@ -859,7 +880,16 @@ export class DotPageStore extends ComponentStore { languages, isEnterprise, environments, - filterParams + filterParams, + collapsedParam + ]: [ + ESContent, + DotCurrentUser, + DotLanguage[], + boolean, + boolean, + DotSessionStorageFilter, + boolean ]) => { return this.dotCurrentUser .getUserPermissions( @@ -877,7 +907,8 @@ export class DotPageStore extends ComponentStore { isEnterprise, environments, permissionsType, - filterParams + filterParams, + collapsedParam ]; }) ); @@ -892,7 +923,8 @@ export class DotPageStore extends ComponentStore { isEnterprise, environments, permissions, - filterParams + filterParams, + collapsedParam ]: [ ESContent, DotCurrentUser, @@ -900,10 +932,12 @@ export class DotPageStore extends ComponentStore { boolean, boolean, DotPermissionsType, - DotSessionStorageFilter + DotSessionStorageFilter, + boolean ]): void => { this.setState({ favoritePages: { + collapsed: collapsedParam, items: favoritePages?.jsonObjectView.contentlets, showLoadMoreButton: favoritePages.jsonObjectView.contentlets.length < @@ -937,6 +971,7 @@ export class DotPageStore extends ComponentStore { () => { this.setState({ favoritePages: { + collapsed: true, items: [], showLoadMoreButton: false, total: 0 @@ -976,6 +1011,22 @@ export class DotPageStore extends ComponentStore { this.setFavoritePages(favoritePages.slice(0, limit)); } + /** + * Sets on LocalStorage Favorite Page panel collapsed state + * @param boolean collapsed + * @memberof DotFavoritePageStore + */ + setLocalStorageFavoritePanelCollapsedParams(collapsed: boolean): void { + this.dotLocalstorageService.setItem( + LOCAL_STORAGE_FAVORITES_PANEL_KEY, + collapsed.toString() + ); + } + + /** + * Sets on Session Storage Page's table filter params + * @memberof DotFavoritePageStore + */ setSessionStorageFilterParams(): void { const { keyword, languageId, archived } = this.get().pages; From 003371a49a5f113e1c7f09b4ee99de701cc7e8da Mon Sep 17 00:00:00 2001 From: alfredo-dotcms <37185433+alfredo-dotcms@users.noreply.github.com> Date: Mon, 22 May 2023 10:29:49 -0600 Subject: [PATCH 04/63] dotCMS/core#24984 fix Archived pages should not be able to ADD/EDIT bookmarks (#24995) --- .../dot-pages-store/dot-pages.store.spec.ts | 47 +++++++++++-- .../dot-pages-store/dot-pages.store.ts | 68 ++++++++++--------- 2 files changed, 79 insertions(+), 36 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts index 33a06ebe1ff8..6e0368dac816 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts @@ -156,7 +156,6 @@ describe('DotPageStore', () => { dotFavoritePageService = TestBed.inject(DotFavoritePageService); dotLocalstorageService = TestBed.inject(DotLocalstorageService); - spyOn(dialogService, 'open').and.callThrough(); spyOn(dotHttpErrorManagerService, 'handle'); spyOn(dotLocalstorageService, 'getItem').and.returnValue(`true`); @@ -639,12 +638,45 @@ describe('DotPageStore', () => { }); }); + it('should not have Add/Edit Bookmark actions in context menu when contentlet is archived', () => { + spyOn(dotPageWorkflowsActionsService, 'getByUrl').and.returnValue( + of({ actions: mockWorkflowsActions, page: dotcmsContentletMock }) + ); + + dotPageStore.showActionsMenu({ + item: { + ...favoritePagesInitialTestData[1], + url: '/index2?host_id=A&language_id=1&device_inode=123', + contentType: 'dotFavoritePage', + archived: true + }, + actionMenuDomId: 'test1' + }); + + expect(dotPageWorkflowsActionsService.getByUrl).toHaveBeenCalledWith({ + host_id: 'A', + language_id: '1', + url: '/index2' + }); + + dotPageStore.state$.subscribe((data) => { + expect(data.pages.menuActions.length).toEqual(8); + expect(data.pages.menuActions[0].label).toEqual('favoritePage.contextMenu.action.edit'); + expect(data.pages.menuActions[1].label).toEqual('favoritePage.dialog.delete.button'); + }); + }); + it('should get all menu actions from a favorite page when page is archived', () => { - const error404 = mockResponseView(404, '/page', null, { message: 'error' }); - spyOn(dotPageWorkflowsActionsService, 'getByUrl').and.returnValue(throwError(error404)); + spyOn(dotPageWorkflowsActionsService, 'getByUrl').and.returnValue( + of({ actions: mockWorkflowsActions, page: dotcmsContentletMock }) + ); dotPageStore.showActionsMenu({ - item: { ...favoritePagesInitialTestData[0], contentType: 'dotFavoritePage' }, + item: { + ...favoritePagesInitialTestData[0], + contentType: 'dotFavoritePage', + archived: true + }, actionMenuDomId: 'test1' }); @@ -655,9 +687,14 @@ describe('DotPageStore', () => { }); dotPageStore.state$.subscribe((data) => { - expect(data.pages.menuActions.length).toEqual(2); expect(data.pages.menuActions[0].label).toEqual('favoritePage.contextMenu.action.edit'); expect(data.pages.menuActions[1].label).toEqual('favoritePage.dialog.delete.button'); + expect(data.pages.menuActions[2]).toEqual({ separator: true }); + expect(data.pages.menuActions[3].label).toEqual('Assign Workflow'); + expect(data.pages.menuActions[4].label).toEqual('Save'); + expect(data.pages.menuActions[5].label).toEqual('Save / Publish'); + expect(data.pages.menuActions[6].label).toEqual('contenttypes.content.push_publish'); + expect(data.pages.menuActions[7].label).toEqual('contenttypes.content.add_to_bundle'); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts index a3a03f81d169..fff7c7bf8c1b 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts @@ -509,14 +509,16 @@ export class DotPageStore extends ComponentStore { take(1), tapResponse( ({ workflowsData, dotFavorite }) => { - this.setMenuActions({ - actions: this.getSelectActions( - workflowsData?.actions, - workflowsData?.page, - dotFavorite.jsonObjectView.contentlets[0] - ), - actionMenuDomId - }); + if (workflowsData) { + this.setMenuActions({ + actions: this.getSelectActions( + workflowsData?.actions, + workflowsData?.page, + dotFavorite.jsonObjectView.contentlets[0] + ), + actionMenuDomId + }); + } }, (error: HttpErrorResponse) => this.httpErrorManagerService.handle(error) ) @@ -712,29 +714,31 @@ export class DotPageStore extends ComponentStore { }); // Adding DotFavorite actions - actionsMenu.push({ - label: favoritePage - ? this.dotMessageService.get('favoritePage.contextMenu.action.edit') - : this.dotMessageService.get('favoritePage.contextMenu.action.add'), - command: () => { - this.dialogService.open(DotFavoritePageComponent, { - header: this.dotMessageService.get('favoritePage.dialog.header'), - width: '80rem', - data: { - page: { - favoritePageUrl, - favoritePage - }, - onSave: () => { - this.getFavoritePages(FAVORITE_PAGE_LIMIT); - }, - onDelete: () => { - this.getFavoritePages(FAVORITE_PAGE_LIMIT); + if (!item.archived) { + actionsMenu.push({ + label: favoritePage + ? this.dotMessageService.get('favoritePage.contextMenu.action.edit') + : this.dotMessageService.get('favoritePage.contextMenu.action.add'), + command: () => { + this.dialogService.open(DotFavoritePageComponent, { + header: this.dotMessageService.get('favoritePage.dialog.header'), + width: '80rem', + data: { + page: { + favoritePageUrl, + favoritePage + }, + onSave: () => { + this.getFavoritePages(FAVORITE_PAGE_LIMIT); + }, + onDelete: () => { + this.getFavoritePages(FAVORITE_PAGE_LIMIT); + } } - } - }); - } - }); + }); + } + }); + } if (favoritePage) { actionsMenu.push({ @@ -749,7 +753,9 @@ export class DotPageStore extends ComponentStore { return actionsMenu; } - actionsMenu.push({ separator: true }); + if (actionsMenu?.length > 0) { + actionsMenu.push({ separator: true }); + } // Adding Edit & View actions const { loggedUser, isEnterprise, environments } = this.get(); From 94eba0cdb010401f36a9c8e28a1ffde26b27d7eb Mon Sep 17 00:00:00 2001 From: alfredo-dotcms <37185433+alfredo-dotcms@users.noreply.github.com> Date: Mon, 22 May 2023 17:32:46 -0600 Subject: [PATCH 05/63] Fix #24991 dot favorite pages table stuck when site changed (#25011) * dotCMS/core#24984 fix Archived pages should not be able to ADD/EDIT bookmarks * dotCMS/core#24991 fix Fix Stuck State in the New Pages Portlet When Changing Sites --- .../dot-pages-store/dot-pages.store.spec.ts | 30 +++++++++++++++++++ .../dot-pages-store/dot-pages.store.ts | 8 ++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts index 6e0368dac816..aa028ef7a937 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts @@ -454,6 +454,36 @@ describe('DotPageStore', () => { }); }); + it('should set Pages to empty when changed from a Site with data to an empty one', () => { + const pagesData = [ + { + ...favoritePagesInitialTestData[0] + }, + { + ...favoritePagesInitialTestData[1] + } + ]; + + dotPageStore.setPages(pagesData); + + spyOn(dotESContentService, 'get').and.returnValue( + of({ + contentTook: 0, + jsonObjectView: { + contentlets: [] + }, + queryTook: 1, + resultsSize: 0 + }) + ); + dotPageStore.getPages({ offset: 0, sortField: 'title', sortOrder: 1 }); + + dotPageStore.state$.subscribe((data) => { + expect(data.pages.items).toEqual([]); + }); + expect(dotESContentService.get).toHaveBeenCalledTimes(1); + }); + it('should handle error when get Pages value fails', () => { const error500 = mockResponseView(500, '/test', null, { message: 'error' }); spyOn(dotESContentService, 'get').and.returnValue(throwError(error500)); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts index fff7c7bf8c1b..54512125093a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts @@ -459,17 +459,13 @@ export class DotPageStore extends ComponentStore { return this.getPagesData(offset, sortOrderValue, sortField).pipe( tapResponse( (items) => { - let currentPages = this.get().pages.items; - - if (currentPages.length === 0) { - currentPages = Array.from({ length: items.resultsSize }); - } + const currentPages = Array.from({ length: items.resultsSize }); Array.prototype.splice.apply(currentPages, [ ...[offset, 40], ...items.jsonObjectView.contentlets ]); - this.setPages(currentPages); + this.setPages(currentPages as DotCMSContentlet[]); }, (error: HttpErrorResponse) => { this.setPagesStatus(ComponentStatus.LOADED); From 60c068acddbe54f98afc6a1c7a6673476da2ef86 Mon Sep 17 00:00:00 2001 From: Manuel Rojas Date: Mon, 22 May 2023 17:33:20 -0600 Subject: [PATCH 06/63] Adding fix for sonarqube (#25012) --- .../src/components/dot-html-to-image/dot-html-to-image.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core-web/libs/dotcms-webcomponents/src/components/dot-html-to-image/dot-html-to-image.tsx b/core-web/libs/dotcms-webcomponents/src/components/dot-html-to-image/dot-html-to-image.tsx index 68906863e34f..07d883d01379 100644 --- a/core-web/libs/dotcms-webcomponents/src/components/dot-html-to-image/dot-html-to-image.tsx +++ b/core-web/libs/dotcms-webcomponents/src/components/dot-html-to-image/dot-html-to-image.tsx @@ -94,7 +94,7 @@ export class DotHtmlToImage { } private getIframeDocument(): HtmlIframeDoc { - const iframe = document.querySelector(`#${this.iframeId}`) as HTMLIFrameElement; + const iframe: HTMLIFrameElement = document.querySelector(`#${this.iframeId}`); const doc = iframe.contentDocument || iframe.contentWindow.document; return { doc, iframe }; From 7df7c26a3efcf0d2f6822aa3d4b5079ff0365845 Mon Sep 17 00:00:00 2001 From: Rafael Velazco Date: Wed, 24 May 2023 12:07:13 -0400 Subject: [PATCH 07/63] Favorite Pages: Allow User Cross-Site Page Editing (#25005) * dev: allow user cross site page editing #24992 * dev: set site in edit page route resolver * dev: handler error * fix: test * feedback v1 * feedback v2 * fix: docs * fix: docs v2 --- .../dot-edit-page-resolver.service.spec.ts | 46 ++++++++++- .../dot-edit-page-resolver.service.ts | 79 ++++++++++++------- ...dot-experiment-experiment.resolver.spec.ts | 9 ++- .../portlets/dot-pages/dot-pages.module.ts | 2 - .../dot-toolbar/dot-toolbar.component.ts | 12 +-- .../src/lib/core/site.service.mock.ts | 12 ++- .../dotcms-js/src/lib/core/site.service.ts | 53 +++++++++---- .../src/lib/site-service.mock.ts | 13 ++- 8 files changed, 167 insertions(+), 59 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/shared/services/dot-edit-page-resolver/dot-edit-page-resolver.service.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/shared/services/dot-edit-page-resolver/dot-edit-page-resolver.service.spec.ts index f1ad8e1daaf0..de3162795a00 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/shared/services/dot-edit-page-resolver/dot-edit-page-resolver.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/shared/services/dot-edit-page-resolver/dot-edit-page-resolver.service.spec.ts @@ -22,7 +22,7 @@ import { DotESContentService, DotPageRenderService } from '@dotcms/data-access'; -import { CoreWebService, HttpCode, LoginService } from '@dotcms/dotcms-js'; +import { CoreWebService, HttpCode, LoginService, SiteService } from '@dotcms/dotcms-js'; import { DotPageMode, DotPageRender, DotPageRenderState } from '@dotcms/dotcms-models'; import { CoreWebServiceMock, @@ -30,7 +30,8 @@ import { mockDotRenderedPage, MockDotRouterService, mockResponseView, - mockUser + mockUser, + SiteServiceMock } from '@dotcms/utils-testing'; import { DotEditPageResolver } from './dot-edit-page-resolver.service'; @@ -51,6 +52,7 @@ describe('DotEditPageResolver', () => { let injector: TestBed; let dotEditPageResolver: DotEditPageResolver; + let siteService: SiteService; beforeEach(() => { TestBed.configureTestingModule({ @@ -72,6 +74,7 @@ describe('DotEditPageResolver', () => { DotFavoritePageService, { provide: DotRouterService, useClass: MockDotRouterService }, { provide: DotMessageDisplayService, useClass: DotMessageDisplayServiceMock }, + { provide: SiteService, useClass: SiteServiceMock }, { provide: ActivatedRouteSnapshot, useValue: route @@ -88,6 +91,7 @@ describe('DotEditPageResolver', () => { dotPageStateService = injector.get(DotPageStateService); dotPageStateServiceRequestPageSpy = spyOn(dotPageStateService, 'requestPage'); dotRouterService = injector.get(DotRouterService); + siteService = injector.get(SiteService); spyOn(dotHttpErrorManagerService, 'handle').and.returnValue(of()); }); @@ -144,6 +148,44 @@ describe('DotEditPageResolver', () => { expect(dotPageStateServiceRequestPageSpy).not.toHaveBeenCalled(); }); + describe('Switch Site', () => { + it('should switch site when host_id is present in queryparams', () => { + route.queryParams.host_id = '123'; + spyOn(siteService, 'switchSiteById').and.returnValue(of(null)); + const mock = new DotPageRenderState( + mockUser(), + new DotPageRender(mockDotRenderedPage()) + ); + dotPageStateServiceRequestPageSpy.and.returnValue(of(mock)); + dotEditPageResolver.resolve(route).subscribe(); + expect(siteService.switchSiteById).toHaveBeenCalledWith('123'); + }); + + it('should not switch site when host_id is not present in queryparams', () => { + route.queryParams = {}; + spyOn(siteService, 'switchSiteById').and.returnValue(of(null)); + const mock = new DotPageRenderState( + mockUser(), + new DotPageRender(mockDotRenderedPage()) + ); + dotPageStateServiceRequestPageSpy.and.returnValue(of(mock)); + dotEditPageResolver.resolve(route).subscribe(); + expect(siteService.switchSiteById).not.toHaveBeenCalled(); + }); + + it('should not switch site when host_id is equal to current site id', () => { + route.queryParams.host_id = siteService.currentSite.identifier; + spyOn(siteService, 'switchSiteById').and.returnValue(of(null)); + const mock = new DotPageRenderState( + mockUser(), + new DotPageRender(mockDotRenderedPage()) + ); + dotPageStateServiceRequestPageSpy.and.returnValue(of(mock)); + dotEditPageResolver.resolve(route).subscribe(); + expect(siteService.switchSiteById).not.toHaveBeenCalled(); + }); + }); + describe('handle layout', () => { beforeEach(() => { route.children = [ diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/shared/services/dot-edit-page-resolver/dot-edit-page-resolver.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/shared/services/dot-edit-page-resolver/dot-edit-page-resolver.service.ts index da5c7b10f0de..d8035cb1add4 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/shared/services/dot-edit-page-resolver/dot-edit-page-resolver.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/shared/services/dot-edit-page-resolver/dot-edit-page-resolver.service.ts @@ -1,14 +1,14 @@ -import { Observable, of, throwError } from 'rxjs'; +import { Observable, forkJoin, of, throwError } from 'rxjs'; import { HttpErrorResponse, HttpHeaders, HttpResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; -import { catchError, filter, map, switchMap, tap } from 'rxjs/operators'; +import { catchError, filter, map, switchMap } from 'rxjs/operators'; import { DotHttpErrorManagerService } from '@dotcms/app/api/services/dot-http-error-manager/dot-http-error-manager.service'; import { DotRouterService } from '@dotcms/app/api/services/dot-router/dot-router.service'; -import { DotCMSResponse, HttpCode } from '@dotcms/dotcms-js'; +import { DotCMSResponse, HttpCode, Site, SiteService } from '@dotcms/dotcms-js'; import { DotPageRenderOptions, DotPageRenderState } from '@dotcms/dotcms-models'; import { DotPageStateService } from '../../../content/services/dot-page-state/dot-page-state.service'; @@ -25,37 +25,21 @@ export class DotEditPageResolver implements Resolve { constructor( private dotPageStateService: DotPageStateService, private dotRouterService: DotRouterService, - private dotHttpErrorManagerService: DotHttpErrorManagerService + private dotHttpErrorManagerService: DotHttpErrorManagerService, + private siteService: SiteService ) {} resolve(route: ActivatedRouteSnapshot): Observable { const data = this.dotPageStateService.getInternalNavigationState(); + const renderOptions = this.getDotPageRenderOptions(route); + const currentSection = route.children[0].url[0].path; + const isLayout = currentSection === 'layout'; + const hostId = route.queryParams?.host_id; - if (data) { - return of(data); - } else { - return this.dotPageStateService.requestPage(this.getDotPageRenderOptions(route)).pipe( - tap((state: DotPageRenderState) => { - if (!state) { - this.dotRouterService.goToSiteBrowser(); - } - }), - filter((state: DotPageRenderState) => !!state), - switchMap((dotRenderedPageState: DotPageRenderState) => { - const currentSection = route.children[0].url[0].path; - const isLayout = currentSection === 'layout'; - - return isLayout - ? this.checkUserCanGoToLayout(dotRenderedPageState) - : of(dotRenderedPageState); - }), - catchError((err: HttpErrorResponse) => { - this.dotRouterService.goToSiteBrowser(); - - return this.dotHttpErrorManagerService.handle(err).pipe(map(() => null)); - }) - ); - } + // If we have data, we don't need to request the page again + const data$ = data ? of(data) : this.getPageRenderState(renderOptions, isLayout); + + return forkJoin([this.setSite(hostId), data$]).pipe(map(([_, pageRender]) => pageRender)); } private checkUserCanGoToLayout( @@ -104,4 +88,41 @@ export class DotEditPageResolver implements Resolve { return renderOptions; } + + private setSite(id: string): Observable { + const currentSiteId = this.siteService.currentSite?.identifier; + const shouldSwitchSite = id && id !== currentSiteId; + + // If we have a site id and is different from the current one, we switch + return shouldSwitchSite + ? this.siteService.switchSiteById(id).pipe( + catchError((err: HttpErrorResponse) => { + return this.dotHttpErrorManagerService.handle(err).pipe(map(() => null)); + }) + ) + : of(null); + } + + private getPageRenderState( + renderOptions: DotPageRenderOptions, + isLayout: boolean + ): Observable { + return this.dotPageStateService.requestPage(renderOptions).pipe( + filter((state: DotPageRenderState) => { + if (!state) this.dotRouterService.goToSiteBrowser(); + + return !!state; + }), + switchMap((dotRenderedPageState: DotPageRenderState) => { + return isLayout + ? this.checkUserCanGoToLayout(dotRenderedPageState) + : of(dotRenderedPageState); + }), + catchError((err: HttpErrorResponse) => { + this.dotRouterService.goToSiteBrowser(); + + return this.dotHttpErrorManagerService.handle(err).pipe(map(() => null)); + }) + ); + } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/resolvers/dot-experiment-experiment.resolver.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/resolvers/dot-experiment-experiment.resolver.spec.ts index b3136ebeca9f..558e547f7135 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/resolvers/dot-experiment-experiment.resolver.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/resolvers/dot-experiment-experiment.resolver.spec.ts @@ -18,7 +18,13 @@ import { DotESContentService, DotPageRenderService } from '@dotcms/data-access'; -import { CoreWebService, HttpCode, LoginService } from '@dotcms/dotcms-js'; +import { + CoreWebService, + HttpCode, + LoginService, + SiteService, + SiteServiceMock +} from '@dotcms/dotcms-js'; import { DotPageRender, DotPageRenderState } from '@dotcms/dotcms-models'; import { CoreWebServiceMock, @@ -66,6 +72,7 @@ describe('DotExperimentExperimentResolver', () => { DotFavoritePageService, { provide: DotMessageDisplayService, useClass: DotMessageDisplayServiceMock }, { provide: DotRouterService, useClass: MockDotRouterService }, + { provide: SiteService, useClass: SiteServiceMock }, { provide: ActivatedRouteSnapshot, useValue: route diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.module.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.module.ts index 376347b86148..9bb14ba61c40 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.module.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.module.ts @@ -19,7 +19,6 @@ import { DotPageTypesService, DotPageWorkflowsActionsService } from '@dotcms/data-access'; -import { SiteService } from '@dotcms/dotcms-js'; import { DotPagesCreatePageDialogComponent } from './dot-pages-create-page-dialog/dot-pages-create-page-dialog.component'; import { DotPagesFavoritePanelModule } from './dot-pages-favorite-panel/dot-pages-favorite-panel.module'; @@ -51,7 +50,6 @@ import { DotPagesComponent } from './dot-pages.component'; DotWorkflowActionsFireService, DotWorkflowEventHandlerService, DotRouterService, - SiteService, DotFavoritePageService ] }) diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.component.ts index cd70fcd3c782..019acb12f86e 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.component.ts @@ -39,11 +39,13 @@ export class DotToolbarComponent implements OnInit { } siteChange(site: Site): void { - this.siteService.switchSite(site); - - if (this.dotRouterService.isEditPage()) { - this.dotRouterService.goToSiteBrowser(); - } + this.siteService.switchSite(site).subscribe(() => { + // wait for the site to be switched + // before redirecting to the site browser + if (this.dotRouterService.isEditPage()) { + this.dotRouterService.goToSiteBrowser(); + } + }); } handleMainButtonClick(): void { diff --git a/core-web/libs/dotcms-js/src/lib/core/site.service.mock.ts b/core-web/libs/dotcms-js/src/lib/core/site.service.mock.ts index dc0590ca7889..3bb688da0a85 100644 --- a/core-web/libs/dotcms-js/src/lib/core/site.service.mock.ts +++ b/core-web/libs/dotcms-js/src/lib/core/site.service.mock.ts @@ -1,5 +1,6 @@ -import { of as observableOf, Observable, Subject, merge } from 'rxjs'; +import { of as observableOf, Observable, Subject, merge, of } from 'rxjs'; import { Site } from '@dotcms/dotcms-js'; +import { switchMap } from 'rxjs/operators'; export const mockSites: Site[] = [ { @@ -37,8 +38,13 @@ export class SiteServiceMock { this._switchSite$.next(site || mockSites[0]); } - // eslint-disable-next-line @typescript-eslint/no-empty-function - switchSite(_site: Site) {} + switchSiteById(): Observable { + return this.getSiteById().pipe(switchMap((site) => this.switchSite(site))); + } + + switchSite(site: Site): Observable { + return of(site); + } getCurrentSite(): Observable { return merge(observableOf(mockSites[0]), this.switchSite$); diff --git a/core-web/libs/dotcms-js/src/lib/core/site.service.ts b/core-web/libs/dotcms-js/src/lib/core/site.service.ts index 9867923629e9..4fff9cde49fc 100644 --- a/core-web/libs/dotcms-js/src/lib/core/site.service.ts +++ b/core-web/libs/dotcms-js/src/lib/core/site.service.ts @@ -2,7 +2,7 @@ import { Observable, Subject, of, merge } from 'rxjs'; import { Injectable } from '@angular/core'; -import { pluck, map, take } from 'rxjs/operators'; +import { pluck, map, take, switchMap, tap } from 'rxjs/operators'; import { CoreWebService } from './core-web.service'; import { DotcmsEventsService } from './dotcms-events.service'; @@ -70,10 +70,11 @@ export class SiteService { if (siteIdentifier === this.selectedSite.identifier) { name === 'ARCHIVE_SITE' ? this.switchToDefaultSite() - .pipe(take(1)) - .subscribe((currentSite: Site) => { - this.switchSite(currentSite); - }) + .pipe( + take(1), + switchMap((site) => this.switchSite(site)) + ) + .subscribe() : this.loadCurrentSite(); } } @@ -132,8 +133,8 @@ export class SiteService { /** * Get a site by the id * - * @param string id - * @returns Observable + * @param {string} id + * @return {*} {Observable} * @memberof SiteService */ getSiteById(id: string): Observable { @@ -147,22 +148,46 @@ export class SiteService { ); } + /** + * Switch site by the id + * This method gets a new site by the id and switch to it + * + * @param {string} id + * @return {*} {Observable} + * @memberof SiteService + */ + switchSiteById(id: string): Observable { + this.loggerService.debug('Applying a Site Switch'); + + return this.getSiteById(id).pipe( + switchMap((site) => { + // If there is a site we switch to it + return site ? this.switchSite(site) : of(null); + }), + take(1) + ); + } + /** * Change the current site - * @param Site site + * + * @param {Site} site + * @return {*} {Observable} * @memberof SiteService */ - switchSite(site: Site): void { + switchSite(site: Site): Observable { this.loggerService.debug('Applying a Site Switch', site.identifier); - this.coreWebService + + return this.coreWebService .requestView({ method: 'PUT', url: `${this.urls.switchSiteUrl}/${site.identifier}` }) - .pipe(take(1)) - .subscribe(() => { - this.setCurrentSite(site); - }); + .pipe( + take(1), + tap(() => this.setCurrentSite(site)), + map(() => site) + ); } /** diff --git a/core-web/libs/utils-testing/src/lib/site-service.mock.ts b/core-web/libs/utils-testing/src/lib/site-service.mock.ts index a829852e6bbe..f12001738f14 100644 --- a/core-web/libs/utils-testing/src/lib/site-service.mock.ts +++ b/core-web/libs/utils-testing/src/lib/site-service.mock.ts @@ -1,4 +1,7 @@ import { of as observableOf, Observable, Subject, merge } from 'rxjs'; + +import { switchMap } from 'rxjs/operators'; + import { Site } from '@dotcms/dotcms-js'; export const mockSites: Site[] = [ @@ -17,7 +20,7 @@ export const mockSites: Site[] = [ ]; export class SiteServiceMock { - _currentSite: Site; + _currentSite!: Site; private _switchSite$: Subject = new Subject(); get currentSite(): Site { @@ -37,8 +40,12 @@ export class SiteServiceMock { this._switchSite$.next(site || mockSites[0]); } - switchSite(_site: Site) { - /* */ + switchSiteById(_id: string): Observable { + return this.getSiteById().pipe(switchMap((site) => this.switchSite(site))); + } + + switchSite(site: Site): Observable { + return observableOf(site); } get loadedSites(): Site[] { From 81de48de258e776a9dcd7063f3e15f67e463df76 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Wed, 24 May 2023 16:10:27 -0600 Subject: [PATCH 08/63] =?UTF-8?q?#18123=20adding=20the=20ability=20to=20ad?= =?UTF-8?q?d=20children=20relationships=20to=20the=20urlmap=E2=80=A6=20(#2?= =?UTF-8?q?4939)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #18123 adding the ability to add children relationships to the urlmap on the page render * #18123 feedback and itest done * #18213 adding feedback --- .../content/util/ContentUtilsTest.java | 65 +++++++++++++++ .../velocity/services/PageRenderUtil.java | 32 +------- .../viewtools/content/util/ContentUtils.java | 82 ++++++++++++++++++- .../render/page/PageViewSerializer.java | 41 ++++++++-- .../java/com/dotmarketing/util/WebKeys.java | 1 + 5 files changed, 181 insertions(+), 40 deletions(-) diff --git a/dotCMS/src/integration-test/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtilsTest.java b/dotCMS/src/integration-test/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtilsTest.java index 8c7f248c8885..3e995b604551 100644 --- a/dotCMS/src/integration-test/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtilsTest.java +++ b/dotCMS/src/integration-test/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtilsTest.java @@ -18,9 +18,14 @@ import com.dotcms.contenttype.model.type.ContentType; import com.dotcms.contenttype.model.type.ContentTypeBuilder; import com.dotcms.contenttype.model.type.SimpleContentType; +import com.dotcms.datagen.ContentTypeDataGen; import com.dotcms.datagen.ContentletDataGen; +import com.dotcms.datagen.FieldDataGen; import com.dotcms.datagen.SiteDataGen; import com.dotcms.datagen.TestDataUtils; +import com.dotcms.mock.request.MockHttpRequestIntegrationTest; +import com.dotcms.mock.request.MockParameterRequest; +import com.dotcms.mock.response.MockHttpResponse; import com.dotcms.rendering.velocity.viewtools.content.util.ContentUtilsTest.TestCase.LANGUAGE_TYPE_FILTER; import com.dotcms.rendering.velocity.viewtools.content.util.ContentUtilsTest.TestCase.PUBLISH_TYPE_FILTER; import com.dotcms.util.IntegrationTestInitService; @@ -39,8 +44,10 @@ import com.dotmarketing.portlets.languagesmanager.model.Language; import com.dotmarketing.portlets.structure.model.ContentletRelationships; import com.dotmarketing.portlets.structure.model.Relationship; +import com.dotmarketing.util.PageMode; import com.dotmarketing.util.UtilMethods; import com.dotmarketing.util.WebKeys.Relationship.RELATIONSHIP_CARDINALITY; +import com.google.common.collect.ImmutableMap; import com.liferay.portal.model.User; import com.liferay.util.StringPool; import com.tngtech.java.junit.dataprovider.DataProvider; @@ -48,15 +55,23 @@ import com.tngtech.java.junit.dataprovider.UseDataProvider; import java.util.ArrayList; +import java.util.Arrays; import java.util.Calendar; +import java.util.Collection; import java.util.Date; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + /** * @author nollymar */ @@ -1087,4 +1102,54 @@ public void testPullRelatedFieldShouldRespectDefaultOrder() } } } + + /** + * Method to test: {@link ContentUtils#addRelationships(Contentlet, User, PageMode, long, int, HttpServletRequest, HttpServletResponse)} + * Given Scenario: Creates a content parent with a children many to many relationship, create a few instances of the child type and related to the parent + * ExpectedResult: Calling the parameter with depth in 0 should retrieve the children contentlets + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + public void test_add_relationships_on_parent_children_related_contents() + throws DotDataException, DotSecurityException { + + // 1) create the child content type + final ContentType childContentType = new ContentTypeDataGen().velocityVarName("child"+System.currentTimeMillis()).nextPersisted(); + // 2) create parent content type and add a relationship to the child content type + final ContentType parentContentType = new ContentTypeDataGen().velocityVarName("parent"+System.currentTimeMillis()).nextPersisted(); + Field field = FieldBuilder.builder(RelationshipField.class).name("children") + .contentTypeId(parentContentType.id()).values(String.valueOf(RELATIONSHIP_CARDINALITY.MANY_TO_MANY.ordinal())) + .relationType(childContentType.variable()).build(); + + APILocator.getContentTypeFieldAPI().save(field, APILocator.systemUser()); + // 3) create a few child instances + + final Contentlet child1 = new ContentletDataGen(childContentType.id()).nextPersisted(); + final Contentlet child2 = new ContentletDataGen(childContentType.id()).nextPersisted(); + final Contentlet child3 = new ContentletDataGen(childContentType.id()).nextPersisted(); + // 4) create a instance of the parent and add the child instances to the relationship + // 5) save it + final Contentlet parent = new ContentletDataGen(parentContentType.id()).setProperty("children", Arrays.asList(child1, child2, child3)).nextPersisted(); + + // 6) retrieve again + final Contentlet parentRetrieved = APILocator.getContentletAPI().findContentletByIdentifierAnyLanguage(parent.getIdentifier()); + Assert.assertNotNull(parentRetrieved); + Assert.assertEquals(parent.getIdentifier(), parentRetrieved.getIdentifier()); + // 7) call the addRelationships method with depth = 1 + final int depth = 0; // only ids + final HttpServletRequest request = new MockHttpRequestIntegrationTest("localhost", "/api/v1/test").request(); + final HttpServletResponse response = new MockHttpResponse().response(); + ContentUtils.addRelationships(parentRetrieved, user, PageMode.EDIT_MODE, + APILocator.getLanguageAPI().getDefaultLanguage().getId(), depth, request, response); + // 8) check the children contentlets are there + Assert.assertTrue(parentRetrieved.getMap().containsKey("children")); + final Object children = parentRetrieved.get("children"); + Assert.assertNotNull(children); + Assert.assertTrue(children instanceof Collection); + final Set childrenIds = new HashSet<>((Collection) children); + Assert.assertTrue(childrenIds.contains(child1.getIdentifier())); + Assert.assertTrue(childrenIds.contains(child2.getIdentifier())); + Assert.assertTrue(childrenIds.contains(child3.getIdentifier())); + } } diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/services/PageRenderUtil.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/services/PageRenderUtil.java index ec1648a55743..6cc6c22f3845 100644 --- a/dotCMS/src/main/java/com/dotcms/rendering/velocity/services/PageRenderUtil.java +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/services/PageRenderUtil.java @@ -11,6 +11,7 @@ import com.dotcms.publisher.endpoint.bean.PublishingEndPoint; import com.dotcms.rendering.velocity.directive.ParseContainer; import com.dotcms.rendering.velocity.viewtools.DotTemplateTool; +import com.dotcms.rendering.velocity.viewtools.content.util.ContentUtils; import com.dotcms.repackage.com.google.common.collect.Lists; import com.dotcms.rest.ContentResource; import com.dotcms.rest.api.v1.DotObjectMapperProvider; @@ -110,8 +111,6 @@ public class PageRenderUtil implements Serializable { final TemplateLayout templateLayout; - // it is true, even if the pattern is false because the client has to include the depth parameter to activate it - private static final Lazy ADD_RELATIONSHIPS_ON_PAGE = Lazy.of(()->Config.getBooleanProperty("ADD_RELATIONSHIPS_ON_PAGE", true)); /** * Creates an instance of this class for a given HTML Page. @@ -356,34 +355,7 @@ private List populateContainers() throws DotDataException, DotSecu private void addRelationships(final Contentlet contentlet) { - final HttpServletRequest request = HttpServletRequestThreadLocal.INSTANCE.getRequest(); - final HttpServletResponse response = HttpServletResponseThreadLocal.INSTANCE.getResponse(); - if (ADD_RELATIONSHIPS_ON_PAGE.get() && null != response && null != request && null != request.getParameter("depth")) { - - final int depth = ConversionUtils.toInt(request.getParameter("depth"), -1); - if (depth >= 0 && depth <= 3) { - - try { - - final JSONObject jsonWithRelationShips = ContentResource.addRelationshipsToJSON(request, response, - request.getParameter("render"), user, depth, mode.respectAnonPerms, contentlet, - new JSONObject(), null, languageId, mode.showLive, false, - true); - - final HashMap relationshipsMap = DotObjectMapperProvider.getInstance() - .getDefaultObjectMapper().readValue(jsonWithRelationShips.toString(), HashMap.class); - - if (UtilMethods.isSet(relationshipsMap)) { - contentlet.getMap().putAll(relationshipsMap); - } - } catch (Exception e) { - - Logger.error(this, "Error, contentlet id:" + - contentlet.getIdentifier() + ", msg:" + e.getMessage(), e); - throw new RuntimeException(e); - } - } - } + ContentUtils.addRelationships(contentlet, user, mode, languageId); } private Contentlet getContentletByVariantFallback(final String currentVariantId, diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtils.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtils.java index 90d6f9a46bb3..23e3ae423ac7 100644 --- a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtils.java +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtils.java @@ -1,31 +1,43 @@ package com.dotcms.rendering.velocity.viewtools.content.util; +import com.dotcms.api.web.HttpServletRequestThreadLocal; +import com.dotcms.api.web.HttpServletResponseThreadLocal; import com.dotcms.content.elasticsearch.business.ESMappingAPIImpl; import com.dotcms.rendering.velocity.viewtools.content.PaginatedContentList; +import com.dotcms.rest.ContentResource; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotcms.util.ConversionUtils; import com.dotcms.util.TimeMachineUtil; import com.dotmarketing.beans.Identifier; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.web.WebAPILocator; import com.dotmarketing.common.model.ContentletSearch; import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.portlets.calendar.business.RecurrenceUtil; import com.dotmarketing.portlets.contentlet.business.ContentletAPI; import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.contentlet.model.ContentletVersionInfo; import com.dotmarketing.portlets.structure.model.Relationship; +import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; import com.dotmarketing.util.PageMode; import com.dotmarketing.util.PaginatedArrayList; import com.dotmarketing.util.UtilMethods; +import com.dotmarketing.util.WebKeys; +import com.dotmarketing.util.json.JSONObject; import com.liferay.portal.model.User; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -37,8 +49,11 @@ * @since 1.9.3 */ public class ContentUtils { - - private static ContentletAPI conAPI; + + // it is true, even if the pattern is false because the client has to include the depth parameter to activate it + private static final boolean addRelationshipsOnPage = Config.getBooleanProperty("ADD_RELATIONSHIPS_ON_PAGE", true); + + private static ContentletAPI conAPI; public static final ContentUtils INSTANCE = new ContentUtils(); private ContentUtils() { @@ -749,5 +764,68 @@ public static String addDefaultsToQuery(String query, final boolean editOrPrevie } return q; } + + + /** + * Adds the relationships to the contentlet based on the request parameters depth + * @param contentlet + * @param user + * @param mode + * @param languageId + */ + public static void addRelationships(final Contentlet contentlet, final User user, final PageMode mode, final long languageId) { + + final HttpServletRequest request = HttpServletRequestThreadLocal.INSTANCE.getRequest(); + final HttpServletResponse response = HttpServletResponseThreadLocal.INSTANCE.getResponse(); + if (addRelationshipsOnPage && + Objects.nonNull(response) && + Objects.nonNull(request) && + Objects.nonNull(user) && + Objects.nonNull(request.getParameter(WebKeys.HTMLPAGE_DEPTH)) + ) { + + final int depth = ConversionUtils.toInt(request.getParameter(WebKeys.HTMLPAGE_DEPTH), -1); + addRelationships(contentlet, user, mode, languageId, depth, request, response); + } + } + + /** + * Adds the relationships to the contentlet based on depth argument + * @param contentlet + * @param user + * @param mode + * @param languageId + * @param depth + * @param request + * @param response + */ + public static void addRelationships(final Contentlet contentlet, final User user, final PageMode mode, + final long languageId, final int depth, final HttpServletRequest request, + final HttpServletResponse response) { + + if (depth >= 0 && depth <= 3) { + + try { + + final JSONObject jsonWithRelationShips = ContentResource.addRelationshipsToJSON(request, response, + request.getParameter("render"), user, depth, mode.respectAnonPerms, contentlet, + new JSONObject(), null, languageId, mode.showLive, false, + true); + + final HashMap relationshipsMap = DotObjectMapperProvider.getInstance() + .getDefaultObjectMapper().readValue(jsonWithRelationShips.toString(), HashMap.class); + + if (UtilMethods.isSet(relationshipsMap)) { + contentlet.getMap().putAll(relationshipsMap); + } + } catch (Exception e) { + + Logger.error(ContentUtils.class, "Error, contentlet id:" + + contentlet.getIdentifier() + ", msg:" + e.getMessage(), e); + throw new DotRuntimeException(e); + } + } + + } } diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/htmlpageasset/business/render/page/PageViewSerializer.java b/dotCMS/src/main/java/com/dotmarketing/portlets/htmlpageasset/business/render/page/PageViewSerializer.java index 09de64f4ea92..88e3e4da963c 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/htmlpageasset/business/render/page/PageViewSerializer.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/htmlpageasset/business/render/page/PageViewSerializer.java @@ -1,31 +1,35 @@ package com.dotmarketing.portlets.htmlpageasset.business.render.page; -import com.dotcms.contenttype.model.field.Field; -import com.dotcms.contenttype.model.field.KeyValueField; -import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.api.web.HttpServletRequestThreadLocal; +import com.dotcms.api.web.HttpServletResponseThreadLocal; +import com.dotcms.rendering.velocity.viewtools.content.util.ContentUtils; +import com.dotmarketing.business.web.WebAPILocator; import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.contentlet.transform.DotContentletTransformer; import com.dotmarketing.portlets.contentlet.transform.DotTransformerBuilder; -import com.dotmarketing.portlets.contentlet.transform.strategy.KeyValueViewStrategy; +import com.dotmarketing.portlets.languagesmanager.model.Language; import com.dotmarketing.portlets.templates.model.Template; -import com.dotmarketing.util.UtilMethods; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.PageMode; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.ObjectWriter; import com.fasterxml.jackson.databind.SerializerProvider; import com.google.common.collect.ImmutableMap; +import com.liferay.portal.model.User; +import io.vavr.control.Try; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import java.io.CharArrayReader; import java.io.IOException; import java.util.Collections; -import java.util.List; import java.util.Map; import java.util.Objects; import java.util.TreeMap; -import static java.util.Collections.emptyMap; - /** * JsonSerializer of {@link PageView} */ @@ -71,12 +75,33 @@ protected Map getObjectMap(final PageView pageView) { protected void createObjectMapUrlContent(final Contentlet urlContent, final Map pageViewMap) { + Try.run(()->addRelationships(urlContent)) + .onFailure(e -> Logger.error(PageViewSerializer.class, e.getMessage(), e)); + final DotContentletTransformer transformer = new DotTransformerBuilder().urlContentMapTransformer().content(urlContent).build(); final Map urlContentletMap = transformer.toMaps().stream().findFirst().orElse(Collections.EMPTY_MAP); pageViewMap.put("urlContentMap", urlContentletMap); } + private static void addRelationships(final Contentlet urlContent) { + + final HttpServletRequest request = HttpServletRequestThreadLocal.INSTANCE.getRequest(); + final HttpServletResponse response = HttpServletResponseThreadLocal.INSTANCE.getResponse(); + if (null != request && null != response) { + + final User user = WebAPILocator.getUserWebAPI().getUser(request); + final PageMode mode = PageMode.get(request); + final Language language = WebAPILocator.getLanguageWebAPI().getLanguage(request); + + if (null != user && null != mode && null != language) { + + ContentUtils.addRelationships(urlContent, user, mode, language.getId()); + } + } + } + + private Map asPageMap(final PageView pageView) { final Map pageMap = this.asMap(pageView.getPage()); diff --git a/dotCMS/src/main/java/com/dotmarketing/util/WebKeys.java b/dotCMS/src/main/java/com/dotmarketing/util/WebKeys.java index 75505c67b76b..df72f1b39960 100644 --- a/dotCMS/src/main/java/com/dotmarketing/util/WebKeys.java +++ b/dotCMS/src/main/java/com/dotmarketing/util/WebKeys.java @@ -198,6 +198,7 @@ public final class WebKeys { public static final String TEMPLATE_HOSTS = "com.dotmarketing.template.hosts"; + public static final String HTMLPAGE_DEPTH = "depth"; public static final String HTMLPAGE_EDIT = "com.dotmarketing.htmlpages.edit"; public static final String HTMLPAGE_REFERER = "com.dotmarketing.htmlpages.referer"; public static final String HTMLPAGES_VIEW = "com.dotmarketing.htmlpages.view"; From d66030cb82e523f77fb13e2dc013721ad4453e56 Mon Sep 17 00:00:00 2001 From: Jonathan Gamba Date: Thu, 25 May 2023 15:27:12 -0600 Subject: [PATCH 09/63] Issue 24969 handle remaining contentlets without contentlet as json value (#25043) * #24969 Replacing writing into a temporal file to writing to a temporal table. * #24969 Replacing writing into a temporal file to writing to a temporal table. * #24969 Replacing writing into a temporal file to writing to a temporal table. * #24969 Replacing writing into a temporal file to writing to a temporal table. * #24969 Creating a new quartz job to migrate the remaining contentlets * #24969 Handling the whole process in batches. * #24969 Better logging * #24969 Better logging * #24969 Better logging --- .../PopulateContentletAsJSONUtilTest.java | 86 ++- .../json/PopulateContentletAsJSONUtil.java | 500 ++++++++++++------ .../job/PopulateContentletAsJSONJob.java | 84 ++- .../Task230320FixMissingContentletAsJSON.java | 12 +- 4 files changed, 496 insertions(+), 186 deletions(-) diff --git a/dotCMS/src/integration-test/java/com/dotcms/util/content/json/PopulateContentletAsJSONUtilTest.java b/dotCMS/src/integration-test/java/com/dotcms/util/content/json/PopulateContentletAsJSONUtilTest.java index 0fe03481f066..b54db7da7064 100644 --- a/dotCMS/src/integration-test/java/com/dotcms/util/content/json/PopulateContentletAsJSONUtilTest.java +++ b/dotCMS/src/integration-test/java/com/dotcms/util/content/json/PopulateContentletAsJSONUtilTest.java @@ -4,23 +4,26 @@ import com.dotcms.datagen.ContentletDataGen; import com.dotcms.datagen.SiteDataGen; import com.dotcms.datagen.TestDataUtils; +import com.dotcms.datagen.VariantDataGen; import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.variant.model.Variant; import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; import com.dotmarketing.common.db.DotConnect; import com.dotmarketing.db.DbConnectionFactory; import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.util.Logger; import org.apache.felix.framework.OSGIUtil; import org.junit.BeforeClass; import org.junit.Test; -import java.io.IOException; import java.sql.SQLException; import java.util.ArrayList; import java.util.Collection; +import static com.dotcms.util.CollectionsUtils.map; import static org.junit.Assert.*; public class PopulateContentletAsJSONUtilTest extends IntegrationTestBase { @@ -83,13 +86,13 @@ private void createContentletAsJSONColumn() throws DotDataException { /** * Method to test: {@link PopulateContentletAsJSONUtil#populateForAssetSubType(String)} *

- * Given sceneario: We create some hosts, then we drop the contentlet_as_json column, and we add it again to simulate + * Given scenario: We create some hosts, then we drop the contentlet_as_json column, and we add it again to simulate * contlentlets without data in the contentlet_as_json, to finally run the populateForAssetSubType method. *

* Expected result: We should have the contentlet_as_json column populated with the contentlet data in the test hosts. */ @Test - public void Test_populate_host() throws SQLException, DotDataException, IOException { + public void Test_populate_host() throws DotDataException { Collection hosts = new ArrayList<>(); @@ -152,14 +155,14 @@ public void Test_populate_host() throws SQLException, DotDataException, IOExcept /** * Method to test: {@link PopulateContentletAsJSONUtil#populateExcludingAssetSubType(String)} *

- * Given sceneario: We create some contentlets, then we drop the contentlet_as_json column, and we add it again to + * Given scenario: We create some contentlets, then we drop the contentlet_as_json column, and we add it again to * simulate contlentlets without data in the contentlet_as_json, to finally run the populateExcludingAssetSubType method. *

* Expected result: We should have the contentlet_as_json column populated with the contentlet data in the test * contentlets. */ @Test - public void Test_populate_All_excluding_host() throws SQLException, DotDataException, IOException { + public void Test_populate_All_excluding_host() throws DotDataException { Collection contents = new ArrayList<>(); @@ -188,7 +191,7 @@ public void Test_populate_All_excluding_host() throws SQLException, DotDataExcep "WHERE i.asset_subtype <> 'Host' AND asset_type = 'contentlet'") .loadObjectResults(); - // Make sure we have the right number of hosts + // Make sure we have the right number of contentlets assertTrue(results.size() >= 10); results.forEach(rowMap -> { @@ -207,7 +210,76 @@ public void Test_populate_All_excluding_host() throws SQLException, DotDataExcep "WHERE i.asset_subtype <> 'Host' AND asset_type = 'contentlet'") .loadObjectResults(); - // Make sure we have the right number of hosts again + // Make sure we have the right number of contentlets again + assertTrue(results.size() >= 10); + + // This time contentlet_as_json can not be null + results.forEach(rowMap -> { + assertTrue(rowMap.containsKey("contentlet_as_json")); + assertNotNull(rowMap.get("contentlet_as_json")); + }); + } finally { + // Clean up + contents.forEach(ContentletDataGen::destroy); + } + } + + /** + * Method to test: {@link PopulateContentletAsJSONUtil#populateEverything()} + *

+ * Given scenario: We create some contentlets, then we drop the contentlet_as_json column, and we add it again to + * simulate contlentlets without data in the contentlet_as_json, to finally run the populateEverything method. + *

+ * Expected result: We should have the contentlet_as_json column populated with the contentlet data in the test + * contentlets. + */ + @Test + public void Test_populate_everything() throws DotDataException, DotSecurityException { + + Collection contents = new ArrayList<>(); + + try { + + final var defaultLanguageId = APILocator.getLanguageAPI().getDefaultLanguage().getId(); + final Variant variant_1 = new VariantDataGen().nextPersisted(); + + // First we need to create some contentlets + for (int i = 0; i < 10; i++) { + + var contenlet = TestDataUtils.getGenericContentContent(true, defaultLanguageId); + contents.add(contenlet); + + // For this contentlet we need to create multiple versions + for (int j = 0; j < 3; j++) { + var newVersion = ContentletDataGen.createNewVersion(contenlet, variant_1, map()); + ContentletDataGen.publish(newVersion); + } + } + + // We drop the contentlet_as_json column + removeContentletAsJSONColumn(); + + // And we add it again + createContentletAsJSONColumn(); + + // Make sure we have the column but with not content + final DotConnect dotConnect = new DotConnect(); + var results = dotConnect.setSQL("select * from contentlet").loadObjectResults(); + + // Make sure we have the right number of contentlets + assertTrue(results.size() >= 10); + + results.forEach(rowMap -> { + assertTrue(rowMap.containsKey("contentlet_as_json")); + assertNull(rowMap.get("contentlet_as_json")); + }); + + // Now we execute the task + new PopulateContentletAsJSONUtil().populateEverything(); + + results = dotConnect.setSQL("select * from contentlet").loadObjectResults(); + + // Make sure we have the right number of contents again assertTrue(results.size() >= 10); // This time contentlet_as_json can not be null diff --git a/dotCMS/src/main/java/com/dotcms/util/content/json/PopulateContentletAsJSONUtil.java b/dotCMS/src/main/java/com/dotcms/util/content/json/PopulateContentletAsJSONUtil.java index 010dac2301ed..9d781086a10c 100644 --- a/dotCMS/src/main/java/com/dotcms/util/content/json/PopulateContentletAsJSONUtil.java +++ b/dotCMS/src/main/java/com/dotcms/util/content/json/PopulateContentletAsJSONUtil.java @@ -18,22 +18,20 @@ import com.dotmarketing.util.Logger; import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.primitives.Ints; +import io.vavr.Tuple; +import io.vavr.Tuple2; import org.apache.commons.lang3.mutable.MutableInt; import javax.annotation.Nullable; -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileWriter; import java.io.IOException; -import java.nio.file.Files; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; -import java.util.stream.Stream; /** * Utility class to populate the contentlet_as_json column in the contentlet table. @@ -48,7 +46,8 @@ public class PopulateContentletAsJSONUtil { " JOIN identifier i ON i.id = c.identifier" + " JOIN contentlet_version_info cv ON i.id = cv.identifier" + " AND (c.inode = cv.working_inode OR c.inode = cv.live_inode)" + - " WHERE i.asset_subtype = '%s' AND c.contentlet_as_json IS NULL;"; + " WHERE i.asset_subtype = '%s' AND c.contentlet_as_json IS NULL " + + " LIMIT %d;"; // Query to find all the contentlets that have a null contentlet_as_json private final String CONTENTS_WITH_NO_JSON = "select c.* " + @@ -56,7 +55,14 @@ public class PopulateContentletAsJSONUtil { " JOIN identifier i ON i.id = c.identifier" + " JOIN contentlet_version_info cv ON i.id = cv.identifier" + " AND (c.inode = cv.working_inode OR c.inode = cv.live_inode)" + - " WHERE i.asset_type = 'contentlet' AND c.contentlet_as_json IS NULL;"; + " WHERE i.asset_type = 'contentlet' AND c.contentlet_as_json IS NULL " + + " LIMIT %d;"; + + // Query to find all the contentlets that have a null contentlet_as_json for all the versions + private final String CONTENTS_WITH_NO_JSON_ALL_VERSIONS = "SELECT c.* " + + "FROM contentlet c " + + "WHERE c.contentlet_as_json IS NULL " + + "LIMIT %d;"; // Query to find all the contentlets that are NOT of a given asset_subtype and have a null contentlet_as_json private final String CONTENTS_WITH_NO_JSON_AND_EXCLUDE = "select c.* " + @@ -64,139 +70,230 @@ public class PopulateContentletAsJSONUtil { " JOIN identifier i ON i.id = c.identifier" + " JOIN contentlet_version_info cv ON i.id = cv.identifier" + " AND (c.inode = cv.working_inode OR c.inode = cv.live_inode)" + - " WHERE i.asset_subtype <> '%s' AND i.asset_type = 'contentlet' AND c.contentlet_as_json IS NULL;"; + " WHERE i.asset_subtype <> '%s' AND i.asset_type = 'contentlet' AND c.contentlet_as_json IS NULL " + + " LIMIT %d;"; // Query to update the contentlet_as_json column of the contentlet table - private final String UPDATE_CONTENTLET_AS_JSON = "UPDATE contentlet SET contentlet_as_json = ? " + - "WHERE inode = ? AND contentlet_as_json IS NULL"; + private final String UPDATE_CONTENTLET_AS_JSON = + "UPDATE contentlet SET contentlet_as_json = ? " + + "WHERE inode = ? AND contentlet_as_json IS NULL"; + + // Temporal table related queries + private final String CREATE_TEMP_TABLE = "CREATE TEMP TABLE tmp_contentlet_json (" + + " inode varchar(36) not null," + + " json text not null" + + ");"; + + private final String DROP_TEMP_TABLE = "DROP TABLE IF EXISTS tmp_contentlet_json;"; + + private final String INSERT_INTO_TEMP_TABLE = "INSERT INTO tmp_contentlet_json (inode, json) " + + "VALUES (?,?)"; // Cursor related queries private final String DECLARE_CURSOR = "DECLARE missingContentletAsJSONCursor CURSOR FOR %s"; - private final String FETCH_CURSOR_POSTGRES = "FETCH FORWARD %s FROM missingContentletAsJSONCursor"; - private final String FETCH_CURSOR_MSSQL = "FETCH NEXT FROM missingContentletAsJSONCursor"; - private final String OPEN_CURSOR_MSSQL = "OPEN missingContentletAsJSONCursor"; + private final String DECLARE_CURSOR_FOR_TEMPORAL_TABLE = + "DECLARE tmpContentletJSONCursor CURSOR " + + "FOR SELECT inode, json FROM tmp_contentlet_json;"; + private final String FETCH_CURSOR = "FETCH FORWARD %s FROM missingContentletAsJSONCursor"; + private final String FETCH_CURSOR_FOR_TEMPORAL_TABLE = "FETCH FORWARD %s FROM tmpContentletJSONCursor"; private final String CLOSE_CURSOR = "CLOSE missingContentletAsJSONCursor"; - private final String DEALLOCATE_CURSOR_MSSQL = "DEALLOCATE missingContentletAsJSONCursor"; + private final String CLOSE_CURSOR_FOR_TEMPORAL_TABLE = "CLOSE tmpContentletJSONCursor"; - private static final int MAX_UPDATE_BATCH_SIZE = Config.getIntProperty("task.230320.maxupdatebatchsize", 100); - private static final int MAX_CURSOR_FETCH_SIZE = Config.getIntProperty("task.230320.maxcursorfetchsize", 100); + private static final int MAX_BATCH_SIZE = Config.getIntProperty( + "task.populateContentletAsJSON.maxbatchsize", 200); + private static final int MAX_CURSOR_FETCH_SIZE = Config.getIntProperty( + "task.populateContentletAsJSON.maxcursorfetchsize", 200); + private static final int LIMIT_SIZE_FOR_SELECTS = Config.getIntProperty( + "task.populateContentletAsJSON.selectslimitsize", 5000); public PopulateContentletAsJSONUtil() { this.contentletJsonAPI = APILocator.getContentletJsonAPI(); } /** - * Finds all the contentlets that need to be updated with the contentlet_as_json column for a given - * assetSubtype (Content Type). + * Finds all the contentlets that need to be updated with the contentlet_as_json column. + *

+ * All versions will be processed. + */ + public void populateEverything() { + Logger.info(this, "Populate Contentlet as JSON task started for all versions"); + populate(null, null, true); + } + + /** + * Finds all the contentlets that need to be updated with the contentlet_as_json column for a + * given assetSubtype (Content Type). + *

+ * Only working and live versions of the contentlets will be processed. * - * @param assetSubtype Asset subtype (Content Type) to filter the contentlets to process, if null then all - * the contentlets will be processed. + * @param assetSubtype Asset subtype (Content Type) to filter the contentlets to process, if + * null then all the contentlets will be processed. * @throws SQLException - * @throws DotDataException * @throws IOException */ - public void populateForAssetSubType(final String assetSubtype) throws SQLException, DotDataException, IOException { - Logger.info(this, String.format("Populate Contentlet as JSON task started for asset subtype [%s]", assetSubtype)); - populate(assetSubtype, null); + public void populateForAssetSubType(final String assetSubtype) { + Logger.info(this, + String.format("Populate Contentlet as JSON task started for asset subtype [%s]", + assetSubtype)); + populate(assetSubtype, null, false); } /** * Finds all the contentlets that need to be updated with the contentlet_as_json column excluding the contentles * of a given assetSubtype (Content Type). + *

+ * Only working and live versions of the contentlets will be processed. * * @param assetSubtype Asset subtype (Content Type) use to exclude contentlets of that given type from the query. * @throws SQLException * @throws DotDataException * @throws IOException */ - public void populateExcludingAssetSubType(final String assetSubtype) throws SQLException, DotDataException, IOException { + public void populateExcludingAssetSubType(final String assetSubtype) { Logger.info(this, String.format("Populate Contentlet as JSON task started excluding asset subtype [%s]", assetSubtype)); - populate(null, assetSubtype); + populate(null, assetSubtype, false); } /** - * Finds all the contentlets that need to be updated with the contentlet_as_json column for the given - * assetSubtype and excludingAssetSubtype. + * Finds all the contentlets that need to be updated with the contentlet_as_json for the + * given assetSubtype and excludingAssetSubtype. * - * @param assetSubtype Optional asset subtype (Content Type) to filter the contentlets to process, if null then all - * the contentlets will be processed unless the excludingAssetSubtype is provided. - * @param excludingAssetSubtype Optional asset subtype (Content Type) use to exclude contentlets from the query - * @throws IOException + * @param assetSubtype Optional asset subtype (Content Type) to filter the contentlets + * to process. If null, all the contentlets will be processed + * unless the excludingAssetSubtype is provided. + * Applies only for working and live versions. + * @param excludingAssetSubtype Optional asset subtype (Content Type) used to exclude contentlets + * from the query. + * @param allVersions Boolean indicating whether to process all versions of contentlets. */ @LogTime(loggingLevel = "INFO") - private void populate(@Nullable String assetSubtype, @Nullable String excludingAssetSubtype) throws IOException { + private void populate(@Nullable String assetSubtype, + @Nullable String excludingAssetSubtype, + final Boolean allVersions) { - final File populateJSONTaskDataFile = File.createTempFile("rows-task-230320", "tmp"); + final MutableInt totalRecordsAffected = new MutableInt(0); - Logger.debug(this, "File created: " + populateJSONTaskDataFile.getAbsolutePath()); + while (true) { + + CompletableFuture future = CompletableFuture.supplyAsync(() -> + populateWrapper(assetSubtype, excludingAssetSubtype, allVersions, totalRecordsAffected)); - Runnable findAndStore = () -> { try { - // First we need to find all the contentlets to process and write them into a file - findAndStoreToDisk(assetSubtype, excludingAssetSubtype, populateJSONTaskDataFile); - } catch (SQLException | DotDataException | IOException e) { - throw new DotRuntimeException("Error finding, generating JSON representation of Contentlets " + - "and storing them in file.", e); + Boolean foundRecords = future.get(); + if (!foundRecords) { + break; // We don't need to continue processing + } + } catch (InterruptedException | ExecutionException e) { + throw new DotRuntimeException("Error populating contentlets with missing contentlet as JSON", e); } - }; + } + + // Log task completion status + Logger.info(this, "---- Records processed: " + totalRecordsAffected.intValue()); + if (allVersions) { + + Logger.info(this, "Contentlet as JSON migration task DONE for all versions"); + } else if (!Strings.isNullOrEmpty(assetSubtype)) { + + Logger.info(this, String.format("Contentlet as JSON migration task " + + "DONE for assetSubtype: [%s].", assetSubtype)); + } else if (!Strings.isNullOrEmpty(excludingAssetSubtype)) { + + Logger.info(this, String.format("Contentlet as JSON migration task " + + "DONE for excludingAssetSubtype [%s].", excludingAssetSubtype)); + } else { + + Logger.info(this, "Contentlet as JSON migration task DONE"); + } + } + + /** + * Internal method for populating the contentlet_as_json in contentlets. + * Executes the population process for the given assetSubtype and excludingAssetSubtype. + * + * @param assetSubtype Optional asset subtype (Content Type) to filter the contentlets + * to process. If null, all the contentlets will be processed + * unless the excludingAssetSubtype is provided. + * Applies only for working and live versions. + * @param excludingAssetSubtype Optional asset subtype (Content Type) used to exclude contentlets + * from the query. + * @param allVersions Boolean indicating whether to process all versions of contentlets. + * @param totalRecords A MutableInt object to keep track of the total number of affected records. + * @return True if contentlets were found and processed, false otherwise. + */ + @WrapInTransaction + private boolean populateWrapper(@Nullable String assetSubtype, + @Nullable String excludingAssetSubtype, + final Boolean allVersions, + final MutableInt totalRecords) { + + var foundRecords = false; + + try { + // First we need to find all the contentlets to process and write them into a file + foundRecords = findAndStore(assetSubtype, excludingAssetSubtype, allVersions, totalRecords); + } catch (SQLException | DotDataException e) { + throw new DotRuntimeException("Error finding, generating JSON representation of " + + "Contentlets and storing them into a temporal table.", e); + } + + if (foundRecords) { - Runnable processFile = () -> { try { // Now we need to process the file and each record on it - processFile(populateJSONTaskDataFile); - } catch (IOException e) { - throw new DotRuntimeException("Error processing file with the JSON representation of Contentlets to " + - "update.", e); + processRecords(); + } catch (SQLException | DotDataException e) { + throw new DotRuntimeException( + "Error processing records with the JSON representation " + + "of Contentlets to update.", e); } - }; - - CompletableFuture. - runAsync(findAndStore). - thenRunAsync(processFile). - thenAccept(unused -> Logger.info(this, String.format("Contentlet as JSON migration task " + - "DONE for assetSubtype: [%s] / excludingAssetSubtype [%s].", - assetSubtype, excludingAssetSubtype))). - join();// Block the current thread and wait for the CompletableFuture to complete + } + + return foundRecords; } /** - * Searches for all the contentlets of a given asset subtype (Content Type) that have a null contentlet_as_json. This - * method uses a cursor to avoid loading all the contentlets into memory. - * Each found contentlet is written into a file for a later processing. + * Searches for all the contentlets of a given asset subtype (Content Type) that have a null + * contentlet_as_json. This method uses a cursor to avoid loading all the contentlets into + * memory. Each found contentlet is written into a temporal table for a later processing. * - * @param assetSubtype The asset subtype (Content Type) to search for, if null all the contentlets will be searched. - * @param excludingAssetSubtype The asset subtype (Content Type) to exclude in the search, if null no Content Type will be excluded. - * @param populateJSONTaskDataFile The file where the contentlets will be written. - * @throws SQLException - * @throws DotDataException - * @throws IOException + * @param assetSubtype The asset subtype (Content Type) to search for, if null all the + * contentlets will be searched. + * @param excludingAssetSubtype The asset subtype (Content Type) to exclude in the search, if + * null no Content Type will be excluded. + * @param allVersions Boolean indicating whether to process all versions of contentlets. + * @param totalRecords A MutableInt object to keep track of the total number of affected records. */ @WrapInTransaction - private void findAndStoreToDisk(@Nullable final String assetSubtype, - @Nullable final String excludingAssetSubtype, - final File populateJSONTaskDataFile) throws - SQLException, DotDataException, IOException { + private boolean findAndStore(@Nullable final String assetSubtype, + @Nullable final String excludingAssetSubtype, + final Boolean allVersions, + final MutableInt totalRecords + ) throws SQLException, DotDataException { + + final Collection paramsInsert = new ArrayList<>(); + final MutableInt totalInsertAffected = new MutableInt(0); + + var foundData = false; - int recordsProcessed = 0; + Logger.info(this, "Finding records with missing Contentlet as JSON"); final Connection conn = DbConnectionFactory.getConnection(); - try (var fileWriter = new BufferedWriter(new FileWriter(populateJSONTaskDataFile)); - var stmt = conn.createStatement()) { + // Creating the temporal table to hold the contentlets and json to process + createTempTable(conn); + + try (var stmt = conn.createStatement()) { // Declaring the cursor - declareCursor(stmt, assetSubtype, excludingAssetSubtype); + declareCursor(stmt, assetSubtype, excludingAssetSubtype, allVersions); boolean hasRows; do { - if (DbConnectionFactory.isMsSql()) { - stmt.execute(FETCH_CURSOR_MSSQL); - } else { - // Fetching batches of 100 records - stmt.execute(String.format(FETCH_CURSOR_POSTGRES, MAX_CURSOR_FETCH_SIZE)); - } + // Fetching batches of 100 records + stmt.execute(String.format(FETCH_CURSOR, MAX_CURSOR_FETCH_SIZE)); try (ResultSet rs = stmt.getResultSet()) { @@ -205,11 +302,11 @@ private void findAndStoreToDisk(@Nullable final String assetSubtype, dotConnect.fromResultSet(rs); var loadedResults = dotConnect.loadObjectResults(); - recordsProcessed += loadedResults.size(); if (!loadedResults.isEmpty()) { hasRows = true; + foundData = true; var jsonDataArray = Optional.ofNullable(loadedResults) .map(results -> @@ -223,12 +320,13 @@ private void findAndStoreToDisk(@Nullable final String assetSubtype, .orElse(Collections.emptyList()); for (var jsonData : jsonDataArray) { - // Write the json representation of the contentlet into the file - fileWriter.write(jsonData); - fileWriter.newLine(); + // Insert the json representation of the contentlet into the temp table + this.processInsertRecord(jsonData._1(), jsonData._2(), paramsInsert, totalInsertAffected); } - Logger.debug(this, String.format("Added [%s] records for update to temp file", jsonDataArray.size())); + if (!paramsInsert.isEmpty()) { + this.doInsertBatch(paramsInsert, totalInsertAffected); + } } else { hasRows = false; @@ -237,45 +335,80 @@ private void findAndStoreToDisk(@Nullable final String assetSubtype, } while (hasRows); - // Flush the writer to the file - fileWriter.flush(); - // Close the cursor stmt.execute(CLOSE_CURSOR); - if (DbConnectionFactory.isMsSql()) { - stmt.execute(DEALLOCATE_CURSOR_MSSQL); + + if (foundData) { + totalRecords.add(totalInsertAffected); } } - Logger.info(this, "-- Records found to process: " + recordsProcessed); + return foundData; } /** - * This method processes a file that contains all the contentlets that need to be updated with the contentlet_as_json + * This method processes a temporal table that contains all the contentlets that need to be + * updated with the contentlet_as_json * - * @param taskDataFile - * @throws IOException + * @throws SQLException If there is an error in the SQL execution. + * @throws DotDataException If there is an error related to data handling. */ @WrapInTransaction - private void processFile(final File taskDataFile) throws IOException { - - Logger.info(this, "Updating records with missing Contentlet as JSON"); + private void processRecords() throws SQLException, DotDataException { final Collection paramsUpdate = new ArrayList<>(); final MutableInt totalUpdateAffected = new MutableInt(0); - try (final Stream streamLines = Files.lines(taskDataFile.toPath())) { + Logger.info(this, "Updating records with missing Contentlet as JSON"); - streamLines.forEachOrdered(line -> this.processLine(paramsUpdate, line, totalUpdateAffected)); + final Connection conn = DbConnectionFactory.getConnection(); - if (!paramsUpdate.isEmpty()) { - this.doUpdateBatch(paramsUpdate, totalUpdateAffected); - } - } finally { - Logger.info(this, "-- total updates: " + totalUpdateAffected.intValue()); - } + try (var stmt = conn.createStatement()) { + + // Declaring the cursor + stmt.execute(DECLARE_CURSOR_FOR_TEMPORAL_TABLE); + + boolean hasRows; - Logger.info(this, "Updated records with missing Contentlet as JSON"); + do { + + // Fetching batches of 100 records + stmt.execute(String.format(FETCH_CURSOR_FOR_TEMPORAL_TABLE, MAX_CURSOR_FETCH_SIZE)); + + try (ResultSet rs = stmt.getResultSet()) { + + // Now we want to write the found Contentlets into a file for a later processing + var dotConnect = new DotConnect(); + dotConnect.fromResultSet(rs); + + var loadedResults = dotConnect.loadObjectResults(); + + if (!loadedResults.isEmpty()) { + + hasRows = true; + + loadedResults.forEach( + record -> this.processUpdateRecord( + (String) record.get("inode"), + (String) record.get("json"), + paramsUpdate, + totalUpdateAffected) + ); + + if (!paramsUpdate.isEmpty()) { + this.doUpdateBatch(paramsUpdate, totalUpdateAffected); + } + + } else { + hasRows = false; + } + } + + } while (hasRows); + + // Close the cursor + stmt.execute(CLOSE_CURSOR_FOR_TEMPORAL_TABLE); + } } /** @@ -286,76 +419,119 @@ private void processFile(final File taskDataFile) throws IOException { * @param excludingAssetSubtype The asset subtype (Content Type) to exclude in the search, if null no Content Type will be excluded. * @throws SQLException */ - private void declareCursor(final Statement stmt, @Nullable final String assetSubtype, - @Nullable final String excludingAssetSubtype) throws SQLException { + private void declareCursor(final Statement stmt, + @Nullable final String assetSubtype, + @Nullable final String excludingAssetSubtype, + final Boolean allVersions + ) throws SQLException { // Declaring the cursor - if (Strings.isNullOrEmpty(assetSubtype)) { - if (Strings.isNullOrEmpty(excludingAssetSubtype)) { - stmt.execute(String.format(DECLARE_CURSOR, CONTENTS_WITH_NO_JSON)); - } else { - var selectQuery = String.format(CONTENTS_WITH_NO_JSON_AND_EXCLUDE, excludingAssetSubtype); - stmt.execute(String.format(DECLARE_CURSOR, selectQuery)); - } + if (allVersions) { + var selectQuery = String.format(CONTENTS_WITH_NO_JSON_ALL_VERSIONS, LIMIT_SIZE_FOR_SELECTS); + stmt.execute(String.format(DECLARE_CURSOR, selectQuery)); + } else if (!Strings.isNullOrEmpty(assetSubtype)) { + var selectQuery = String.format(SUBTYPE_WITH_NO_JSON, assetSubtype, LIMIT_SIZE_FOR_SELECTS); + stmt.execute(String.format(DECLARE_CURSOR, selectQuery)); + } else if (!Strings.isNullOrEmpty(excludingAssetSubtype)) { + var selectQuery = String.format(CONTENTS_WITH_NO_JSON_AND_EXCLUDE, excludingAssetSubtype, LIMIT_SIZE_FOR_SELECTS); + stmt.execute(String.format(DECLARE_CURSOR, selectQuery)); } else { - var selectQuery = String.format(SUBTYPE_WITH_NO_JSON, assetSubtype); + var selectQuery = String.format(CONTENTS_WITH_NO_JSON, LIMIT_SIZE_FOR_SELECTS); stmt.execute(String.format(DECLARE_CURSOR, selectQuery)); } + } - if (DbConnectionFactory.isMsSql()) { - stmt.execute(OPEN_CURSOR_MSSQL); + /** + * Processes a record by preparing the parameters for a batch insert into the temporal tables. + * + * @param inode The inode of the contentlet. + * @param json The JSON representation of the contentlet. + * @param paramsInsert A collection of Params objects used for batch inserts. The Params + * object contains the contentlet inode and JSON. + * @param totalInsertAffected A MutableInt object to keep track of the total number of affected + * rows in batch inserts. + */ + private void processInsertRecord( + final String inode, + final String json, + final Collection paramsInsert, + final MutableInt totalInsertAffected + ) { + + paramsInsert.add(new Params(inode, json)); + + // Execute the batch for the inserts if we have reached the max batch size + if (paramsInsert.size() >= MAX_BATCH_SIZE) { + this.doInsertBatch(paramsInsert, totalInsertAffected); } } /** - * Processes the given line preparing the params for the batch update. + * Processes a record by preparing the parameters for a batch update. * - * @param line Line with the json representation of the contentlet. - * @throws JsonProcessingException + * @param inode The inode of the contentlet. + * @param json The JSON representation of the contentlet. + * @param paramsUpdate A collection of Params objects used for batch updates. The Params + * object contains the contentlet JSON and inode. + * @param totalUpdateAffected A MutableInt object to keep track of the total number of affected + * rows in batch updates. + * @throws JsonProcessingException If there is an error while processing the JSON. */ - private void processLine(final Collection paramsUpdate, final String line, - final MutableInt totalInsertAffected) { - - try { - var contentlet = ContentletJsonHelper.INSTANCE.get().immutableFromJson(line); - - final Object contentletAsJSON; - if (DbConnectionFactory.isPostgres()) { - contentletAsJSON = new DotPGobject.Builder() - .jsonValue(line) - .build(); - } else { - contentletAsJSON = line; - } + private void processUpdateRecord( + final String inode, + final String json, + final Collection paramsUpdate, + final MutableInt totalUpdateAffected + ) { + + final Object contentletAsJSON; + if (DbConnectionFactory.isPostgres()) { + contentletAsJSON = new DotPGobject.Builder() + .jsonValue(json) + .build(); + } else { + contentletAsJSON = json; + } - paramsUpdate.add(new Params(contentletAsJSON, contentlet.inode())); + paramsUpdate.add(new Params(contentletAsJSON, inode)); - // Execute the batch for the updates if we have reached the max batch size - if (paramsUpdate.size() >= MAX_UPDATE_BATCH_SIZE) { - this.doUpdateBatch(paramsUpdate, totalInsertAffected); - } - } catch (JsonProcessingException e) { - throw new DotRuntimeException("Error processing line", e); + // Execute the batch for the updates if we have reached the max batch size + if (paramsUpdate.size() >= MAX_BATCH_SIZE) { + this.doUpdateBatch(paramsUpdate, totalUpdateAffected); } } /** - * Converts the contentlet to an immutable contentlet and then builds a json representation of it. + * Creates a temporary table in the database. + * + * @throws DotDataException If there is an error related to data handling. + * @throws SQLException If there is an error in the SQL execution. + */ + private void createTempTable(final Connection conn) throws DotDataException, SQLException { + + new DotConnect().setSQL(DROP_TEMP_TABLE).loadResult(conn); + new DotConnect().setSQL(CREATE_TEMP_TABLE).loadResult(conn); + } + + /** + * Converts the given {@link Contentlet} to an immutable contentlet and then builds a json + * representation of it. * - * @param contentlet - * @return The Contentlet with the json representation attached to it. + * @param contentlet The contentlet to convert. + * @return A tuple containing the inode of the contentlet and its JSON representation. */ - private String toJSON(Contentlet contentlet) { + private Tuple2 toJSON(Contentlet contentlet) { try { // Converts the given contentlet to an immutable contentlet and then builds a json representation of it. - var contentletAsJSON = ContentletJsonHelper.INSTANCE.get().writeAsString(this.contentletJsonAPI.toImmutable(contentlet)); + var contentletAsJSON = ContentletJsonHelper.INSTANCE.get() + .writeAsString(this.contentletJsonAPI.toImmutable(contentlet)); - // I need to have the JSON in a single line, so I can write it into a file - return contentletAsJSON.replaceAll("[\\t\\n\\r]", ""); + return Tuple.of(contentlet.getInode(), contentletAsJSON); } catch (JsonProcessingException e) { - throw new DotRuntimeException(String.format("Error creating the JSON representation of Contentlet - " + - "inode [%s]", contentlet.getInode()), e); + throw new DotRuntimeException( + String.format("Error creating the JSON representation of Contentlet - " + + "inode [%s]", contentlet.getInode()), e); } } @@ -371,7 +547,7 @@ private void doUpdateBatch(final Collection paramsUpdate, final MutableI paramsUpdate)); final int rowsAffected = batchResult.stream().reduce(0, Integer::sum); - Logger.info(this, "Batch rows to populate contentlet_as_json column, updated: " + rowsAffected + " rows"); + Logger.info(this, "-- Batch rows to populate contentlet_as_json column, updated: " + rowsAffected + " rows"); totalUpdateAffected.add(rowsAffected); } catch (DotDataException e) { Logger.error(this, "Couldn't update these rows: " + paramsUpdate); @@ -381,6 +557,28 @@ private void doUpdateBatch(final Collection paramsUpdate, final MutableI } } + /** + * Executes the batch of inserts to populate the temporal tmp_contentlet_json table. + */ + private void doInsertBatch(final Collection paramsInsert, final MutableInt totalInsertAffected) { + + try { + final List batchResult = + Ints.asList(new DotConnect().executeBatch( + INSERT_INTO_TEMP_TABLE, + paramsInsert)); + + final int rowsAffected = batchResult.stream().reduce(0, Integer::sum); + Logger.info(this, "-- Batch rows to populate temporal table, inserted: " + rowsAffected + " rows"); + totalInsertAffected.add(rowsAffected); + } catch (DotDataException e) { + Logger.error(this, "Couldn't insert these rows: " + paramsInsert); + Logger.error(this, e.getMessage(), e); + } finally { + paramsInsert.clear(); + } + } + /** * This basically tells Weather or not we support saving content as json and if we have not turned it off. */ diff --git a/dotCMS/src/main/java/com/dotmarketing/quartz/job/PopulateContentletAsJSONJob.java b/dotCMS/src/main/java/com/dotmarketing/quartz/job/PopulateContentletAsJSONJob.java index 1fb1b2380e60..e3a35bf20cd9 100644 --- a/dotCMS/src/main/java/com/dotmarketing/quartz/job/PopulateContentletAsJSONJob.java +++ b/dotCMS/src/main/java/com/dotmarketing/quartz/job/PopulateContentletAsJSONJob.java @@ -1,7 +1,6 @@ package com.dotmarketing.quartz.job; import com.dotcms.util.content.json.PopulateContentletAsJSONUtil; -import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.quartz.DotStatefulJob; import com.dotmarketing.quartz.QuartzUtils; @@ -12,40 +11,54 @@ import io.vavr.control.Try; import org.quartz.*; -import java.io.IOException; -import java.sql.SQLException; - /** * Job created to populate in the Contentlet table missing contentlet_as_json columns. */ public class PopulateContentletAsJSONJob extends DotStatefulJob { - private static final String EXCLUDING_ASSET_SUB_TYPE = "excludingAssetSubType"; + private static final String JOB_DATA_EXCLUDING_ASSET_SUB_TYPE = "excludingAssetSubType"; + private static final String JOB_DATA_ALL_CONTENTS_ALL_VERSIONS = "allContentsAllVersions"; private static final String CONFIG_PROPERTY_HOURS_INTERVAL = "populateContentletAsJSONJob.hours.interval"; private static final Lazy HOURS_INTERVAL = Lazy.of(() -> Config.getIntProperty( CONFIG_PROPERTY_HOURS_INTERVAL, 4)); + /** + * Executes the job logic, which first involves populating the working and live versions of contentlets as JSON. + * After the first population process finishes, it registers another job to migrate all the remaining contentlets. + * + * @param jobContext The JobExecutionContext object containing the job execution context. + * @throws JobExecutionException if there is an error executing the job. + */ @Override public void run(JobExecutionContext jobContext) throws JobExecutionException { final var jobDataMap = jobContext.getJobDetail().getJobDataMap(); - final String excludingAssetSubType; - if (jobDataMap.containsKey(EXCLUDING_ASSET_SUB_TYPE)) { - excludingAssetSubType = (String) jobDataMap.get(EXCLUDING_ASSET_SUB_TYPE); - } else { - excludingAssetSubType = null; + String excludingAssetSubType = null; + Boolean forAllContentsAllVersions = null; + + if (jobDataMap.containsKey(JOB_DATA_ALL_CONTENTS_ALL_VERSIONS)) { + forAllContentsAllVersions = (Boolean) jobDataMap.get(JOB_DATA_ALL_CONTENTS_ALL_VERSIONS); + } else if (jobDataMap.containsKey(JOB_DATA_EXCLUDING_ASSET_SUB_TYPE)) { + excludingAssetSubType = (String) jobDataMap.get(JOB_DATA_EXCLUDING_ASSET_SUB_TYPE); } try { // Executing the populate contentlet as JSON logic - new PopulateContentletAsJSONUtil().populateExcludingAssetSubType(excludingAssetSubType); + if (forAllContentsAllVersions != null && forAllContentsAllVersions) { + new PopulateContentletAsJSONUtil().populateEverything(); + } else { + new PopulateContentletAsJSONUtil().populateExcludingAssetSubType(excludingAssetSubType); + } // Removing the job if everything went well removeJob(); - } catch (SQLException | DotDataException | IOException e) { - Logger.error(this, "Error executing Contentlet as JSON population job", e); - throw new DotRuntimeException(e); + + // If the populate for working and live versions is done we fire the job to populate the missing contentlets + if (forAllContentsAllVersions == null) { + fireJobAllContentsAllVersions(); + } + } catch (SchedulerException e) { Logger.error(this, String.format("Unable to remove [%s] job", PopulateContentletAsJSONJob.class.getName()), e); @@ -54,10 +67,36 @@ public void run(JobExecutionContext jobContext) throws JobExecutionException { } /** - * Fires the job to populate the missing contentlet_as_json columns. + * Fires the job to populate the missing contentlet_as_json columns for all contents and all versions. + */ + private static void fireJobAllContentsAllVersions() { + + final var jobDataMap = new JobDataMap(); + jobDataMap.put(JOB_DATA_ALL_CONTENTS_ALL_VERSIONS, true); + + fireJob(jobDataMap); + } + + /** + * Fires the job to populate the missing contentlet_as_json columns excluding a specific asset sub-type. + * + * @param excludingAssetSubType The asset sub-type to exclude. */ public static void fireJob(final String excludingAssetSubType) { + final var jobDataMap = new JobDataMap(); + jobDataMap.put(JOB_DATA_EXCLUDING_ASSET_SUB_TYPE, excludingAssetSubType); + + fireJob(jobDataMap); + } + + /** + * Fires the job to populate the missing contentlet_as_json columns. + * + * @param jobDataMap The JobDataMap containing the job data. + */ + private static void fireJob(final JobDataMap jobDataMap) { + final var jobName = getJobName(); final var groupName = getJobGroupName(); @@ -77,9 +116,6 @@ public static void fireJob(final String excludingAssetSubType) { } // Creating the job - final var jobDataMap = new JobDataMap(); - jobDataMap.put(EXCLUDING_ASSET_SUB_TYPE, excludingAssetSubType); - jobDetail = new JobDetail( jobName, groupName, PopulateContentletAsJSONJob.class ); @@ -107,17 +143,29 @@ public static void fireJob(final String excludingAssetSubType) { /** * Removes the PopulateContentletAsJSONJob from the scheduler + * + * @throws SchedulerException if there is an error removing the job. */ @VisibleForTesting static void removeJob() throws SchedulerException { QuartzUtils.removeJob(getJobName(), getJobGroupName()); } + /** + * Gets the job name. + * + * @return The job name. + */ @VisibleForTesting static String getJobName() { return PopulateContentletAsJSONJob.class.getSimpleName(); } + /** + * Gets the job group name. + * + * @return The job group name. + */ @VisibleForTesting static String getJobGroupName() { return getJobName() + "_Group"; diff --git a/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task230320FixMissingContentletAsJSON.java b/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task230320FixMissingContentletAsJSON.java index 659796236bd1..4df8273f3d60 100644 --- a/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task230320FixMissingContentletAsJSON.java +++ b/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task230320FixMissingContentletAsJSON.java @@ -7,9 +7,6 @@ import com.dotmarketing.startup.StartupTask; import com.dotmarketing.util.Logger; -import java.io.IOException; -import java.sql.SQLException; - public class Task230320FixMissingContentletAsJSON implements StartupTask { @Override @@ -26,13 +23,8 @@ public void executeUpgrade() throws DotDataException, DotRuntimeException { // will execute a background stateful quartz job PopulateContentletAsJSONJob.fireJob("Host"); - try { - // Now we populate the contentlet as JSON for Hosts, this will execute in the same thread - new PopulateContentletAsJSONUtil().populateForAssetSubType("Host"); - } catch (SQLException | IOException e) { - Logger.error(this, "Error populating Contentlet as JSON population column for Hosts", e); - throw new DotDataException(e.getMessage(), e); - } + // Now we populate the contentlet as JSON for Hosts, this will execute in the same thread + new PopulateContentletAsJSONUtil().populateForAssetSubType("Host"); } } From 014bb2375c3e336b7064c60436318dfff3ef1688 Mon Sep 17 00:00:00 2001 From: Jose Castro Date: Thu, 25 May 2023 18:37:38 -0600 Subject: [PATCH 10/63] #24908 : Avoid showing the `System Host` in the result list in the `Sites` portlet. (#25045) * #24908 : Avoid showing the `System Host` in the result list in the `Sites` portlet. * Adding Integration Test to check results when the System Host is required/not required to be in the results. --- .../contentlet/business/HostAPITest.java | 67 +++++++++++++------ .../dotmarketing/business/ajax/HostAjax.java | 17 +++-- .../portlets/contentlet/business/HostAPI.java | 22 +++++- .../contentlet/business/HostAPIImpl.java | 50 +++++++++++--- 4 files changed, 119 insertions(+), 37 deletions(-) diff --git a/dotCMS/src/integration-test/java/com/dotmarketing/portlets/contentlet/business/HostAPITest.java b/dotCMS/src/integration-test/java/com/dotmarketing/portlets/contentlet/business/HostAPITest.java index 6c99c17c50b5..3db3fc98dfec 100644 --- a/dotCMS/src/integration-test/java/com/dotmarketing/portlets/contentlet/business/HostAPITest.java +++ b/dotCMS/src/integration-test/java/com/dotmarketing/portlets/contentlet/business/HostAPITest.java @@ -46,20 +46,20 @@ import com.dotmarketing.util.PaginatedArrayList; import com.dotmarketing.util.UUIDGenerator; import com.liferay.portal.model.User; -import java.util.HashMap; -import java.util.Map; import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Test; import org.mockito.Mockito; import org.quartz.JobDetail; import org.quartz.JobExecutionContext; +import org.quartz.Trigger; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; -import org.quartz.Trigger; import static com.dotmarketing.portlets.templates.model.Template.ANONYMOUS_PREFIX; import static org.junit.Assert.assertEquals; @@ -505,7 +505,7 @@ private void unpublishHost(final Host host, final User user) throws DotHibernate try { HibernateUtil.startTransaction(); - host.setIndexPolicy(IndexPolicy.FORCE); + host.setIndexPolicy(IndexPolicy.WAIT_FOR); APILocator.getHostAPI().unpublish(host, user, false); HibernateUtil.closeAndCommitTransaction(); } catch (Exception e) { @@ -520,10 +520,12 @@ private void unpublishHost(final Host host, final User user) throws DotHibernate * Archives a given host */ private void archiveHost(final Host host, final User user) throws DotHibernateException { - + if (null == host || null == user) { + return; + } try { HibernateUtil.startTransaction(); - host.setIndexPolicy(IndexPolicy.FORCE); + host.setIndexPolicy(IndexPolicy.WAIT_FOR); APILocator.getHostAPI().archive(host, user, false); HibernateUtil.closeAndCommitTransaction(); } catch (Exception e) { @@ -539,11 +541,13 @@ private void archiveHost(final Host host, final User user) throws DotHibernateEx */ private void deleteHost(final Host host, final User user) throws DotHibernateException, InterruptedException, ExecutionException { - + if (null == host || null == user) { + return; + } Optional> hostDeleteResult = Optional.empty(); try { HibernateUtil.startTransaction(); - host.setIndexPolicy(IndexPolicy.FORCE); + host.setIndexPolicy(IndexPolicy.WAIT_FOR); hostDeleteResult = APILocator.getHostAPI().delete(host, user, false, true); HibernateUtil.closeAndCommitTransaction(); } catch (Exception e) { @@ -1219,14 +1223,15 @@ public void retrieveHostsPerTagStorage() throws DotHibernateException, Execution } /** - * Method to test: {@link HostAPI#searchByStopped(String, boolean, boolean, int, int, User, boolean)} - * - * Given Scenario: Create a test Site and stop it. Then, create another Site, then stop it and archive it. Finally, - * compare the total count of stopped Sites. - * - * Expected Result: When compared to the initial stopped Sites count, after stopping the new Site, the count must - * increase by one. After stopping and archivnig the second Site, the count must be increased by two because - * archived Sites are also considered "stopped Sites". + *

    + *
  • Method to test: + * {@link HostAPI#searchByStopped(String, boolean, boolean, int, int, User, boolean)}
  • + *
  • Given Scenario: Create a test Site and stop it. Then, create another Site, then stop it and + * archive it. Finally, compare the total count of stopped Sites.
  • + *
  • Expected Result: When compared to the initial stopped Sites count, after stopping the new Site, + * the count must be increased by one. After stopping AND archiving the second Site, the total count difference + * must be 2 because archived Sites are also considered "stopped Sites" as well.
  • + *
*/ @Test public void searchByStopped() throws DotHibernateException, ExecutionException, InterruptedException { @@ -1242,7 +1247,7 @@ public void searchByStopped() throws DotHibernateException, ExecutionException, final PaginatedArrayList stoppedSites = hostAPI.searchByStopped(null, true, false, 0, 0, systemUser, false); testSite = siteDataGen.nextPersisted(); - unpublishHost(testSite, systemUser); + this.unpublishHost(testSite, systemUser); final PaginatedArrayList updatedStoppedSites = hostAPI.searchByStopped(null, true, false, 0, 0, systemUser, false); @@ -1253,8 +1258,8 @@ public void searchByStopped() throws DotHibernateException, ExecutionException, // Test data generation #2 siteDataGen = new SiteDataGen(); testSiteTwo = siteDataGen.nextPersisted(); - unpublishHost(testSiteTwo, systemUser); - archiveHost(testSiteTwo, systemUser); + this.unpublishHost(testSiteTwo, systemUser); + this.archiveHost(testSiteTwo, systemUser); final PaginatedArrayList updatedStoppedAndArchivedSites = hostAPI.searchByStopped(null, true, false, 0, 0, systemUser, false); @@ -1366,4 +1371,28 @@ public void count() throws DotHibernateException, ExecutionException, Interrupte } } + /** + *
    + *
  • Method to test: {@link HostAPI#findAllFromDB(User, boolean, boolean)}
  • + *
  • Given Scenario: Create a test Site and call the {@findAllFromDB} method that allows you to include + * or exclude the System Host.
  • + *
  • Expected Result: When calling the method with the {@code includeSystemHost} parameter as + * {@code true}, the System Host must be included. Otherwise, it must be left out.
  • + *
+ */ + @Test + public void findAllFromDB() throws DotDataException, DotSecurityException { + // Initialization + final HostAPI hostAPI = APILocator.getHostAPI(); + final User systemUser = APILocator.systemUser(); + + // Test data generation + final List siteList = hostAPI.findAllFromDB(systemUser, false, false); + final List siteListWithSystemHost = hostAPI.findAllFromDB(systemUser, true, false); + + // Assertions + assertEquals("The size difference between both Site lists MUST be 1", 1, + siteListWithSystemHost.size() - siteList.size()); + } + } diff --git a/dotCMS/src/main/java/com/dotmarketing/business/ajax/HostAjax.java b/dotCMS/src/main/java/com/dotmarketing/business/ajax/HostAjax.java index 2dd2a30c83f8..e6c5a5bdc1c9 100644 --- a/dotCMS/src/main/java/com/dotmarketing/business/ajax/HostAjax.java +++ b/dotCMS/src/main/java/com/dotmarketing/business/ajax/HostAjax.java @@ -48,9 +48,9 @@ */ public class HostAjax { - private HostAPI hostAPI = APILocator.getHostAPI(); - private UserWebAPI userWebAPI = WebAPILocator.getUserWebAPI(); - private PermissionAPI permissionAPI = APILocator.getPermissionAPI(); + private final HostAPI hostAPI = APILocator.getHostAPI(); + private final UserWebAPI userWebAPI = WebAPILocator.getUserWebAPI(); + private final PermissionAPI permissionAPI = APILocator.getPermissionAPI(); public Map findHostsForDataStore(String filter, boolean showArchived, int offset, int count) throws PortalException, SystemException, DotDataException, DotSecurityException { return findHostsForDataStore(filter, showArchived, offset, count, Boolean.FALSE); @@ -103,8 +103,8 @@ public Map findHostsForDataStore(String filter, boolean showArch /** * Returns the complete list of Sites that exist in a dotCMS instance based on specific case-insensitive filtering - * criteria. When filtering results, only listed text-type fields can be searched, which are basically the two - * columns displayed in the UI: {@code Site Key}, and {@code Aliases}. + * criteria, and excluding the System Host. When filtering results, only listed text-type fields can be + * searched, which are basically the two columns displayed in the UI: {@code Site Key}, and {@code Aliases}. * * @param filter Search term used to filter results. * @param showArchived If archived Sites must be returned, set to {@code true}. Otherwise, set to {@code false}. @@ -121,16 +121,15 @@ public Map findHostsForDataStore(String filter, boolean showArch * @throws SystemException An application error has occurred. */ public Map findHostsPaginated(final String filter, final boolean showArchived, int offset, int count) throws DotDataException, DotSecurityException, PortalException, SystemException { - final User user = this.getLoggedInUser(); final boolean respectFrontend = !this.userWebAPI.isLoggedToBackend(this.getHttpRequest()); - final List sitesFromDb = this.hostAPI.findAllFromDB(user, respectFrontend); + final List sitesFromDb = this.hostAPI.findAllFromDB(user, false, respectFrontend); final List fields = FieldsCache.getFieldsByStructureVariableName(Host.HOST_VELOCITY_VAR_NAME); final List searchableFields = fields.stream().filter(field -> field.isListed() && field .getFieldType().startsWith("text")).collect(Collectors.toList()); List> siteList = new ArrayList<>(sitesFromDb.size()); - Collections.sort(sitesFromDb, new HostNameComparator()); + sitesFromDb.sort(new HostNameComparator()); for (final Host site : sitesFromDb) { boolean addToResultList = false; if (showArchived || !site.isArchived()) { @@ -174,7 +173,7 @@ public Map findHostsPaginated(final String filter, final boolean } } - final List> fieldMapList = fields.stream().map(field -> field.getMap()).collect(Collectors.toList()); + final List> fieldMapList = fields.stream().map(Field::getMap).collect(Collectors.toList()); final Structure siteContentType = CacheLocator.getContentTypeCache().getStructureByVelocityVarName(Host.HOST_VELOCITY_VAR_NAME); return CollectionsUtils.map( "total", totalResults, diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/HostAPI.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/HostAPI.java index 527d5da2d6da..73a7bd5a8835 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/HostAPI.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/HostAPI.java @@ -200,7 +200,8 @@ Host find(Contentlet contentlet, List findAll(User user, int limit, int offset, String sortBy, boolean respectFrontendRoles) throws DotDataException, DotSecurityException; /** - * Returns the complete list of Sites in your dotCMS repository retrieved directly from the data source. + * Returns the complete list of Sites in your dotCMS repository retrieved directly from the data source, + * including the System Host. * * @param user The {@link User} that is calling this method. * @param respectFrontendRoles If the User's front-end roles need to be taken into account in order to perform this @@ -214,6 +215,23 @@ Host find(Contentlet contentlet, */ List findAllFromDB(final User user, final boolean respectFrontendRoles) throws DotDataException, DotSecurityException; + /** + * Returns the complete list of Sites in your dotCMS repository retrieved directly from the data source. This + * method allows you to EXCLUDE the System Host from the result list. + * + * @param user The {@link User} that is calling this method. + * @param includeSystemHost If the System Host must be included in the results, set to {@code true}. + * @param respectFrontendRoles If the User's front-end roles need to be taken into account in order to perform this + * operation, set to {@code true}. Otherwise, set to {@code false}. + * + * @return The list of {@link Host} objects. + * + * @throws DotDataException An error occurred when accessing the data source. + * @throws DotSecurityException The specified User does not have the required permissions to perform this + * operation. + */ + List findAllFromDB(final User user, final boolean includeSystemHost, final boolean respectFrontendRoles) throws DotDataException, DotSecurityException; + /** * Returns the complete list of Sites in your dotCMS repository retrieved from the cache. If no data is currently * available, it will be retrieved from the data source, and put into the respective cache region. @@ -461,6 +479,8 @@ PaginatedArrayList search(final String filter, final boolean showArchived, *
  • {@code showStopped}: Determines if stopped Sites are returned in the result set.
  • *
  • {@code showSystemHost}: Determines whether the System Host must be returned or not.
  • * + * It's very important to note that, if the {@code showStopped} parameter is set to {@code true}, then all archived + * Sites will also be returned because they're considered stopped Sites. * * @param filter The initial part or full name of the Site you need to look up. * @param showStopped If stopped Sites must be returned, set to {@code true}. Otherwise, se to {@code diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/HostAPIImpl.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/HostAPIImpl.java index 02b6f6c5a8b9..5fefa102da7e 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/HostAPIImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/HostAPIImpl.java @@ -60,7 +60,7 @@ */ public class HostAPIImpl implements HostAPI, Flushable { - private HostCache hostCache = CacheLocator.getHostCache(); + private final HostCache hostCache = CacheLocator.getHostCache(); private Host systemHost; private final SystemEventsAPI systemEventsAPI; private HostFactory hostFactory; @@ -320,7 +320,13 @@ public List findAll(final User user, final int limit, final int offset, fi @Override public List findAllFromDB(final User user, final boolean respectFrontendRoles) throws DotDataException, DotSecurityException { - return this.findPaginatedSitesFromDB(user, 0, 0, null, respectFrontendRoles); + return this.findAllFromDB(user, true, respectFrontendRoles); + } + + @Override + public List findAllFromDB(final User user, final boolean includeSystemHost, + final boolean respectFrontendRoles) throws DotDataException, DotSecurityException { + return this.findPaginatedSitesFromDB(user, 0, 0, null, includeSystemHost, respectFrontendRoles); } /** @@ -345,15 +351,43 @@ public List findAllFromDB(final User user, final boolean respectFrontendRo @CloseDBIfOpened private List findPaginatedSitesFromDB(final User user, final int limit, final int offset, final String sortBy, final boolean respectFrontendRoles) throws DotDataException, DotSecurityException { - final List siteList = this.getHostFactory().findAll(limit, offset, sortBy); + return this.findPaginatedSitesFromDB(user, limit, offset, sortBy, true, respectFrontendRoles); + } + + /** + * Returns an optionally paginated list of all Sites in your dotCMS content repository. This method allows you to + * EXCLUDE the System Host from the result list. + * + * @param user The {@link User} performing this action. + * @param limit Limit of results returned in the response, for pagination purposes. If set equal or + * lower than zero, this parameter will be ignored. + * @param offset Expected offset of results in the response, for pagination purposes. If set equal or + * lower than zero, this parameter will be ignored. + * @param sortBy Optional sorting criterion, as specified by the available columns in: {@link + * com.dotmarketing.common.util.SQLUtil#ORDERBY_WHITELIST} + * @param includeSystemHost If the System Host should be included in the result list, set to {@code true}. + * @param respectFrontendRoles If the User's front-end roles need to be taken into account in order to perform this + * operation, set to {@code true}. Otherwise, set to {@code false}. + * + * @return The list of {@link Host} objects. + * + * @throws DotDataException An error occurred when accessing the data source. + * @throws DotSecurityException The specified User does not have the required permissions to perform this + * operation. + */ + private List findPaginatedSitesFromDB(final User user, final int limit, final int offset, + final String sortBy, final boolean includeSystemHost, + final boolean respectFrontendRoles) throws DotDataException, DotSecurityException { + final List siteList = this.getHostFactory().findAll(limit, offset, sortBy, includeSystemHost); if (null != siteList && !siteList.isEmpty()) { return siteList.stream().filter(site -> { try { - checkSitePermission(user, respectFrontendRoles, site); + this.checkSitePermission(user, respectFrontendRoles, site); return true; } catch (final DotDataException | DotSecurityException e) { - Logger.warn(this, String.format("An error occurred when checking permissions from User '%s' on " + - "Site '%s': %s", user.getUserId(), site.getInode(), e.getMessage())); + Logger.warn(this, + String.format("An error occurred when checking permissions from User '%s' on " + "Site " + + "'%s': %s", user.getUserId(), site.getInode(), e.getMessage())); } return false; }).collect(Collectors.toList()); @@ -826,9 +860,9 @@ public PaginatedArrayList search(final String filter, final boolean showAr } } if (showStopped && !showArchived) { - // Return stopped Sites + // Return stopped Sites, which include archived Sites as well siteListOpt = this.getHostFactory() - .findStoppedSites(filter, false, limit, offset, showSystemHost, user, respectFrontendRoles); + .findStoppedSites(filter, true, limit, offset, showSystemHost, user, respectFrontendRoles); if (siteListOpt.isPresent()) { return convertToSitePaginatedList(siteListOpt.get()); } From 1891bed6dac3ac58a86b053766f591d1e1cc3f42 Mon Sep 17 00:00:00 2001 From: Freddy Rodriguez Date: Fri, 26 May 2023 08:24:16 -0600 Subject: [PATCH 11/63] Renaming integration test (#25042) * Renaming IntegrationTest class * Updating MainSuite --- .../src/integration-test/java/com/dotcms/MainSuite.java | 8 ++++---- ...APIImpIT.java => ExperimentAPIImpIntegrationTest.java} | 8 +------- ...IT.java => ExperimentAnalyzerUtilIntegrationTest.java} | 2 +- ...a => ExperimentResultQueryFactoryIntegrationTest.java} | 4 +--- ... => ExperimentResultsQueryFactoryIntegrationTest.java} | 2 +- ...plIT.java => ExperimentWebAPIImplIntegrationTest.java} | 7 +------ 6 files changed, 9 insertions(+), 22 deletions(-) rename dotCMS/src/integration-test/java/com/dotcms/experiments/business/{ExperimentAPIImpIT.java => ExperimentAPIImpIntegrationTest.java} (99%) rename dotCMS/src/integration-test/java/com/dotcms/experiments/business/{ExperimentAnalyzerUtilIT.java => ExperimentAnalyzerUtilIntegrationTest.java} (99%) rename dotCMS/src/integration-test/java/com/dotcms/experiments/business/{ExperimentResultQueryFactoryIT.java => ExperimentResultQueryFactoryIntegrationTest.java} (97%) rename dotCMS/src/integration-test/java/com/dotcms/experiments/business/{ExperimentResultsQueryFactoryIT.java => ExperimentResultsQueryFactoryIntegrationTest.java} (99%) rename dotCMS/src/integration-test/java/com/dotcms/experiments/business/web/{ExperimentWebAPIImplIT.java => ExperimentWebAPIImplIntegrationTest.java} (99%) diff --git a/dotCMS/src/integration-test/java/com/dotcms/MainSuite.java b/dotCMS/src/integration-test/java/com/dotcms/MainSuite.java index 4e79551731ea..eaca2a51316c 100644 --- a/dotCMS/src/integration-test/java/com/dotcms/MainSuite.java +++ b/dotCMS/src/integration-test/java/com/dotcms/MainSuite.java @@ -45,8 +45,8 @@ import com.dotcms.enterprise.publishing.staticpublishing.LanguageFolderTest; import com.dotcms.enterprise.publishing.staticpublishing.StaticPublisherIntegrationTest; import com.dotcms.enterprise.rules.RulesAPIImplIntegrationTest; -import com.dotcms.experiments.business.ExperimentAPIImpIT; -import com.dotcms.experiments.business.web.ExperimentWebAPIImplIT; +import com.dotcms.experiments.business.ExperimentAPIImpIntegrationTest; +import com.dotcms.experiments.business.web.ExperimentWebAPIImplIntegrationTest; import com.dotcms.filters.interceptor.meta.MetaWebInterceptorTest; import com.dotcms.graphql.DotGraphQLHttpServletTest; import com.dotcms.integritycheckers.HostIntegrityCheckerTest; @@ -226,8 +226,8 @@ Task220825CreateVariantFieldTest.class, Task221007AddVariantIntoPrimaryKeyTest.class, ESContentletAPIImplTest.class, - ExperimentAPIImpIT.class, - ExperimentWebAPIImplIT.class, + ExperimentAPIImpIntegrationTest.class, + ExperimentWebAPIImplIntegrationTest.class, ContentletWebAPIImplIntegrationTest.class, // moved to top because of failures on GHA DependencyBundlerTest.class, // moved to top because of failures on GHA SiteAndFolderResolverImplTest.class, //Moved up to avoid conflicts with CT deletion diff --git a/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentAPIImpIT.java b/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentAPIImpIntegrationTest.java similarity index 99% rename from dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentAPIImpIT.java rename to dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentAPIImpIntegrationTest.java index 9580729a3769..8228dc0b8327 100644 --- a/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentAPIImpIT.java +++ b/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentAPIImpIntegrationTest.java @@ -34,7 +34,6 @@ import com.dotcms.datagen.MultiTreeDataGen; import com.dotcms.datagen.SiteDataGen; import com.dotcms.datagen.TemplateDataGen; -import com.dotcms.datagen.VariantDataGen; import com.dotcms.exception.NotAllowedException; import com.dotcms.experiments.business.result.BrowserSession; import com.dotcms.experiments.business.result.ExperimentAnalyzerUtil; @@ -47,17 +46,12 @@ import com.dotcms.experiments.model.ExperimentVariant; import com.dotcms.experiments.model.GoalFactory; import com.dotcms.experiments.model.Goals; -import com.dotcms.http.CircuitBreakerUrl; -import com.dotcms.http.CircuitBreakerUrl.Method; -import com.dotcms.http.CircuitBreakerUrl.Response; import com.dotcms.http.server.mock.MockHttpServer; import com.dotcms.http.server.mock.MockHttpServerContext; import com.dotcms.http.server.mock.MockHttpServerContext.RequestContext; -import com.dotcms.jitsu.EventLogRunnable; import com.dotcms.util.IntegrationTestInitService; import com.dotcms.util.JsonUtil; import com.dotcms.util.network.IPUtils; -import com.dotcms.variant.VariantAPI; import com.dotcms.variant.model.Variant; import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; @@ -101,7 +95,7 @@ /** * Test of {@link ExperimentsAPIImpl} */ -public class ExperimentAPIImpIT extends IntegrationTestBase { +public class ExperimentAPIImpIntegrationTest extends IntegrationTestBase { private static final String CUBEJS_SERVER_IP = "127.0.0.1"; private static final int CUBEJS_SERVER_PORT = 5000; diff --git a/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentAnalyzerUtilIT.java b/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentAnalyzerUtilIntegrationTest.java similarity index 99% rename from dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentAnalyzerUtilIT.java rename to dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentAnalyzerUtilIntegrationTest.java index 30a9aba22604..d4e9ff5d8817 100644 --- a/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentAnalyzerUtilIT.java +++ b/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentAnalyzerUtilIntegrationTest.java @@ -45,7 +45,7 @@ /** * Test of {@link ExperimentAnalyzerUtil} */ -public class ExperimentAnalyzerUtilIT { +public class ExperimentAnalyzerUtilIntegrationTest { @BeforeClass public static void prepare() throws Exception { diff --git a/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentResultQueryFactoryIT.java b/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentResultQueryFactoryIntegrationTest.java similarity index 97% rename from dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentResultQueryFactoryIT.java rename to dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentResultQueryFactoryIntegrationTest.java index 13a5f06937b1..4c8a0a95ca4e 100644 --- a/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentResultQueryFactoryIT.java +++ b/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentResultQueryFactoryIntegrationTest.java @@ -19,15 +19,13 @@ import com.dotmarketing.beans.Host; import com.dotmarketing.portlets.htmlpageasset.model.HTMLPageAsset; import com.dotmarketing.portlets.templates.model.Template; -import graphql.AssertException; -import org.jetbrains.annotations.NotNull; import org.junit.BeforeClass; import org.junit.Test; /** * Test of {@link ExperimentResultQueryFactory} */ -public class ExperimentResultQueryFactoryIT { +public class ExperimentResultQueryFactoryIntegrationTest { @BeforeClass public static void prepare() throws Exception { diff --git a/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentResultsQueryFactoryIT.java b/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentResultsQueryFactoryIntegrationTest.java similarity index 99% rename from dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentResultsQueryFactoryIT.java rename to dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentResultsQueryFactoryIntegrationTest.java index dec61568e551..b97d18d7f819 100644 --- a/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentResultsQueryFactoryIT.java +++ b/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentResultsQueryFactoryIntegrationTest.java @@ -25,7 +25,7 @@ /** * Test of {@link ExperimentResultsQueryFactory} */ -public class ExperimentResultsQueryFactoryIT { +public class ExperimentResultsQueryFactoryIntegrationTest { @BeforeClass public static void prepare() throws Exception { diff --git a/dotCMS/src/integration-test/java/com/dotcms/experiments/business/web/ExperimentWebAPIImplIT.java b/dotCMS/src/integration-test/java/com/dotcms/experiments/business/web/ExperimentWebAPIImplIntegrationTest.java similarity index 99% rename from dotCMS/src/integration-test/java/com/dotcms/experiments/business/web/ExperimentWebAPIImplIT.java rename to dotCMS/src/integration-test/java/com/dotcms/experiments/business/web/ExperimentWebAPIImplIntegrationTest.java index 0b3ba6df8ade..33b6b3a3ba40 100644 --- a/dotCMS/src/integration-test/java/com/dotcms/experiments/business/web/ExperimentWebAPIImplIT.java +++ b/dotCMS/src/integration-test/java/com/dotcms/experiments/business/web/ExperimentWebAPIImplIntegrationTest.java @@ -34,24 +34,19 @@ import com.dotmarketing.portlets.htmlpageasset.model.HTMLPageAsset; import com.dotmarketing.portlets.rules.model.LogicalOperator; import com.dotmarketing.portlets.templates.model.Template; -import com.dotmarketing.util.UtilMethods; -import com.liferay.util.StringPool; -import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; -import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.junit.BeforeClass; import org.junit.Test; -public class ExperimentWebAPIImplIT { +public class ExperimentWebAPIImplIntegrationTest { @BeforeClass public static void prepare() throws Exception { From 9e2491cef370b53baeed0e46894f83d96dd41ed5 Mon Sep 17 00:00:00 2001 From: Daniel Silva Date: Fri, 26 May 2023 08:24:40 -0600 Subject: [PATCH 12/63] #24999 publish experiment page on start (#25036) * #24999 publish experiment page on start * #24999 missing * #24999 small refactor * #24999 end Experiment after testing --- ...periments Resource.postman_collection.json | 583 +++++++++++++++++- .../business/ExperimentsAPIImpl.java | 28 + 2 files changed, 610 insertions(+), 1 deletion(-) diff --git a/dotCMS/src/curl-test/Experiments Resource.postman_collection.json b/dotCMS/src/curl-test/Experiments Resource.postman_collection.json index bddc2844747e..5c2748d4be24 100644 --- a/dotCMS/src/curl-test/Experiments Resource.postman_collection.json +++ b/dotCMS/src/curl-test/Experiments Resource.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "0441f95e-4230-48b5-ae39-221a9d987f34", + "_postman_id": "3e400a2a-25b3-403e-b03c-e23b9a4121ba", "name": "Experiments Resource", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", "_exporter_id": "4500400" @@ -4588,6 +4588,587 @@ } ] }, + { + "name": "StartExperimentShouldPublishPage", + "item": [ + { + "name": "pre_ImportBundleWithPage", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Bundle uploaded sucessfully\", function () {", + " pm.response.to.have.status(200);", + "", + " var jsonData = pm.response.json();", + " console.log(jsonData);", + "", + " pm.expect(jsonData[\"bundleName\"]).to.eql(\"page_experiment.tar.gz\");", + " pm.expect(jsonData[\"status\"]).to.eql(\"SUCCESS\");", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "password", + "value": "admin", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/octet-stream" + }, + { + "key": "Content-Disposition", + "type": "text", + "value": "attachment" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "src": "resources/Experiments/page_experiment.tar.gz" + } + ] + }, + "url": { + "raw": "{{serverURL}}/api/bundle?sync=true", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "bundle" + ], + "query": [ + { + "key": "sync", + "value": "true" + }, + { + "key": "AUTH_TOKEN", + "value": "", + "disabled": true + } + ] + }, + "description": "Imports a Bundle that includes:\n* A piece of content with a tag field without any tags selected" + }, + "response": [] + }, + { + "name": "pre_UnpublishPage", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"No errors\", function () {", + " ", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors.length).to.eql(0);", + "});", + "", + "pm.test(\"Information Publish Correctly\", function () {", + " ", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.entity.baseType).to.eql(\"HTMLPAGE\");", + " pm.expect(jsonData.entity.live).to.eql(false);", + "});", + "", + "", + "", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "password", + "value": "admin", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/UNPUBLISH?identifier=e424abd7e2e7a9031c5a0a3c18182f1b", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "workflow", + "actions", + "default", + "fire", + "UNPUBLISH" + ], + "query": [ + { + "key": "identifier", + "value": "e424abd7e2e7a9031c5a0a3c18182f1b" + } + ] + }, + "description": "Fires an PUBLISH default action" + }, + "response": [] + }, + { + "name": "pre_createExperiment", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "", + "", + "pm.test(\"Status code should be ok 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.collectionVariables.set(\"experimentToStartNow\", jsonData.entity.id);", + "", + "", + "", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"pageId\": \"e424abd7e2e7a9031c5a0a3c18182f1b\",\n \"name\": \"20220901\",\n \"description\": \"experiment with goals and variants\", \n \"goals\": {\n \"primary\": {\n \"name\": \"Reach thank-you page\",\n \"type\": \"REACH_PAGE\",\n \"conditions\": [\n {\n \"parameter\": \"url\",\n \"operator\": \"EQUALS\",\n \"value\": \"thank-you\"\n },\n {\n \"parameter\": \"referer\",\n \"operator\": \"EQUALS\",\n \"value\": \"home\"\n }\n ]\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/experiments/", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "experiments", + "" + ] + } + }, + "response": [] + }, + { + "name": "pre_addVariantToExperiment", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "", + "", + "pm.test(\"Variants with correct weight\", function () {", + " pm.response.to.have.status(200);", + " pm.expect(jsonData.entity.trafficProportion.type).equal(\"SPLIT_EVENLY\");", + " pm.expect(jsonData.entity.trafficProportion.variants.length).equal(2);", + " pm.expect(jsonData.entity.trafficProportion.variants[0].name).equal(\"Original\");", + " pm.expect(jsonData.entity.trafficProportion.variants[0].weight).equal(50.0);", + " pm.expect(jsonData.entity.trafficProportion.variants[1].name).equal(\"I wanna be promoted!\");", + " pm.expect(jsonData.entity.trafficProportion.variants[1].weight).equal(50.0);", + " ", + " pm.collectionVariables.set(\"originalVariant\", jsonData.entity.trafficProportion.variants[0].id);", + " pm.collectionVariables.set(\"variantToPromote\", jsonData.entity.trafficProportion.variants[1].id);", + "", + "});", + "", + "", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"description\": \"I wanna be promoted!\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/experiments/{{experimentToStartNow}}/variants", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "experiments", + "{{experimentToStartNow}}", + "variants" + ] + } + }, + "response": [] + }, + { + "name": "pre_setExperimentGoal", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "", + "", + "pm.test(\"Status code should be ok 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Experiment should have the expected values\", function () {", + " pm.expect(jsonData.entity.goals.primary.type).equal(\"REACH_PAGE\");", + " pm.expect(jsonData.entity.goals.primary.name).equal(\"Reach thank-you page\");", + " pm.expect(jsonData.entity.goals.primary.conditions[0].operator).equal(\"EQUALS\");", + " pm.expect(jsonData.entity.goals.primary.conditions[0].parameter).equal(\"url\");", + " pm.expect(jsonData.entity.goals.primary.conditions[0].value).equal(\"thank-you\");", + " pm.expect(jsonData.entity.goals.primary.conditions[1].operator).equal(\"EQUALS\");", + " pm.expect(jsonData.entity.goals.primary.conditions[1].parameter).equal(\"referer\");", + " pm.expect(jsonData.entity.goals.primary.conditions[1].value).equal(\"home\");", + "", + "});", + "", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"goals\": {\n \"primary\": {\n \"name\": \"Reach thank-you page\",\n \"type\": \"REACH_PAGE\",\n \"conditions\": [\n {\n \"parameter\": \"url\",\n \"operator\": \"EQUALS\",\n \"value\": \"thank-you\"\n },\n {\n \"parameter\": \"referer\",\n \"operator\": \"EQUALS\",\n \"value\": \"home\"\n }\n ]\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/experiments/{{experimentToStartNow}}", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "experiments", + "{{experimentToStartNow}}" + ] + } + }, + "response": [] + }, + { + "name": "startExperiment_shouldSucceed", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "", + "", + "pm.test(\"Started Experiment with expected values\", function () {", + " pm.response.to.have.status(200);", + " pm.expect(jsonData.entity.status).equal(\"RUNNING\");", + " pm.expect(jsonData.entity.scheduling.startDate).to.be.not.null;", + " pm.expect(jsonData.entity.scheduling.endDate).to.be.not.null;", + "});", + "", + "", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/experiments/{{experimentToStartNow}}/_start", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "experiments", + "{{experimentToStartNow}}", + "_start" + ] + } + }, + "response": [] + }, + { + "name": "pageOfExperiment_shouldBeLive", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "", + "var identifier = pm.collectionVariables.get(\"contentletIdentifier\");", + "pm.test(\"Live check\", function () {", + " pm.expect(jsonData.entity.live).to.true;", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/content/e424abd7e2e7a9031c5a0a3c18182f1b", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "content", + "e424abd7e2e7a9031c5a0a3c18182f1b" + ] + } + }, + "response": [] + }, + { + "name": "post_endExperiment", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "", + "", + "pm.test(\"Ended Experiment with expected values\", function () {", + " pm.response.to.have.status(200);", + " pm.expect(jsonData.entity.status).equal(\"ENDED\");", + " pm.expect(jsonData.entity.scheduling.startDate).to.be.not.null;", + " pm.expect(jsonData.entity.scheduling.endDate).to.be.not.null;", + "});", + "", + "", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/experiments/{{experimentToStartNow}}/_end", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "experiments", + "{{experimentToStartNow}}", + "_end" + ] + } + }, + "response": [] + } + ] + }, { "name": "pre_createExperiment_NoGoal_NoVariant", "event": [ diff --git a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentsAPIImpl.java b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentsAPIImpl.java index 243e6f70232e..c37a465e04c7 100644 --- a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentsAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentsAPIImpl.java @@ -73,7 +73,9 @@ import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.exception.InvalidLicenseException; +import com.dotmarketing.exception.WebAssetException; import com.dotmarketing.factories.MultiTreeAPI; +import com.dotmarketing.factories.PublishFactory; import com.dotmarketing.portlets.contentlet.business.ContentletAPI; import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.contentlet.model.ContentletVersionInfo; @@ -507,6 +509,7 @@ public Experiment start(String experimentId, User user) final Experiment experimentToSave = persistedExperiment.withScheduling(scheduling).withStatus(RUNNING); validateNoConflictsWithScheduledExperiments(experimentToSave, user); toReturn = save(experimentToSave, user); + publishExperimentPage(toReturn, user); publishContentOnExperimentVariants(user, toReturn); cacheRunningExperiments(); } else { @@ -519,6 +522,30 @@ public Experiment start(String experimentId, User user) return toReturn; } + private void publishExperimentPage(final Experiment experiment, final User user) + throws DotDataException, DotSecurityException { + final HTMLPageAsset htmlPageAsset = APILocator.getHTMLPageAssetAPI().fromContentlet(contentletAPI + .findContentletByIdentifierAnyLanguage(experiment.pageId(), false)); + + if(htmlPageAsset.isLive()) { + return; + } + + final List relatedNotPublished = PublishFactory.getUnpublishedRelatedAssetsForPage(htmlPageAsset, new ArrayList(), + true, user, false); + relatedNotPublished.stream().filter(asset -> asset instanceof Contentlet).forEach( + asset -> Contentlet.class.cast(asset) + .setProperty(Contentlet.WORKFLOW_IN_PROGRESS, Boolean.TRUE)); + //Publish the page and the related content + htmlPageAsset.setProperty(Contentlet.WORKFLOW_IN_PROGRESS, Boolean.TRUE); + try { + PublishFactory.publishHTMLPage(htmlPageAsset, relatedNotPublished, user, + false); + } catch(WebAssetException e) { + throw new DotDataException(e); + } + } + private static boolean emptyScheduling(Experiment persistedExperiment) { return persistedExperiment.scheduling().isEmpty() || (persistedExperiment.scheduling().get().startDate()).isEmpty() @@ -567,6 +594,7 @@ public Experiment startScheduled(String experimentId, User user) Experiment running = save(persistedExperiment.withStatus(RUNNING), user); cacheRunningExperiments(); + publishExperimentPage(running, user); publishContentOnExperimentVariants(user, running); return running; From 7d9459d05b4cceaf073ada44382057119c8925aa Mon Sep 17 00:00:00 2001 From: Nollymar Longa Date: Fri, 26 May 2023 09:52:26 -0500 Subject: [PATCH 13/63] A new empty starter was generated (#25048) Co-authored-by: nollymar --- dotCMS/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotCMS/build.gradle b/dotCMS/build.gradle index fb47f164ce5d..ded423569dd3 100644 --- a/dotCMS/build.gradle +++ b/dotCMS/build.gradle @@ -292,7 +292,7 @@ dependencies { //// starter config - starter group: 'com.dotcms', name: 'starter', version: 'empty_20230509', ext: 'zip' + starter group: 'com.dotcms', name: 'starter', version: 'empty_20230525', ext: 'zip' //Uncomment this line if you want to download the starter that comes with data // starter group: 'com.dotcms', name: 'starter', version: '20230518', ext: 'zip' testsStarter group: 'com.dotcms', name: 'starter', version: 'empty_20220718', ext: 'zip' From 112d9dcfa862b4b7b3a82b9a1b016732a7976e24 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Fri, 26 May 2023 08:54:43 -0600 Subject: [PATCH 14/63] #23175 adding secrets as velocity (#24386) * #23175 adding secrets as velocity * #23175 adding secrets as velocity * #23175 adding feedback * #23175 removing typo * #23175 trying to keep the secrets * #23175 feedback done and some minor improvements * #23175 adding a fix on postman * #23175 fixing curl test * #23175 fixing unit test --- .../VelocitySecrets.postman_collection.json | 217 ++++++++++++++++++ .../integration-test/resources/toolbox.xml | 5 + .../api/web/RequestThreadLocalListener.java | 4 +- .../velocity/viewtools/SecretTool.java | 76 ++++++ .../secrets/DotVelocitySecretAppConfig.java | 197 ++++++++++++++++ ...DotVelocitySecretAppConfigThreadLocal.java | 63 +++++ .../secrets/DotVelocitySecretAppKeys.java | 17 ++ .../resources/apps/dotVelocitySecretApp.yml | 12 + dotCMS/src/main/webapp/WEB-INF/toolbox.xml | 5 + 9 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 dotCMS/src/curl-test/VelocitySecrets.postman_collection.json create mode 100644 dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/SecretTool.java create mode 100644 dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/secrets/DotVelocitySecretAppConfig.java create mode 100644 dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/secrets/DotVelocitySecretAppConfigThreadLocal.java create mode 100644 dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/secrets/DotVelocitySecretAppKeys.java create mode 100644 dotCMS/src/main/resources/apps/dotVelocitySecretApp.yml diff --git a/dotCMS/src/curl-test/VelocitySecrets.postman_collection.json b/dotCMS/src/curl-test/VelocitySecrets.postman_collection.json new file mode 100644 index 000000000000..8d70163d60b5 --- /dev/null +++ b/dotCMS/src/curl-test/VelocitySecrets.postman_collection.json @@ -0,0 +1,217 @@ +{ + "info": { + "_postman_id": "c55a23bc-a33b-42ab-a63b-083e43af4572", + "name": "VelocitySecrets", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "781456" + }, + "item": [ + { + "name": "Add Config to System Host", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"title\": {\n \"value\": \"System Config\"\n },\n \"secretKey1\": {\n \"value\": \"value1\"\n },\n \"secretKey2\": {\n \"value\": \"value2\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/apps/dotVelocitySecretApp/SYSTEM_HOST", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "apps", + "dotVelocitySecretApp", + "SYSTEM_HOST" + ] + } + }, + "response": [] + }, + { + "name": "Add Config to Default Host", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"title\": {\n \"value\": \"Default Config\"\n },\n \"secretKey1\": {\n \"value\": \"default-value1\"\n },\n \"secretKey3\": {\n \"value\": \"value3\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/apps/dotVelocitySecretApp/8a7d5e23-da1e-420a-b4f0-471e7da8ea2d", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "apps", + "dotVelocitySecretApp", + "8a7d5e23-da1e-420a-b4f0-471e7da8ea2d" + ] + } + }, + "response": [] + }, + { + "name": "Test Velocity Secrets", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "", + "var jsonData = pm.response.json();", + "", + "", + "pm.test(\"Test systemDefault to be systemdefault\", function () {", + " ", + " pm.expect(jsonData.systemDefault).to.be.eq('systemdefault');", + "});", + "", + "", + "pm.test(\"Test systemUnknown to be null\", function () {", + " ", + " pm.expect(jsonData.systemUnknown).to.be.eq(null);", + "});", + "", + "pm.test(\"Test systemUnknown to be null\", function () {", + " ", + " pm.expect(jsonData.systemUnknown).to.be.eq(null);", + "});", + "", + "", + "pm.test(\"Test systemSecretKey1 to be value1\", function () {", + " ", + " pm.expect(jsonData.systemSecretKey2).to.be.eq('value2');", + "});", + "", + "pm.test(\"Test systemSecretKey2 to be value2\", function () {", + " ", + " pm.expect(jsonData.systemSecretKey2).to.be.eq('value2');", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "$dotJSON.put(\"secretKey1\", $dotsecrets.secretKey1)\n$dotJSON.put(\"secretKey3\", $dotsecrets.secretKey3)\n$dotJSON.put(\"unknown\", $dotsecrets.unknown)\n$dotJSON.put(\"systemSecretKey1\", $dotsecrets.getSystemSecret('secretKey1'))\n$dotJSON.put(\"systemSecretKey2\", $dotsecrets.getSystemSecret('secretKey2'))\n$dotJSON.put(\"systemUnknown\", $dotsecrets.getSystemSecret('unknown'))\n$dotJSON.put(\"systemDefault\", $dotsecrets.getSystemSecret('unknown', 'systemdefault'))", + "options": { + "raw": { + "language": "text" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/vtl/dynamic/", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "vtl", + "dynamic", + "" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/dotCMS/src/integration-test/resources/toolbox.xml b/dotCMS/src/integration-test/resources/toolbox.xml index 4e71d564b4b2..e803f4a16db1 100644 --- a/dotCMS/src/integration-test/resources/toolbox.xml +++ b/dotCMS/src/integration-test/resources/toolbox.xml @@ -34,6 +34,11 @@ request com.dotcms.rendering.velocity.viewtools.LanguageViewtool + + dotsecrets + application + com.dotcms.rendering.velocity.viewtools.SecretTool + listTool application diff --git a/dotCMS/src/main/java/com/dotcms/api/web/RequestThreadLocalListener.java b/dotCMS/src/main/java/com/dotcms/api/web/RequestThreadLocalListener.java index d6d922bbc488..6ad074f911c5 100644 --- a/dotCMS/src/main/java/com/dotcms/api/web/RequestThreadLocalListener.java +++ b/dotCMS/src/main/java/com/dotcms/api/web/RequestThreadLocalListener.java @@ -3,6 +3,8 @@ import javax.servlet.ServletRequestEvent; import javax.servlet.ServletRequestListener; import javax.servlet.http.HttpServletRequest; + +import com.dotcms.rendering.velocity.viewtools.secrets.DotVelocitySecretAppConfigThreadLocal; import com.dotmarketing.util.Logger; @@ -16,7 +18,7 @@ public RequestThreadLocalListener() { public void requestDestroyed(ServletRequestEvent requestEvent) { HttpServletRequestThreadLocal.INSTANCE.setRequest(null); - + DotVelocitySecretAppConfigThreadLocal.INSTANCE.clearConfig(); } diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/SecretTool.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/SecretTool.java new file mode 100644 index 000000000000..b20dbca01344 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/SecretTool.java @@ -0,0 +1,76 @@ +package com.dotcms.rendering.velocity.viewtools; + +import com.dotcms.api.web.HttpServletRequestThreadLocal; +import com.dotcms.rendering.velocity.viewtools.secrets.DotVelocitySecretAppConfig; +import com.dotmarketing.business.APILocator; +import org.apache.velocity.tools.view.tools.ViewTool; + +import javax.servlet.http.HttpServletRequest; +import java.util.Optional; + +/** + * This view tool expose the dot velocity secrets app to velocity + * This allows to get configuration from the dotVelocitySecretApp + * @author jsanca + */ +public class SecretTool implements ViewTool { + + @Override + public void init(Object initData) { + } + + /** + * Gets a secret as an object|string, based on the current host (if configured) + * @param key String + * @return Object + */ + public Object get(final String key) { + + final HttpServletRequest request = HttpServletRequestThreadLocal.INSTANCE.getRequest(); + final Optional config = DotVelocitySecretAppConfig.config(request); + return config.isPresent()? config.get().getStringOrNull(key) : null; + } + + /** + * Gets a secret as an object|string, based on the system host (if configured) + * @param key String + * @return Object + */ + public Object getSystemSecret (final String key) { + + return getSystemSecret(key, null); + } + + /** + * Gets a secret as an object|string, based on the system host (if configured) + * If not present, returns the default value + * @param key String + * @param defaultValue Object + * @return Object + */ + public Object getSystemSecret (final String key, + final Object defaultValue) { + + final Optional config = DotVelocitySecretAppConfig.config(APILocator.systemHost()); + return config.isPresent()? config.get().getStringOrNull(key, null!= defaultValue? defaultValue.toString():null) : defaultValue; + } + + public char[] getCharArray(final String key) { + + final HttpServletRequest request = HttpServletRequestThreadLocal.INSTANCE.getRequest(); + final Optional config = DotVelocitySecretAppConfig.config(request); + return config.isPresent()? config.get().getCharArrayOrNull(key) : null; + } + + public char[] getCharArraySystemSecret (final String key) { + + return getCharArraySystemSecret(key, null); + } + + public char[] getCharArraySystemSecret (final String key, + final char[] defaultValue) { + + final Optional config = DotVelocitySecretAppConfig.config(APILocator.systemHost()); + return config.isPresent()? config.get().getCharArrayOrNull(key, defaultValue) : defaultValue; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/secrets/DotVelocitySecretAppConfig.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/secrets/DotVelocitySecretAppConfig.java new file mode 100644 index 000000000000..74e598e65456 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/secrets/DotVelocitySecretAppConfig.java @@ -0,0 +1,197 @@ +package com.dotcms.rendering.velocity.viewtools.secrets; + +import com.dotcms.api.web.HttpServletRequestThreadLocal; +import com.dotcms.security.apps.AppSecrets; +import com.dotcms.security.apps.Secret; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.web.WebAPILocator; +import com.dotmarketing.util.UtilMethods; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import io.vavr.control.Try; + +import javax.servlet.http.HttpServletRequest; +import java.io.Serializable; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Encapsulates the velocity secrets (title + extra parameters) + * @author jsanca + */ +@JsonDeserialize(builder = DotVelocitySecretAppConfig.Builder.class) +public class DotVelocitySecretAppConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + private final String title; + private final Map extraParameters; + + private DotVelocitySecretAppConfig(final Builder builder) { + this.title = builder.title; + this.extraParameters = builder.extraParameters; + } + + /** + * Get title + * @return String + */ + public String getTitle () { + return title; + } + + /** + * Get the secret as string (null if does not exist) + * @param key String + * @return String + */ + public String getStringOrNull (final String key) { + + return this.extraParameters.containsKey(key)? + extraParameters.get(key).getString(): null; + } + + /** + * Get the secret as string (null if does not exist) + * @param key String + * @return String + */ + public String getStringOrNull (final String key, final String defaultString) { + + return this.extraParameters.containsKey(key)? + extraParameters.get(key).getString(): defaultString; + } + + /** + * Get the secret as char array (null if does not exist) + * @param key String + * @return char [] + */ + public char[] getCharArrayOrNull (final String key) { + + return this.extraParameters.containsKey(key)? + extraParameters.get(key).getValue(): null; + } + + /** + * Get the secret as char array (null if does not exist) + * @param key String + * @return char [] + */ + public char[] getCharArrayOrNull (final String key, final char[] defaultCharArray) { + + return this.extraParameters.containsKey(key)? + extraParameters.get(key).getValue(): defaultCharArray; + } + + public void destroySecrets () { + + this.extraParameters.values().forEach(Secret::destroy); + } + public static Optional config() { + + return config(HttpServletRequestThreadLocal.INSTANCE.getRequest()); + } + + /** + * Gets the secrets from the App - this will check the current host then the SYSTEM_HOST for a valid + * configuration. This lookup is low overhead and cached by dotCMS. + * + * @param request {@link HttpServletRequest} + * @return + */ + public static Optional config(final HttpServletRequest request) { + + return config(null != request? WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request) : APILocator.systemHost()); + } + + /** + * Gets the secrets from the App - this will check the current host then the SYSTEM_HOST for a valid + * configuration. This lookup is low overhead and cached by dotCMS. + * + * @param host {@link Host} + * @return + */ + public static Optional config(final Host host) { + + if (DotVelocitySecretAppConfigThreadLocal.INSTANCE.getConfig(host.getIdentifier()).isPresent()) { + + return DotVelocitySecretAppConfigThreadLocal.INSTANCE.getConfig(host.getIdentifier()); + } + + final Optional appSecrets = Try.of( + () -> APILocator.getAppsAPI().getSecrets(DotVelocitySecretAppKeys.APP_KEY, true, host, APILocator.systemUser())) + .getOrElse(Optional.empty()); + + if (!appSecrets.isPresent()) { + + return Optional.empty(); + } + + final Map secrets = new HashMap<>(appSecrets.get().getSecrets()); + final String title = + Try.of(() -> secrets.get(DotVelocitySecretAppKeys.TITLE.key).getString()).getOrNull(); + + if (!UtilMethods.isSet(title)) { + + return Optional.empty(); + } + + final DotVelocitySecretAppConfig config = DotVelocitySecretAppConfig.builder() + .withTitle(title) + .withExtraParameters(secrets).build(); + + DotVelocitySecretAppConfigThreadLocal.INSTANCE.setConfig(host.getIdentifier(), Optional.ofNullable(config)); + + return DotVelocitySecretAppConfigThreadLocal.INSTANCE.getConfig(host.getIdentifier()); + } + + /** + * Creates builder to build {@link DotVelocitySecretAppConfig}. + * + * @return created builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Creates a builder to build {@link DotVelocitySecretAppConfig} and initialize it with the given object. + * + * @param appConfig to initialize the builder with + * @return created builder + */ + public static Builder from(DotVelocitySecretAppConfig appConfig) { + return new Builder(appConfig); + } + + /** + * Builder to build {@link DotVelocitySecretAppConfig}. + */ + public static final class Builder { + private String title; + private Map extraParameters = Collections.emptyMap(); + private Builder() {} + + private Builder(DotVelocitySecretAppConfig appConfig) { + this.title = appConfig.title; + this.extraParameters = appConfig.extraParameters; + } + + public Builder withTitle(String title) { + this.title = title; + return this; + } + + public Builder withExtraParameters(final Map extraParameters) { + this.extraParameters = extraParameters; + return this; + } + + public DotVelocitySecretAppConfig build() { + return new DotVelocitySecretAppConfig(this); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/secrets/DotVelocitySecretAppConfigThreadLocal.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/secrets/DotVelocitySecretAppConfigThreadLocal.java new file mode 100644 index 000000000000..90d1ccfe96c5 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/secrets/DotVelocitySecretAppConfigThreadLocal.java @@ -0,0 +1,63 @@ +package com.dotcms.rendering.velocity.viewtools.secrets; + +import com.dotcms.security.apps.Secret; + +import java.io.Serializable; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Thread local holder for the dot secrets + * this approach just avoids to load the secrets more than one in the same request + * @author jsanca + */ +public class DotVelocitySecretAppConfigThreadLocal implements Serializable { + + private static final long serialVersionUID = 1L; + + private static ThreadLocal> configLocal = new ThreadLocal<>(); + + public static final DotVelocitySecretAppConfigThreadLocal INSTANCE = new DotVelocitySecretAppConfigThreadLocal(); + + /** + * Get the request from the current thread + * + * @return {@link DotVelocitySecretAppConfig} + */ + public Optional getConfig(final String siteId) { + + this.init(); + return Optional.ofNullable(configLocal.get().get(siteId)); + } + + public void setConfig(final String siteId, final Optional config) { + + this.init(); + configLocal.get().put(siteId, null != config && config.isPresent()? config.get(): null); + } + + private void init () { + + if (null == configLocal.get()) { + configLocal.set(new ConcurrentHashMap<>()); + } + } + + public void clearConfig() { + + if (null != configLocal.get()) { + + final Map map = configLocal.get(); + for (final String key : map.keySet()) { + + DotVelocitySecretAppConfig secrets = map.get(key); + secrets.destroySecrets(); + secrets = null; + map.remove(key); + } + map.clear(); + } + configLocal.remove(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/secrets/DotVelocitySecretAppKeys.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/secrets/DotVelocitySecretAppKeys.java new file mode 100644 index 000000000000..960ce85150ee --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/secrets/DotVelocitySecretAppKeys.java @@ -0,0 +1,17 @@ +package com.dotcms.rendering.velocity.viewtools.secrets; + +/** + * Defines the Keys for the dotVelocitySecretApp + * @author jsanca + */ +public enum DotVelocitySecretAppKeys { + TITLE("title"); + + final public String key; + + DotVelocitySecretAppKeys(final String key){ + this.key = key; + } + + public final static String APP_KEY = "dotVelocitySecretApp"; +} diff --git a/dotCMS/src/main/resources/apps/dotVelocitySecretApp.yml b/dotCMS/src/main/resources/apps/dotVelocitySecretApp.yml new file mode 100644 index 000000000000..dd4aa1147c73 --- /dev/null +++ b/dotCMS/src/main/resources/apps/dotVelocitySecretApp.yml @@ -0,0 +1,12 @@ +name: "Dot Velocity Secrets" +description: "This provides general configuration to be access on the velocity context" +iconUrl: "https://static.dotcms.com/assets/icons/apps/velocity.png" +allowExtraParameters: true +params: + title: + label: "Title" + value: "" + hidden: false + type: "STRING" + hint: "Title for the config" + required: true diff --git a/dotCMS/src/main/webapp/WEB-INF/toolbox.xml b/dotCMS/src/main/webapp/WEB-INF/toolbox.xml index 0c0db6a5bf8d..e0d37913a20e 100644 --- a/dotCMS/src/main/webapp/WEB-INF/toolbox.xml +++ b/dotCMS/src/main/webapp/WEB-INF/toolbox.xml @@ -34,6 +34,11 @@ request com.dotcms.rendering.velocity.viewtools.LanguageViewtool + + dotsecrets + application + com.dotcms.rendering.velocity.viewtools.SecretTool + listTool application From 540335256b1c035677ab374d68969285816f4767 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Fri, 26 May 2023 08:57:21 -0600 Subject: [PATCH 15/63] =?UTF-8?q?Fix=20#24801=20Adding=20a=20fix=20to=20ha?= =?UTF-8?q?ndle=20content=20type=20permission=20inheritance=E2=80=A6=20(#2?= =?UTF-8?q?4997)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix #24801 Adding a fix to handle content type permission inheritance (#24813) * #24801 adding a fix to handle content type permission inheritance * #24801 adding feedback * #24801 this change allows to publish a new content based on the cms owner content type permissions * #24801 adding a new fix to apply some logic only when the contentlet is new (or at least has just one version) * #24801 show inheritable permissions on content types * #24801 filter inheritable permissions for content type * #24801 make hasOnlyOneVersion faster * #24801 adding more things for identify if content is new * #24801 adding add children content to content types * #24801 now the publish and the inherance works fine * #24801 adding the add user only for admin in order to create a postman test to create a limited user * #24801 adding necessary code for making a dot favorite page with limited user * #24801 changing a doc * #24801 adding just a comment * #24801 doing logout for limited user * #24801 added the create role in order to leave the backend user role as it is and adds the layouts over the limited role assigned too, to the limited user; finally fixing a lot of curl test * #24801 adding feedback --------- Co-authored-by: Will Ezell --- .../Content Resource.postman_collection.json | 94 ++++- .../DotFavoritePage.postman_collection.json | 367 ++++++++++++++++++ dotCMS/src/curl-test/PagesResourceTests.json | 5 +- .../UserResource.postman_collection.json | 6 +- .../workflows/business/WorkflowAPITest.java | 33 ++ .../auth/providers/saml/v1/SAMLHelper.java | 64 +-- .../business/ESContentletAPIImpl.java | 41 +- .../business/ContentTypeInitializer.java | 20 +- .../role/LayoutMapResponseEntityView.java | 17 + .../rest/api/v1/system/role/RoleForm.java | 130 +++++++ .../api/v1/system/role/RoleLayoutForm.java | 4 + .../rest/api/v1/system/role/RoleResource.java | 186 +++++++-- .../system/role/RoleResponseEntityView.java | 16 + .../rest/api/v1/user/CreateUserForm.java | 215 ++++++++++ .../dotcms/rest/api/v1/user/UserHelper.java | 114 ++++++ .../dotcms/rest/api/v1/user/UserResource.java | 123 ++++++ .../dotmarketing/business/PermissionAPI.java | 31 ++ .../business/PermissionBitAPIImpl.java | 73 +++- .../WEB-INF/messages/Language.properties | 1 + ...rmissions_accordion_contentType_entry.html | 14 + .../edit_permissions_accordion_entry.html | 3 +- .../common/edit_permissions_tab_js_inc.jsp | 36 +- 22 files changed, 1453 insertions(+), 140 deletions(-) create mode 100644 dotCMS/src/curl-test/DotFavoritePage.postman_collection.json create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/LayoutMapResponseEntityView.java create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleForm.java create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleResponseEntityView.java create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/user/CreateUserForm.java create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserHelper.java create mode 100644 dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_contentType_entry.html diff --git a/dotCMS/src/curl-test/Content Resource.postman_collection.json b/dotCMS/src/curl-test/Content Resource.postman_collection.json index 86807ce82c0e..d3ea9278d50e 100644 --- a/dotCMS/src/curl-test/Content Resource.postman_collection.json +++ b/dotCMS/src/curl-test/Content Resource.postman_collection.json @@ -1,10 +1,10 @@ { "info": { - "_postman_id": "64a156b5-40b4-46ce-8bab-6083e910e48a", + "_postman_id": "30e7c950-c42b-4489-a163-8fa82bdbbb54", "name": "Content Resource", "description": "Content Resource test", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "5403727" + "_exporter_id": "781456" }, "item": [ { @@ -147,7 +147,7 @@ } }, "url": { - "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH", + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH?indexPolicy=WAIT_FOR", "host": [ "{{serverURL}}" ], @@ -159,6 +159,12 @@ "default", "fire", "PUBLISH" + ], + "query": [ + { + "key": "indexPolicy", + "value": "WAIT_FOR" + } ] }, "description": "Creates a test Contentlet of the previously generated Content Type." @@ -275,7 +281,7 @@ "method": "DELETE", "header": [], "url": { - "raw": "{{serverURL}}/api/v1/contenttype/id/{{contentTypeId}}", + "raw": "{{serverURL}}/api/v1/contenttype/id/{{contentTypeId}}?indexPolicy=WAIT_FOR", "host": [ "{{serverURL}}" ], @@ -285,6 +291,12 @@ "contenttype", "id", "{{contentTypeId}}" + ], + "query": [ + { + "key": "indexPolicy", + "value": "WAIT_FOR" + } ] }, "description": "Deletes the test Content Type with the now-transformed Story Block field." @@ -430,7 +442,7 @@ } }, "url": { - "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH", + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH?indexPolicy=WAIT_FOR", "host": [ "{{serverURL}}" ], @@ -442,6 +454,12 @@ "default", "fire", "PUBLISH" + ], + "query": [ + { + "key": "indexPolicy", + "value": "WAIT_FOR" + } ] } }, @@ -1637,7 +1655,7 @@ ] }, "url": { - "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH", + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH?indexPolicy=WAIT_FOR", "host": [ "{{serverURL}}" ], @@ -1649,6 +1667,12 @@ "default", "fire", "PUBLISH" + ], + "query": [ + { + "key": "indexPolicy", + "value": "WAIT_FOR" + } ] }, "description": "Create a test File Asset that will be used to read Metadata from." @@ -1764,7 +1788,7 @@ } ], "url": { - "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/UNPUBLISH?inode={{fileInode}}&identifier={{fileId}}", + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/UNPUBLISH?inode={{fileInode}}&identifier={{fileId}}&indexPolicy=WAIT_FOR", "host": [ "{{serverURL}}" ], @@ -1785,6 +1809,10 @@ { "key": "identifier", "value": "{{fileId}}" + }, + { + "key": "indexPolicy", + "value": "WAIT_FOR" } ] } @@ -1836,7 +1864,7 @@ } ], "url": { - "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/ARCHIVE?inode={{fileInode}}&identifier={{fileId}}", + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/ARCHIVE?inode={{fileInode}}&identifier={{fileId}}&indexPolicy=WAIT_FOR", "host": [ "{{serverURL}}" ], @@ -1857,6 +1885,10 @@ { "key": "identifier", "value": "{{fileId}}" + }, + { + "key": "indexPolicy", + "value": "WAIT_FOR" } ] } @@ -1908,7 +1940,7 @@ } ], "url": { - "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/DELETE?inode={{fileInode}}&identifier={{fileId}}", + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/DELETE?inode={{fileInode}}&identifier={{fileId}}&indexPolicy=WAIT_FOR", "host": [ "{{serverURL}}" ], @@ -1929,6 +1961,10 @@ { "key": "identifier", "value": "{{fileId}}" + }, + { + "key": "indexPolicy", + "value": "WAIT_FOR" } ] } @@ -1938,6 +1974,38 @@ ], "description": "Verifies that the Metadata section in the JSON response in a File Asset is added." }, + { + "name": "invalidateSession", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/logout", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "logout" + ] + } + }, + "response": [] + }, { "name": "Save Multiple Generic Contentlets", "event": [ @@ -2018,7 +2086,7 @@ "response": [] }, { - "name": "invalidateSession", + "name": "invalidateSessionAgain", "event": [ { "listen": "test", @@ -2503,12 +2571,10 @@ "method": "GET", "header": [], "url": { - "raw": "http://localhost:8080/api/content/render/false/query/+contentType:host +title:default", - "protocol": "http", + "raw": "{{serverURL}}/api/content/render/false/query/+contentType:host +title:default", "host": [ - "localhost" + "{{serverURL}}" ], - "port": "8080", "path": [ "api", "content", diff --git a/dotCMS/src/curl-test/DotFavoritePage.postman_collection.json b/dotCMS/src/curl-test/DotFavoritePage.postman_collection.json new file mode 100644 index 000000000000..ae0f3adde4a9 --- /dev/null +++ b/dotCMS/src/curl-test/DotFavoritePage.postman_collection.json @@ -0,0 +1,367 @@ +{ + "info": { + "_postman_id": "256da9a7-a2a0-40ed-9ce9-c21947f9d67c", + "name": "DotFavoritePage", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "781456" + }, + "item": [ + { + "name": "CreateNewLimitedRole", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"roleName\":\"limitedRole\",\n \"roleKey\":\"limitedRole\",\n \"canEditUsers\":true,\n \"canEditPermissions\":true,\n \"canEditLayouts\":true,\n \"description\":\"Limited Role for Limited Users\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/roles", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "roles" + ] + } + }, + "response": [] + }, + { + "name": "GetLayouts", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200 \", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response includes name\", function () {", + " pm.expect(pm.response.json().entity.length).to.gte(0)", + "});", + "", + "var jsonData = pm.response.json();", + "pm.collectionVariables.set(\"firstLayoutId\", jsonData.entity[0].id);" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/roles/layouts", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "roles", + "layouts" + ] + } + }, + "response": [] + }, + { + "name": "SetLayoutToBackedUser", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200 \", function () {", + " pm.response.to.have.status(200);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"roleId\":\"DOTCMS_BACK_END_USER\",\n \"layoutIds\":[\"{{firstLayoutId}}\"]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/roles/layouts", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "roles", + "layouts" + ] + } + }, + "response": [] + }, + { + "name": "CreateLimitedUser", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200 \", function () {", + " pm.response.to.have.status(200);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"firstName\":\"LimitedTestName\",\n \"lastName\":\"LimitedTestLastName\",\n \"email\":\"mylimiteduser@dotcms.com\",\n \"password\":\"dotcms123456\",\n \"active\":true,\n \"roles\":[\"DOTCMS_BACK_END_USER\"]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/users", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "users" + ] + } + }, + "response": [] + }, + { + "name": "invalidateSession", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/logout", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "logout" + ] + } + }, + "response": [] + }, + { + "name": "FireDotFavoriteWithPermissionsWithLimitedUser", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200 \", function () {", + " pm.response.to.have.status(200);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "dotcms123456", + "type": "string" + }, + { + "key": "username", + "value": "mylimiteduser@dotcms.com", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"contentlet\":{\n \"contentType\":\"dotFavoritePage\",\n \"title\":\"Test3-11\",\n \"screenshot\":\"/dA/8ba493215e/fileAsset/veni-vidi-vici.png\",\n \"url\":\"Test body\",\n \"order\":1\n },\n \"individualPermissions\": {\n \"READ\":[\"CMS Owner\"],\n \"WRITE\":[\"CMS Owner\"],\n \"PUBLISH\":[\"CMS Owner\"],\n \"EDIT_PERMISSIONS\":[\"CMS Owner\"]\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "workflow", + "actions", + "default", + "fire", + "PUBLISH" + ] + } + }, + "response": [] + }, + { + "name": "invalidateSessionAgain", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/logout", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "logout" + ] + } + }, + "response": [] + }, + { + "name": "ReloginAsAnAdminAtTheEnd", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"userId\":\"admin@dotcms.com\",\n \"password\":\"admin\",\n \"backEndLogin\":true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/authentication", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "authentication" + ] + } + }, + "response": [] + } + ], + "variable": [ + { + "key": "firstLayoutId", + "value": "" + } + ] +} \ No newline at end of file diff --git a/dotCMS/src/curl-test/PagesResourceTests.json b/dotCMS/src/curl-test/PagesResourceTests.json index ebac1b2f8967..5067b618f991 100644 --- a/dotCMS/src/curl-test/PagesResourceTests.json +++ b/dotCMS/src/curl-test/PagesResourceTests.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "98af464f-bfb7-495e-89a3-d26545fecc9c", + "_postman_id": "efa22acb-6704-493e-bb14-fec8cb186601", "name": "Page API - [api/v1/page]", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", "_exporter_id": "781456" @@ -5212,14 +5212,13 @@ "method": "GET", "header": [], "url": { - "raw": "{{serverURL}}/api/v1/users/logout", + "raw": "{{serverURL}}/api/v1/logout", "host": [ "{{serverURL}}" ], "path": [ "api", "v1", - "users", "logout" ] } diff --git a/dotCMS/src/curl-test/UserResource.postman_collection.json b/dotCMS/src/curl-test/UserResource.postman_collection.json index de17637da0e8..462dc4b9ec5a 100644 --- a/dotCMS/src/curl-test/UserResource.postman_collection.json +++ b/dotCMS/src/curl-test/UserResource.postman_collection.json @@ -1,10 +1,10 @@ { "info": { - "_postman_id": "a9eac856-e8ee-47da-96a9-f2e68e81fd72", + "_postman_id": "958f7287-3a20-4591-94ae-525a6c9a7c38", "name": "UserResource", "description": "Verifies that commonly-used routines for interacting with User data are working as expected. Most of these are related to filtering operations and for back-end use only.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "5403727" + "_exporter_id": "781456" }, "item": [ { @@ -34,7 +34,7 @@ " var paginationData = pm.response.json().pagination;", " pm.expect(paginationData.currentPage).to.equal(1);", " pm.expect(paginationData.perPage).to.equal(100);", - " pm.expect(paginationData.totalEntries).to.equal(2);", + " pm.expect(paginationData.totalEntries).to.gte(2);", "});", "" ], diff --git a/dotCMS/src/integration-test/java/com/dotmarketing/portlets/workflows/business/WorkflowAPITest.java b/dotCMS/src/integration-test/java/com/dotmarketing/portlets/workflows/business/WorkflowAPITest.java index 95602ffbd654..172edfb9189a 100644 --- a/dotCMS/src/integration-test/java/com/dotmarketing/portlets/workflows/business/WorkflowAPITest.java +++ b/dotCMS/src/integration-test/java/com/dotmarketing/portlets/workflows/business/WorkflowAPITest.java @@ -27,6 +27,7 @@ import com.dotcms.datagen.TestDataUtils; import com.dotcms.datagen.TestUserUtils; import com.dotcms.datagen.TestWorkflowUtils; +import com.dotcms.datagen.UserDataGen; import com.dotcms.system.event.local.model.EventSubscriber; import com.dotcms.util.CollectionsUtils; import com.dotcms.util.IntegrationTestInitService; @@ -585,6 +586,38 @@ public static void prepare() throws Exception { } + /** + * Method to test: APILocator.getPermissionAPI().doesUserHavePermission(user, permissionType, content) + * Given Scenario: Creates a limited user with BE role and a content type. + * Then saves a permission with full grants, the permission should be allowed from the content type. + * ExpectedResult: The permission should be allowed to the contentlet by using owner role. + */ + @Test() + public void send_permission_with_limited_user_Test() + throws DotDataException, DotSecurityException, AlreadyExistException { + + // 1 create a limited user + final long time = System.currentTimeMillis(); + final Role backendRole = APILocator.getRoleAPI().loadBackEndUserRole(); + final Role cmsOwnerRole = APILocator.getRoleAPI().loadCMSOwnerRole(); + final User wflimitedUser = new UserDataGen().active(true).emailAddress("wflimiteduser" + time + "@dotcms.com").roles(backendRole).nextPersisted(); + // create a content type with cms owner full permissions + final ContentType testContentType = new ContentTypeDataGen().velocityVarName("testcontenttype" + time) + .host(APILocator.systemHost()).name("testcontenttype" + time).nextPersisted(); + final int permissionType = PermissionAPI.PERMISSION_USE | PermissionAPI.PERMISSION_EDIT | + PermissionAPI.PERMISSION_PUBLISH | PermissionAPI.PERMISSION_EDIT_PERMISSIONS; + final Permission permission = new Permission(testContentType.getPermissionId(), cmsOwnerRole.getId(), permissionType); + APILocator.getPermissionAPI().save(permission, testContentType, APILocator.systemUser(), false); + // create a contentlet of the type given permissions to the someone else + final Contentlet contentlet = new ContentletDataGen(testContentType).user(wflimitedUser).next(); + final List permissions = new ArrayList<>(); + permissions.add(new Permission(null, cmsOwnerRole.getId(), PermissionAPI.Type.USE.getType())); + APILocator.getContentletAPI().checkin(contentlet, permissions, wflimitedUser, false); + // check if the contentlet has the right permissions + final List contentletPermissions = APILocator.getPermissionAPI().getPermissions(contentlet); + Assert.assertNotNull(contentletPermissions); + Assert.assertTrue(contentletPermissions.stream().anyMatch(p -> p.getRoleId().equals(cmsOwnerRole.getId()))); + } @Test() public void delete_action_and_dependencies_Test() throws DotDataException, DotSecurityException, AlreadyExistException { diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/saml/v1/SAMLHelper.java b/dotCMS/src/main/java/com/dotcms/auth/providers/saml/v1/SAMLHelper.java index 3def4d22b589..3d064c529d74 100644 --- a/dotCMS/src/main/java/com/dotcms/auth/providers/saml/v1/SAMLHelper.java +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/saml/v1/SAMLHelper.java @@ -3,6 +3,7 @@ import com.dotcms.cms.login.LoginServiceAPI; import com.dotcms.company.CompanyAPI; import com.dotcms.filters.interceptor.saml.SamlWebInterceptor; +import com.dotcms.rest.api.v1.user.UserHelper; import com.dotcms.saml.Attributes; import com.dotcms.saml.DotSamlConstants; import com.dotcms.saml.DotSamlException; @@ -439,7 +440,7 @@ private void handleRoles(final User user, final Attributes attributesBean, this.addRolesFromIDP(user, attributesBean, identityProviderConfiguration, buildRolesStrategy); // Add SAML User role - this.addRole(user, DotSamlConstants.DOTCMS_SAML_USER_ROLE, true, true); + UserHelper.getInstance().addRole(user, DotSamlConstants.DOTCMS_SAML_USER_ROLE, true, true); Logger.debug(this, ()->"Default SAML User role has been assigned"); // the only strategy that does not include the saml user role is the "idp" @@ -453,7 +454,7 @@ private void handleRoles(final User user, final Attributes attributesBean, for (final String roleExtra : rolesExtra){ - this.addRole(user, roleExtra, false, false); + UserHelper.getInstance().addRole(user, roleExtra, false, false); Logger.debug(this, () -> "Optional user role: " + this.getSamlConfigurationService().getConfigAsString(identityProviderConfiguration, SamlName.DOTCMS_SAML_OPTIONAL_USER_ROLE) + " has been assigned"); @@ -585,66 +586,9 @@ private void addRole(final User user, final String removeRolePrefix, final Strin roleObject.replaceFirst(removeRolePrefix, StringUtils.EMPTY): roleObject; - addRole(user, roleKey, false, false); + UserHelper.getInstance().addRole(user, roleKey, false, false); } - private void addRole(final User user, final String roleKey, final boolean createRole, final boolean isSystem) - throws DotDataException { - - Role role = this.roleAPI.loadRoleByKey(roleKey); - - // create the role, in case it does not exist - if (role == null && createRole) { - Logger.info(this, "Role with key '" + roleKey + "' was not found. Creating it..."); - role = createNewRole(roleKey, isSystem); - } - - if (null != role) { - if (!this.roleAPI.doesUserHaveRole(user, role)) { - this.roleAPI.addRoleToUser(role, user); - Logger.debug(this, "Role named '" + role.getName() + "' has been added to user: " + user.getEmailAddress()); - } else { - Logger.debug(this, - "User '" + user.getEmailAddress() + "' already has the role '" + role + "'. Skipping assignment..."); - } - } else { - Logger.debug(this, "Role named '" + roleKey + "' does NOT exists in dotCMS. Ignoring it..."); - } - } - - private Role createNewRole(String roleKey, boolean isSystem) throws DotDataException { - Role role = new Role(); - role.setName(roleKey); - role.setRoleKey(roleKey); - role.setEditUsers(true); - role.setEditPermissions(false); - role.setEditLayouts(false); - role.setDescription(""); - role.setId(UUIDGenerator.generateUuid()); - - // Setting SYSTEM role as a parent - role.setSystem(isSystem); - Role parentRole = roleAPI.loadRoleByKey(Role.SYSTEM); - role.setParent(parentRole.getId()); - - String date = DateUtil.getCurrentDate(); - - ActivityLogger.logInfo(ActivityLogger.class, getClass() + " - Adding Role", - "Date: " + date + "; " + "Role:" + roleKey); - AdminLogger.log(AdminLogger.class, getClass() + " - Adding Role", "Date: " + date + "; " + "Role:" + roleKey); - - try { - role = roleAPI.save(role, role.getId()); - } catch (DotDataException | DotStateException e) { - ActivityLogger.logInfo(ActivityLogger.class, getClass() + " - Error adding Role", - "Date: " + date + "; " + "Role:" + roleKey); - AdminLogger.log(AdminLogger.class, getClass() + " - Error adding Role", - "Date: " + date + "; " + "Role:" + roleKey); - throw e; - } - - return role; - } private String toString(final String... rolePatterns) { return null == rolePatterns ? DotSamlConstants.NULL : Arrays.asList(rolePatterns).toString(); diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java index 505aff14e817..96cbe2461e5c 100644 --- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java @@ -221,12 +221,6 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -import static com.dotcms.exception.ExceptionUtil.bubbleUpException; -import static com.dotcms.exception.ExceptionUtil.getLocalizedMessageOrDefault; -import static com.dotmarketing.business.PermissionAPI.PERMISSION_CAN_ADD_CHILDREN; -import static com.dotmarketing.portlets.contentlet.model.Contentlet.URL_MAP_FOR_CONTENT_KEY; -import static com.dotmarketing.portlets.personas.business.PersonaAPI.DEFAULT_PERSONA_NAME_KEY; - /** * Implementation class for the {@link ContentletAPI} interface. * @@ -242,6 +236,8 @@ public class ESContentletAPIImpl implements ContentletAPI { private static final String NEVER_EXPIRE = "NeverExpire"; private static final String CHECKIN_IN_PROGRESS = "__checkin_in_progress__"; + private static final String IS_NEW_CONTENT = "__IS_NEW_CONTENT__"; + private final ContentletIndexAPIImpl indexAPI; private final ESContentFactoryImpl contentFactory; private final PermissionAPI permissionAPI; @@ -978,8 +974,14 @@ private void internalPublish(final Contentlet contentlet, final User user, ? contentlet.getInode() : "Unknown")); //If the contentlet has CMS Owner Publish permission on it, the user creating the new contentlet is allowed to publish - final List roles = permissionAPI.getRoles(contentlet.getPermissionId(), - PermissionAPI.PERMISSION_PUBLISH, "CMS Owner", 0, -1); + List roles = permissionAPI.getRoles(contentlet.getPermissionId(), + PermissionAPI.PERMISSION_PUBLISH, Role.CMS_OWNER_ROLE, 0, -1); + if (roles.isEmpty() && + isNewContentlet(contentlet)) { + + roles = permissionAPI.getRoles(contentlet.getContentType().getPermissionId(), + PermissionAPI.PERMISSION_PUBLISH, Role.CMS_OWNER_ROLE, 0, -1); + } final Role cmsOwner = APILocator.getRoleAPI().loadCMSOwnerRole(); if (roles.size() > 0) { @@ -1052,6 +1054,20 @@ private void internalPublish(final Contentlet contentlet, final User user, //contentletSystemEventUtil.pushPublishEvent(contentlet); } + private boolean isNewContentlet(final Contentlet contentlet) throws DotDataException { + return contentlet.isNew() || null == contentlet.getIdentifier() + || ConversionUtils.toBooleanFromDb(contentlet.getMap().getOrDefault(IS_NEW_CONTENT, false)) + || hasOnlyOneVersion(contentlet); + } + + private boolean hasOnlyOneVersion(final Contentlet contentlet) throws DotDataException { + + final int versionCount = new DotConnect().setSQL("SELECT count(*) as count FROM (SELECT 1 FROM contentlet WHERE identifier =? LIMIT 2) AS t") + .addParam(contentlet.getIdentifier()) + .loadInt("count"); + return versionCount <= 1; + } + @Override public void publishAssociated(Contentlet contentlet, boolean isNew) throws DotSecurityException, DotDataException, @@ -5634,7 +5650,10 @@ private Contentlet internalCheckin(Contentlet contentlet, } new ContentletLoader().invalidate(contentlet); - + if (isNewContent) { + // we mark as new. b/c next actions on the pipe may need to know if it is new or not. + contentlet.setProperty(IS_NEW_CONTENT, true); + } } catch (Exception e) { if (createNewVersion && workingContentlet != null && UtilMethods.isSet( workingContentlet.getInode())) { @@ -9751,7 +9770,9 @@ public boolean canLock(final Contentlet contentlet, final User user, return true; } else if (!APILocator.getPermissionAPI().doesUserHavePermission( - contentlet, PermissionAPI.PERMISSION_EDIT, user, respectFrontendRoles)) { + contentlet, PermissionAPI.PERMISSION_EDIT, user, respectFrontendRoles) + && !APILocator.getPermissionAPI().doesUserHavePermission( + contentlet.getContentType(), PermissionAPI.PERMISSION_EDIT, user, respectFrontendRoles)) { throw new DotLockException("User: " + (user != null ? user.getUserId() : "Unknown") + " does not have Edit Permissions to lock content: " + (contentlet != null diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeInitializer.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeInitializer.java index 953f3f705d2a..1a02c8d62d12 100644 --- a/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeInitializer.java +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeInitializer.java @@ -15,14 +15,18 @@ import com.dotmarketing.business.Role; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.folders.model.Folder; +import com.dotmarketing.portlets.structure.model.Structure; import com.dotmarketing.portlets.workflows.business.WorkflowAPI; +import com.dotmarketing.quartz.job.ResetPermissionsJob; import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; import io.vavr.Lazy; import io.vavr.control.Try; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -121,15 +125,23 @@ private void checkDefaultPermissions(final ContentType savedContentType) { try { + // Add CMS Owner Permissions final int permissionType = PermissionAPI.PERMISSION_USE | PermissionAPI.PERMISSION_EDIT | PermissionAPI.PERMISSION_PUBLISH | PermissionAPI.PERMISSION_EDIT_PERMISSIONS; final Role backendRole = APILocator.getRoleAPI().loadCMSOwnerRole(); - if (!APILocator.getPermissionAPI().doesRoleHavePermission( - savedContentType, permissionType, backendRole)) { + if (!APILocator.getPermissionAPI().doesRoleHavePermission(savedContentType, permissionType, backendRole)) { + + // remove all current permissions + APILocator.getPermissionAPI().removePermissions(savedContentType); Logger.info(this, "Adding default permissions to the Favorite Page Content Type..."); - final Permission permission = new Permission(savedContentType.getPermissionId(), backendRole.getId(),permissionType); - APILocator.getPermissionAPI().save(permission, savedContentType, APILocator.systemUser(), false); + final List newSetOfPermissions = new ArrayList<>(); + // this is the individual permission + newSetOfPermissions.add(new Permission(savedContentType.getPermissionId(), backendRole.getId(), permissionType, true)); + // this is the inheritance permission + newSetOfPermissions.add(new Permission(Contentlet.class.getCanonicalName(), savedContentType.getPermissionId(), backendRole.getId(), permissionType, true)); + APILocator.getPermissionAPI().assignPermissions(newSetOfPermissions, savedContentType, APILocator.systemUser(), false); + ResetPermissionsJob.triggerJobImmediately(savedContentType); } } catch (DotDataException | DotSecurityException e) { diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/LayoutMapResponseEntityView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/LayoutMapResponseEntityView.java new file mode 100644 index 000000000000..85d1da12d5bc --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/LayoutMapResponseEntityView.java @@ -0,0 +1,17 @@ +package com.dotcms.rest.api.v1.system.role; + +import com.dotcms.rest.ResponseEntityView; + +import java.util.List; +import java.util.Map; + +/** + * LayoutMapResponseEntityView + * @author jsanca + */ +public class LayoutMapResponseEntityView extends ResponseEntityView>> { + + public LayoutMapResponseEntityView(final List> entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleForm.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleForm.java new file mode 100644 index 000000000000..929d95519784 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleForm.java @@ -0,0 +1,130 @@ +package com.dotcms.rest.api.v1.system.role; + +import com.dotcms.repackage.javax.validation.constraints.NotNull; +import com.dotcms.rest.api.Validated; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +/** + * Form to save a new role + * @author jsanca + */ +@JsonDeserialize(builder = RoleForm.Builder.class) +public class RoleForm extends Validated { + + @NotNull + private final String roleName; + + private final String roleKey; + + private final String parentRoleId; + private final boolean canEditUsers; + + private final boolean canEditPermissions; + private final boolean canEditLayouts; + + private final String description; + + private RoleForm(final RoleForm.Builder builder) { + super(); + roleName = builder.roleName; + roleKey = builder.roleKey; + parentRoleId = builder.parentRoleId; + canEditUsers = builder.canEditUsers; + canEditPermissions = builder.canEditPermissions; + canEditLayouts = builder.canEditLayouts; + description = builder.description; + checkValid(); + } + + public String getRoleName() { + return roleName; + } + + public String getRoleKey() { + return roleKey; + } + + public String getParentRoleId() { + return parentRoleId; + } + + public boolean isCanEditUsers() { + return canEditUsers; + } + + public boolean isCanEditPermissions() { + return canEditPermissions; + } + + public boolean isCanEditLayouts() { + return canEditLayouts; + } + + public String getDescription() { + return description; + } + + public static final class Builder { + + @JsonProperty(required = true) + private String roleName; + + @JsonProperty() + private String roleKey; + + @JsonProperty() + private String parentRoleId; + + @JsonProperty() + private boolean canEditUsers; + + @JsonProperty() + private boolean canEditPermissions; + + @JsonProperty() + private boolean canEditLayouts; + + @JsonProperty() + private String description; + + public RoleForm.Builder roleName(final String roleName) { + this.roleName = roleName; + return this; + } + + public RoleForm.Builder roleKey(final String roleKey) { + this.roleKey = roleKey; + return this; + } + + public RoleForm.Builder parentRoleId(final String parentRoleId) { + this.parentRoleId = parentRoleId; + return this; + } + + public RoleForm.Builder canEditUsers(final boolean canEditUsers) { + this.canEditUsers = canEditUsers; + return this; + } + + public RoleForm.Builder canEditPermissions(final boolean canEditPermissions) { + this.canEditPermissions = canEditPermissions; + return this; + } + + public RoleForm.Builder canEditLayouts(final boolean canEditLayouts) { + this.canEditLayouts = canEditLayouts; + return this; + } + + public RoleForm.Builder description(final String description) { + this.description = description; + return this; + } + public RoleForm build() { + return new RoleForm(this); + } + } +} + diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleLayoutForm.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleLayoutForm.java index 469e18ec398c..84d1ae19bcd4 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleLayoutForm.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleLayoutForm.java @@ -6,6 +6,10 @@ import java.util.Set; +/** + * Form to save a layout on a role + * @author jsanca + */ @JsonDeserialize(builder = RoleLayoutForm.Builder.class) public class RoleLayoutForm extends Validated { diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleResource.java index 68a44349237e..19893c6bc0fe 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleResource.java @@ -1,6 +1,8 @@ package com.dotcms.rest.api.v1.system.role; import com.dotcms.repackage.com.google.common.annotations.VisibleForTesting; +import com.dotcms.repackage.org.directwebremoting.WebContext; +import com.dotcms.repackage.org.directwebremoting.WebContextFactory; import com.dotcms.rest.InitDataObject; import com.dotcms.rest.ResponseEntityView; import com.dotcms.rest.WebResource; @@ -8,19 +10,30 @@ import com.dotcms.rest.exception.mapper.ExceptionMapperUtil; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.ApiProvider; +import com.dotmarketing.business.DotStateException; +import com.dotmarketing.business.Layout; import com.dotmarketing.business.LayoutAPI; import com.dotmarketing.business.Role; import com.dotmarketing.business.RoleAPI; import com.dotmarketing.business.UserAPI; +import com.dotmarketing.business.web.UserWebAPI; +import com.dotmarketing.business.web.WebAPILocator; import com.dotmarketing.exception.DoesNotExistException; import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.exception.RoleNameException; import com.dotmarketing.portlets.user.ajax.UserAjax; +import com.dotmarketing.util.ActivityLogger; +import com.dotmarketing.util.AdminLogger; +import com.dotmarketing.util.DateUtil; import com.dotmarketing.util.Logger; import com.dotmarketing.util.SecurityLogger; import com.dotmarketing.util.StringUtils; import com.dotmarketing.util.UtilMethods; +import com.liferay.portal.PortalException; +import com.liferay.portal.SystemException; import com.liferay.portal.language.LanguageException; import com.liferay.portal.language.LanguageUtil; import com.liferay.portal.model.User; @@ -30,6 +43,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.vavr.control.Try; import org.apache.commons.beanutils.BeanUtils; import java.io.IOException; @@ -40,6 +54,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -162,7 +177,7 @@ public Response deleteRoleLayouts( final String roleId = roleLayoutForm.getRoleId(); final Set layoutIds = roleLayoutForm.getLayoutIds(); - final Role role = roleAPI.loadRoleById(roleId); + final Role role = roleAPI.loadRoleById(roleId); final LayoutAPI layoutAPI = APILocator.getLayoutAPI(); Logger.debug(this, ()-> "Deleting the layouts : " + layoutIds + " to the role: " + roleId); @@ -179,6 +194,73 @@ public Response deleteRoleLayouts( } } + /** + * Add a new role + * Only admins can add roles. + */ + @POST + @Produces("application/json") + public Response addNewRole( + final @Context HttpServletRequest request, + final @Context HttpServletResponse response, + final RoleForm roleForm) throws DotDataException, DotSecurityException { + + final InitDataObject initDataObject = new WebResource.InitBuilder(this.webResource) + .requiredFrontendUser(false).rejectWhenNoUser(true) + .requiredBackendUser(true).requiredPortlet("roles") + .requestAndResponse(request, response).init(); + + if (this.roleAPI.doesUserHaveRole(initDataObject.getUser(), this.roleAPI.loadCMSAdminRole())) { + + final User user = initDataObject.getUser(); + Role role = new Role(); + role.setName(roleForm.getRoleName()); + role.setRoleKey(roleForm.getRoleKey()); + role.setEditUsers(roleForm.isCanEditUsers()); + role.setEditPermissions(roleForm.isCanEditPermissions()); + role.setEditLayouts(roleForm.isCanEditLayouts()); + role.setDescription(roleForm.getDescription()); + + if(Objects.nonNull(roleForm.getParentRoleId())) { + + final Role parentRole = roleAPI.loadRoleById(roleForm.getParentRoleId()); + role.setParent(parentRole.getId()); + } + + final String date = DateUtil.getCurrentDate(); + + ActivityLogger.logInfo(getClass(), "Adding Role", "Date: " + date + "; "+ "User:" + user.getUserId()); + AdminLogger.log(getClass(), "Adding Role", "Date: " + date + "; "+ "User:" + user.getUserId()); + + try { + + role = roleAPI.save(role); + } catch(RoleNameException e) { + + ActivityLogger.logInfo(getClass(), "Error Adding Role. Invalid Name", "Date: " + date + "; "+ "User:" + user.getUserId()); + AdminLogger.log(getClass(), "Error Adding Role. Invalid Name", "Date: " + date + "; "+ "User:" + user.getUserId()); + throw new DotDataException( + Try.of(()->LanguageUtil.get(initDataObject.getUser(),"Role-Save-Name-Failed")).getOrElse("Role Name not valid"), + "Role-Save-Name-Failed", e); + + } catch(DotDataException | DotStateException e) { + ActivityLogger.logInfo(getClass(), "Error Adding Role", "Date: " + date + "; "+ "User:" + user.getUserId()); + AdminLogger.log(getClass(), "Error Adding Role", "Date: " + date + "; "+ "User:" + user.getUserId()); + throw e; + } + + ActivityLogger.logInfo(getClass(), "Role Created", "Date: " + date + "; "+ "User:" + user.getUserId() + "; RoleID: " + role.getId() ); + AdminLogger.log(getClass(), "Role Created", "Date: " + date + "; "+ "User:" + user.getUserId() + "; RoleID: " + role.getId() ); + + return Response.ok(new RoleResponseEntityView(role.toMap())).build(); + } + + final String remoteIp = request.getRemoteHost(); + SecurityLogger.logInfo(UserAjax.class, "unauthorized attempt to call create a role by user "+ + initDataObject.getUser().getUserId() + " from " + remoteIp); + throw new DotSecurityException("User: '" + initDataObject.getUser().getUserId() + "' not authorized"); + } + /** * Saves set of layout into a role * The user must have to be a BE and has to have access to roles portlet @@ -200,7 +282,7 @@ public Response saveRoleLayouts( final String roleId = roleLayoutForm.getRoleId(); final Set layoutIds = roleLayoutForm.getLayoutIds(); - final Role role = roleAPI.loadRoleById(roleId); + final Role role = roleAPI.loadRoleById(roleId); final LayoutAPI layoutAPI = APILocator.getLayoutAPI(); Logger.debug(this, ()-> "Saving the layouts : " + layoutIds + " to the role: " + roleId); @@ -208,13 +290,12 @@ public Response saveRoleLayouts( return Response.ok(new ResponseEntityView(map("savedLayouts", this.roleHelper.saveRoleLayouts(role, layoutIds, layoutAPI, this.roleAPI, APILocator.getSystemEventsAPI())))).build(); - } else { - - final String remoteIp = request.getRemoteHost(); - SecurityLogger.logInfo(UserAjax.class, "unauthorized attempt to call save role layouts by user "+ - initDataObject.getUser().getUserId() + " from " + remoteIp); - throw new DotSecurityException("User: '" + initDataObject.getUser().getUserId() + "' not authorized"); } + + final String remoteIp = request.getRemoteHost(); + SecurityLogger.logInfo(UserAjax.class, "unauthorized attempt to call save role layouts by user "+ + initDataObject.getUser().getUserId() + " from " + remoteIp); + throw new DotSecurityException("User: '" + initDataObject.getUser().getUserId() + "' not authorized"); } /** @@ -259,10 +340,10 @@ public Response findRoleLayouts( @Produces("application/json") @SuppressWarnings("unchecked") public Response loadUsersAndRolesByRoleId(@Context final HttpServletRequest request, - @Context final HttpServletResponse response, - @PathParam ("roleid") final String roleId, - @DefaultValue("false") @QueryParam("roleHierarchyForAssign") final boolean roleHierarchyForAssign, - @QueryParam ("name") final String roleNameToFilter) throws DotDataException, DotSecurityException { + @Context final HttpServletResponse response, + @PathParam ("roleid") final String roleId, + @DefaultValue("false") @QueryParam("roleHierarchyForAssign") final boolean roleHierarchyForAssign, + @QueryParam ("name") final String roleNameToFilter) throws DotDataException, DotSecurityException { new WebResource.InitBuilder(this.webResource).requiredBackendUser(true) .requiredFrontendUser(false).requestAndResponse(request, response) @@ -326,9 +407,9 @@ private final List filterRoleList(final String roleNameToFilter, final Lis @Path("/{roleid}") @Produces("application/json") public Response loadRoleByRoleId(@Context final HttpServletRequest request, - @Context final HttpServletResponse response, - @PathParam ("roleid") final String roleId, - @DefaultValue("true") @QueryParam("loadChildrenRoles") final boolean loadChildrenRoles) + @Context final HttpServletResponse response, + @PathParam ("roleid") final String roleId, + @DefaultValue("true") @QueryParam("loadChildrenRoles") final boolean loadChildrenRoles) throws DotDataException, DotSecurityException { new WebResource.InitBuilder(this.webResource).requiredBackendUser(true) @@ -366,8 +447,8 @@ public Response loadRoleByRoleId(@Context final HttpServletRequest request, @GET @Produces("application/json") public Response loadRootRoles(@Context final HttpServletRequest request, - @Context final HttpServletResponse response, - @DefaultValue("true") @QueryParam("loadChildrenRoles") final boolean loadChildrenRoles) + @Context final HttpServletResponse response, + @DefaultValue("true") @QueryParam("loadChildrenRoles") final boolean loadChildrenRoles) throws DotDataException, DotSecurityException { new WebResource.InitBuilder(this.webResource).requiredBackendUser(true) @@ -433,21 +514,21 @@ public Response loadRootRoles(@Context final HttpServletRequest request, content = @Content(mediaType = "application/json", schema = @Schema(implementation = ResponseEntitySmallRoleView.class)))}) public Response searchRoles(@Context final HttpServletRequest request, - @Context final HttpServletResponse response, - @Parameter(name = "searchName", description = "Value to filter by role name") - @DefaultValue("") @QueryParam("searchName") final String searchName, + @Context final HttpServletResponse response, + @Parameter(name = "searchName", description = "Value to filter by role name") + @DefaultValue("") @QueryParam("searchName") final String searchName, @Parameter(name = "searchKey", description = "Value to filter by role key") - @DefaultValue("") @QueryParam("searchKey") final String searchKey, + @DefaultValue("") @QueryParam("searchKey") final String searchKey, @Parameter(name = "roleId", description = "Value for specific role id") - @DefaultValue("") @QueryParam("roleId") final String roleId, + @DefaultValue("") @QueryParam("roleId") final String roleId, @Parameter(name = "start", description = "Offset on pagination") - @DefaultValue("0") @QueryParam("start") final int startParam, + @DefaultValue("0") @QueryParam("start") final int startParam, @Parameter(name = "count", description = "Size on pagination") - @DefaultValue("20") @QueryParam("count") final int count, + @DefaultValue("20") @QueryParam("count") final int count, @Parameter(name = "includeUserRoles", description = "Set false if do not want to include user rules") - @DefaultValue("true") @QueryParam("includeUserRoles") final boolean includeUserRoles, + @DefaultValue("true") @QueryParam("includeUserRoles") final boolean includeUserRoles, @Parameter(name = "includeWorkflowRoles", description = "Set to true if want to include the workflow roles") - @DefaultValue("false") @QueryParam("includeWorkflowRoles") final boolean includeWorkflowRoles) + @DefaultValue("false") @QueryParam("includeWorkflowRoles") final boolean includeWorkflowRoles) throws DotDataException, DotSecurityException, LanguageException, IOException, InvocationTargetException, IllegalAccessException { final InitDataObject initDataObject = new WebResource.InitBuilder(this.webResource).requiredBackendUser(true) @@ -455,7 +536,7 @@ public Response searchRoles(@Context final HttpServletRequest request, .rejectWhenNoUser(true).init(); Logger.debug(this, ()-> "Searching role, searchName: " + searchName + ", searchKey: " + searchKey + ", roleId: " + roleId - + ", start: " + startParam + ", count: " + count + ", includeUserRoles: " + includeUserRoles + ", includeWorkflowRoles: " + includeWorkflowRoles); + + ", start: " + startParam + ", count: " + count + ", includeUserRoles: " + includeUserRoles + ", includeWorkflowRoles: " + includeWorkflowRoles); int start = startParam; final Role cmsAnonOrig = this.roleAPI.loadCMSAnonymousRole(); @@ -487,6 +568,53 @@ public Response searchRoles(@Context final HttpServletRequest request, return Response.ok(new ResponseEntitySmallRoleView(rolesToView(roleList))).build(); } + + /** + * Get all layouts + * + * @return {@link LayoutMapResponseEntityView} List of Layouts + * @throws DotDataException + * @throws DotSecurityException + */ + @GET + @Path("/layouts") + @Produces("application/json") + public Response getAllLayouts(@Context final HttpServletRequest request, + @Context final HttpServletResponse response) + throws DotDataException, LanguageException, DotRuntimeException, PortalException, SystemException { + + final List> layoutsMap = new ArrayList<>(); + final List layouts = APILocator.getLayoutAPI().findAllLayouts(); + + for(final Layout layout: layouts) { + + final Map layoutMap = layout.toMap(); + layoutMap.put("portletTitles", getPorletTitlesFromLayout(layout, request)); + layoutsMap.add(layoutMap); + } + + return Response.ok(new LayoutMapResponseEntityView(layoutsMap)).build(); + } + + private List getPorletTitlesFromLayout (final Layout layout, + final HttpServletRequest request) + throws LanguageException, DotRuntimeException, PortalException, SystemException { + + final List portletIds = layout.getPortletIds(); + final List portletTitles = new ArrayList<>(); + if(portletIds != null) { + for(final String portletId: portletIds) { + + final String portletTitle = LanguageUtil.get( + WebAPILocator.getUserWebAPI().getLoggedInUser(request), + "com.dotcms.repackage.javax.portlet.title." + portletId); + portletTitles.add(portletTitle); + } + } + + return portletTitles; + } + private boolean fillRoles(final String searchName, final int count, final int startParam, final Role cmsAnon, final String cmsAnonName, final List roleList, final boolean includeUserRoles, final String searchKey) throws DotDataException { @@ -497,8 +625,8 @@ private boolean fillRoles(final String searchName, final int count, final int st while (roleList.size() < count) { final List roles = StringUtils.isSet(searchKey)? - this.roleAPI.findRolesByKeyFilterLeftWildcard(searchKey, start, count): - this.roleAPI.findRolesByFilterLeftWildcard(searchName, start, count); + this.roleAPI.findRolesByKeyFilterLeftWildcard(searchKey, start, count): + this.roleAPI.findRolesByFilterLeftWildcard(searchName, start, count); if (roles.isEmpty()) { break; diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleResponseEntityView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleResponseEntityView.java new file mode 100644 index 000000000000..ebcb2e81fd29 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleResponseEntityView.java @@ -0,0 +1,16 @@ +package com.dotcms.rest.api.v1.system.role; + +import com.dotcms.rest.ResponseEntityView; + +import java.util.Map; + +/** + * View for returning a role + * @author jsanca + */ +public class RoleResponseEntityView extends ResponseEntityView> { + + public RoleResponseEntityView(final Map entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/CreateUserForm.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/CreateUserForm.java new file mode 100644 index 000000000000..3072385ff182 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/CreateUserForm.java @@ -0,0 +1,215 @@ +package com.dotcms.rest.api.v1.user; + +import com.dotcms.repackage.javax.validation.constraints.NotNull; +import com.dotcms.repackage.org.hibernate.validator.constraints.NotBlank; +import com.dotcms.rest.api.Validated; +import com.dotmarketing.util.UtilMethods; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Encapsulates the information to create an User + */ +@JsonDeserialize(builder = CreateUserForm.Builder.class) +public final class CreateUserForm extends Validated { + + private String userId; + private final boolean active; + @NotNull + @NotBlank + private final String firstName; + private final String middleName; + @NotNull + @NotBlank + private final String lastName; + private final String nickName; + @NotNull + @NotBlank + private final String email; + private final boolean male; + private final String birthday; + private final long languageId; + private final String timeZoneId; + private final char[] password; + + private final Map additionalInfo; + + private final List roles; + + private CreateUserForm(CreateUserForm.Builder builder) { + + this.active = builder.active; + this.firstName = builder.firstName; + this.middleName = builder.middleName; + this.lastName = builder.lastName; + this.nickName = builder.nickName; + this.email = builder.email; + this.male = builder.male; + this.birthday = builder.birthday; + this.languageId = builder.languageId; + this.timeZoneId = builder.timeZoneId; + this.password = builder.password; + this.additionalInfo = builder.additionalInfo; + this.roles = UtilMethods.isSet(builder.roles)?builder.roles: Collections.emptyList(); + this.userId = builder.userId; + + checkValid(); + if (!UtilMethods.isSet(this.password)) { + throw new IllegalArgumentException("Password can not be null"); + } + } + + public String getUserId() { + return userId; + } + + public boolean isActive() { + return active; + } + + public String getFirstName() { + return firstName; + } + + public String getMiddleName() { + return middleName; + } + + public String getLastName() { + return lastName; + } + + public String getNickName() { + return nickName; + } + + public String getEmail() { + return email; + } + + public boolean isMale() { + return male; + } + + public String getBirthday() { + return birthday; + } + + public long getLanguageId() { + return languageId; + } + + public String getTimeZoneId() { + return timeZoneId; + } + + public char[] getPassword() { + return password; + } + + public Map getAdditionalInfo() { + return additionalInfo; + } + + public List getRoles() { + return roles; + } + + public static final class Builder { + @JsonProperty private String userId; + @JsonProperty private boolean active; + @JsonProperty private String firstName; + @JsonProperty private String middleName; + @JsonProperty private String lastName; + @JsonProperty private String nickName; + @JsonProperty private String email; + @JsonProperty private boolean male; + @JsonProperty private String birthday; + @JsonProperty private long languageId = -1l; + @JsonProperty private String timeZoneId; + @JsonProperty private char[] password; + @JsonProperty private Map additionalInfo; + + @JsonProperty private List roles; + public Builder() { + } + + public Builder userId(String userId) { + this.userId = userId; + return this; + } + public Builder roles(List roles) { + this.roles = roles; + return this; + } + + public Builder active(boolean active) { + this.active = active; + return this; + } + + public Builder firstName(String firstName) { + this.firstName = firstName; + return this; + } + + public Builder middleName(String middleName) { + this.middleName = middleName; + return this; + } + + public Builder lastName(String lastName) { + this.lastName = lastName; + return this; + } + + public Builder nickName(String nickName) { + this.nickName = nickName; + return this; + } + + public Builder email(String email) { + this.email = email; + return this; + } + + public Builder male(boolean male) { + this.male = male; + return this; + } + + public Builder birthday(String birthday) { + this.birthday = birthday; + return this; + } + + public Builder languageId(long languageId) { + this.languageId = languageId; + return this; + } + + public Builder timeZoneId(String timeZoneId) { + this.timeZoneId = timeZoneId; + return this; + } + + public Builder password(char[] password) { + this.password = password; + return this; + } + + public Builder additionalInfo(Map additionalInfo) { + this.additionalInfo = additionalInfo; + return this; + } + + public CreateUserForm build() { + return new CreateUserForm(this); + } + } +} + diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserHelper.java new file mode 100644 index 000000000000..fbdfaf15b2a4 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserHelper.java @@ -0,0 +1,114 @@ +package com.dotcms.rest.api.v1.user; + +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.DotStateException; +import com.dotmarketing.business.Role; +import com.dotmarketing.business.RoleAPI; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.util.ActivityLogger; +import com.dotmarketing.util.AdminLogger; +import com.dotmarketing.util.DateUtil; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UUIDGenerator; +import com.google.common.annotations.VisibleForTesting; +import com.liferay.portal.model.User; + +/** + * Helper to group SAML and User Resource common methods. + */ +public class UserHelper { + + private static class SingletonHolder { + private static final UserHelper INSTANCE = new UserHelper(); + } + + /** + * Get the instance. + * @return UserHelper + */ + public static UserHelper getInstance() { + + return UserHelper.SingletonHolder.INSTANCE; + } // getInstance. + + private final RoleAPI roleAPI; + + @VisibleForTesting + public UserHelper() { + this(APILocator.getRoleAPI()); + } + + @VisibleForTesting + public UserHelper(final RoleAPI roleAPI) { + this.roleAPI = roleAPI; + } + + /** + * Adds a new role to the user. + * @param user {@link User} user to add the role. + * @param roleKey {@link String} role key to add. + * @param createRole {@link Boolean} create the role if it does not exist. + * @param isSystem {@link Boolean} if it is system role + * @throws DotDataException + */ + public void addRole(final User user, final String roleKey, final boolean createRole, final boolean isSystem) + throws DotDataException { + + Role role = this.roleAPI.loadRoleByKey(roleKey); + + // create the role, in case it does not exist + if (role == null && createRole) { + Logger.info(this, "Role with key '" + roleKey + "' was not found. Creating it..."); + role = createNewRole(roleKey, isSystem); + } + + if (null != role) { + if (!this.roleAPI.doesUserHaveRole(user, role)) { + this.roleAPI.addRoleToUser(role, user); + Logger.debug(this, "Role named '" + role.getName() + "' has been added to user: " + user.getEmailAddress()); + } else { + Logger.debug(this, + "User '" + user.getEmailAddress() + "' already has the role '" + role + "'. Skipping assignment..."); + } + } else { + Logger.debug(this, "Role named '" + roleKey + "' does NOT exists in dotCMS. Ignoring it..."); + } + } + + /** + * Creates a new role. + * @param roleKey {@link String} role key + * @param isSystem {@link Boolean} if it is system role + * @return + * @throws DotDataException + */ + public Role createNewRole(final String roleKey, final boolean isSystem) throws DotDataException { + + Role role = new Role(); + role.setName(roleKey); + role.setRoleKey(roleKey); + role.setEditUsers(true); + role.setEditPermissions(true); + role.setEditLayouts(true); + role.setDescription(""); + role.setId(UUIDGenerator.generateUuid()); + + final String date = DateUtil.getCurrentDate(); + + ActivityLogger.logInfo(ActivityLogger.class, getClass() + " - Adding Role", + "Date: " + date + "; " + "Role:" + roleKey); + AdminLogger.log(AdminLogger.class, getClass() + " - Adding Role", "Date: " + date + "; " + "Role:" + roleKey); + + try { + role = roleAPI.save(role, role.getId()); + } catch (DotDataException | DotStateException e) { + ActivityLogger.logInfo(ActivityLogger.class, getClass() + " - Error adding Role", + "Date: " + date + "; " + "Role:" + roleKey); + AdminLogger.log(AdminLogger.class, getClass() + " - Error adding Role", + "Date: " + date + "; " + "Role:" + roleKey); + throw e; + } + + return role; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserResource.java index 56bfe5ec714c..19ce29ad5de9 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserResource.java @@ -1,7 +1,9 @@ package com.dotcms.rest.api.v1.user; +import com.dotcms.auth.providers.saml.v1.SAMLHelper; import com.dotcms.exception.ExceptionUtil; import com.dotcms.repackage.com.google.common.annotations.VisibleForTesting; +import com.dotcms.rest.AnonymousAccess; import com.dotcms.rest.ErrorEntity; import com.dotcms.rest.ErrorResponseHelper; import com.dotcms.rest.InitDataObject; @@ -20,10 +22,12 @@ import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.ApiProvider; +import com.dotmarketing.business.DotStateException; import com.dotmarketing.business.NoSuchUserException; import com.dotmarketing.business.Role; import com.dotmarketing.business.RoleAPI; import com.dotmarketing.business.UserAPI; +import com.dotmarketing.business.web.WebAPILocator; import com.dotmarketing.common.util.SQLUtil; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotRuntimeException; @@ -31,9 +35,14 @@ import com.dotmarketing.exception.UserFirstNameException; import com.dotmarketing.exception.UserLastNameException; import com.dotmarketing.portlets.contentlet.business.HostAPI; +import com.dotmarketing.util.ActivityLogger; +import com.dotmarketing.util.AdminLogger; import com.dotmarketing.util.DateUtil; import com.dotmarketing.util.Logger; +import com.dotmarketing.util.PortletID; import com.dotmarketing.util.SecurityLogger; +import com.dotmarketing.util.UUIDGenerator; +import com.dotmarketing.util.UUIDUtil; import com.dotmarketing.util.UtilMethods; import com.liferay.portal.auth.PrincipalThreadLocal; import com.liferay.portal.language.LanguageUtil; @@ -58,10 +67,13 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import java.io.Serializable; +import java.text.ParseException; import java.util.Collections; +import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import static com.dotcms.util.CollectionsUtils.list; import static com.dotcms.util.CollectionsUtils.map; @@ -639,4 +651,115 @@ private void checkUserLoginAsRole(final User user) throws DotDataException, DotS } } + /** + * Creates an user. + * If userId is sent will be use, if not will be created "userId-" + UUIDUtil.uuid(). + * By default, users will be inactive unless the active = true is sent and user has permissions( is Admin or access + * to Users and Roles portlets). + * FirstName, LastName, Email and Password are required. + * + * + * Scenarios: + * 1. No Auth or User doing the request do not have access to Users and Roles Portlets + * - Always will be inactive + * - Only the Role DOTCMS_FRONT_END_USER will be added + * 2. Auth, User is Admin or have access to Users and Roles Portlets + * - Can be active if JSON includes ("active": true) + * - The list of RoleKey will be use to assign the roles, if the roleKey doesn't exist will be + * created under the ROOT ROLE. + * + * @param httpServletRequest + * @param createUserForm + * @return User Created + * @throws Exception + */ + @POST + @JSONP + @NoCache + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + public final Response create(@Context final HttpServletRequest httpServletRequest, + @Context final HttpServletResponse httpServletResponse, + final CreateUserForm createUserForm) throws Exception { + + final User modUser = new WebResource.InitBuilder(webResource) + .requestAndResponse(httpServletRequest, httpServletResponse) + .rejectWhenNoUser(true) + .init().getUser(); + + final boolean isRoleAdministrator = modUser.isAdmin() || + ( + APILocator.getLayoutAPI().doesUserHaveAccessToPortlet(PortletID.ROLES.toString(), modUser) && + APILocator.getLayoutAPI().doesUserHaveAccessToPortlet(PortletID.USERS.toString(), modUser) + ); + + if (isRoleAdministrator) { + final User userToUpdated = this.createNewUser( + modUser, createUserForm); + + return Response.ok(new ResponseEntityView(map("userID", userToUpdated.getUserId(), + "user", userToUpdated.toMap()))).build(); // 200 + } + + throw new ForbiddenException("User " + modUser.getUserId() + " does not have permissions to create users"); + } // create. + + protected User createNewUser(final User modUser, + final CreateUserForm createUserForm) + throws DotDataException, DotSecurityException, ParseException { + + final String userId = UtilMethods.isSet(createUserForm.getUserId())? + createUserForm.getUserId(): "userId-" + UUIDUtil.uuid(); + final User user = this.userAPI.createUser(userId, createUserForm.getEmail()); + + user.setFirstName(createUserForm.getFirstName()); + + if (UtilMethods.isSet(createUserForm.getLastName())) { + user.setLastName(createUserForm.getLastName()); + } + + if (UtilMethods.isSet(createUserForm.getBirthday())) { + user.setBirthday(DateUtil.parseISO(createUserForm.getBirthday())); + } + + if (UtilMethods.isSet(createUserForm.getMiddleName())) { + user.setMiddleName(createUserForm.getMiddleName()); + } + + if (createUserForm.getLanguageId() <= 0) { + user.setLanguageId(String.valueOf(createUserForm.getLanguageId() <= 0? + APILocator.getLanguageAPI().getDefaultLanguage().getId(): createUserForm.getLanguageId())); + } + + if (UtilMethods.isSet(createUserForm.getNickName())) { + user.setNickName(createUserForm.getNickName()); + } + + if (UtilMethods.isSet(createUserForm.getTimeZoneId())) { + user.setTimeZoneId(createUserForm.getTimeZoneId()); + } + + user.setPassword(new String(createUserForm.getPassword())); + user.setMale(createUserForm.isMale()); + user.setCreateDate(new Date()); + + if (UtilMethods.isSet(createUserForm.getAdditionalInfo())) { + user.setAdditionalInfo(createUserForm.getAdditionalInfo()); + } + + final List roleKeys = UtilMethods.isSet(createUserForm.getRoles())? + createUserForm.getRoles():list(Role.DOTCMS_FRONT_END_USER); + + this.userAPI.save(user, APILocator.systemUser(), false); + Logger.debug(this, ()-> "User with userId '" + userId + "' and email '" + + createUserForm.getEmail() + "' has been created."); + + for (final String roleKey : roleKeys) { + + UserHelper.getInstance().addRole(user, roleKey, false , false); + } + + return user; + } + + } diff --git a/dotCMS/src/main/java/com/dotmarketing/business/PermissionAPI.java b/dotCMS/src/main/java/com/dotmarketing/business/PermissionAPI.java index 4f259cd72cde..d078d66cff62 100644 --- a/dotCMS/src/main/java/com/dotmarketing/business/PermissionAPI.java +++ b/dotCMS/src/main/java/com/dotmarketing/business/PermissionAPI.java @@ -1,5 +1,6 @@ package com.dotmarketing.business; +import com.dotcms.contenttype.model.type.ContentType; import com.dotmarketing.beans.Inode; import com.dotmarketing.beans.Permission; import com.dotmarketing.exception.DotDataException; @@ -183,6 +184,36 @@ boolean doesUserHavePermission(Permissionable permissionable, */ boolean doesUserHavePermission(Permissionable permissionable, int permissionType, User user) throws DotDataException; + /** + * Return true if the user have over the content type specified + * permission. This method is meant to be used by frontend call because + * assumes that frontend roles should be respected. + * + * @param permissionable permissionable + * @param permissionType + * @param user + * @return boolean + * @version 1.8 + * @throws DotDataException + * @since 1.0 + */ + boolean doesUserHavePermission(ContentType permissionable, int permissionType, User user) throws DotDataException; + + /** + * Return true if the user have over the content type specified + * permission. + * + * @param permissionable permissionable + * @param permissionType + * @param user + * @param respectFrontendRoles + * @return boolean + * @version 1.8 + * @throws DotDataException + * @since 1.0 + */ + boolean doesUserHavePermission(ContentType permissionable, int permissionType, User user, boolean respectFrontendRoles) throws DotDataException; + /** * Return true if the user have over the permissionable the specified * permission diff --git a/dotCMS/src/main/java/com/dotmarketing/business/PermissionBitAPIImpl.java b/dotCMS/src/main/java/com/dotmarketing/business/PermissionBitAPIImpl.java index 2c1c7a95b305..3637c4c13bd5 100644 --- a/dotCMS/src/main/java/com/dotmarketing/business/PermissionBitAPIImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/business/PermissionBitAPIImpl.java @@ -6,6 +6,8 @@ import com.dotcms.api.system.event.Visibility; import com.dotcms.business.CloseDBIfOpened; import com.dotcms.business.WrapInTransaction; +import com.dotcms.contenttype.model.field.HostFolderField; +import com.dotcms.contenttype.model.type.ContentType; import com.dotcms.repackage.com.google.common.annotations.VisibleForTesting; import com.dotmarketing.beans.Host; import com.dotmarketing.beans.Inode; @@ -631,8 +633,12 @@ public void save(Permission permission, Permissionable permissionable, User user */ @WrapInTransaction private void save(Permission permission, Permissionable permissionable, User user, boolean respectFrontendRoles, boolean createEvent) throws DotDataException, DotSecurityException { - if(!doesUserHavePermission(permissionable, PermissionAPI.PERMISSION_EDIT_PERMISSIONS, user)) - throw new DotSecurityException("User id: " + user.getUserId() + " does not have permission to alter permissions on asset " + permissionable.getPermissionId()); + if(!doesUserHavePermission(permissionable, PermissionAPI.PERMISSION_EDIT_PERMISSIONS, user)) { + + if(!checkIfContentletTypeHasEditPermissions(permissionable, user)) { + throw new DotSecurityException("User id: " + user.getUserId() + " does not have permission to alter permissions on asset " + permissionable.getPermissionId()); + } + } RoleAPI roleAPI = APILocator.getRoleAPI(); @@ -672,6 +678,55 @@ private void save(Permission permission, Permissionable permissionable, User use } + /** + * In case the permissionable is a contentlet, we try to check if the content type has edit permissions + * This is applies when the doesUserHavePermission(permissionable, PermissionAPI.PERMISSION_EDIT_PERMISSIONS, user)) was called previously and has fail. + * + * @param permissionable + * @param user + * @return boolean + * @throws DotDataException + */ + private boolean checkIfContentletTypeHasEditPermissions(final Permissionable permissionable, final User user) throws DotDataException { + + return permissionable instanceof Contentlet? // we can check if the content type has edit permissions + doesUserHavePermission(Contentlet.class.cast(permissionable).getContentType(), PermissionAPI.PERMISSION_EDIT_PERMISSIONS, user):false; + } + + + @Override + public boolean doesUserHavePermission(final ContentType permissionable, final int permissionType, final User user) throws DotDataException { + + return doesUserHavePermission(permissionable, permissionType, user, true); + } + + @Override + public boolean doesUserHavePermission(final ContentType type, final int permissionType, + final User user, final boolean respectFrontendRoles) throws DotDataException { + + // try the legacy way + final boolean hasPermission = this.doesUserHavePermission((Permissionable) type, permissionType, user, respectFrontendRoles); + + // if the user does not have permission, check if the type allows CMS owner + if (!hasPermission) { + + final Role cmsOwnerRole = Try.of(() -> APILocator.getRoleAPI().loadCMSOwnerRole()) + .getOrElseThrow(e -> new DotRuntimeException(e.getMessage(), e)); + + final List contentTypePermissions = getPermissions(type, true); + for(final Permission contentTypePermission : contentTypePermissions) { + if (user.isBackendUser() && contentTypePermission.getRoleId().equals(cmsOwnerRole.getId())) { + + if (type.fields(HostFolderField.class).isEmpty() && Host.SYSTEM_HOST.equals(type.host())) { + return true; + } + } + } + } + + return hasPermission; + } // doesUserHavePermission + /* (non-Javadoc) * @see com.dotmarketing.business.PermissionFactory#assignPermissions * @deprecated Use save(permission) instead. @@ -1668,6 +1723,11 @@ private List getNewPermissions(Permissionable parent, Permissionable Host.class.getCanonicalName() ); + final Set ContentTypeInheritableClasses = Sets.newHashSet( + Contentlet.class.getCanonicalName() + ); + + final Set classesToIgnoreHost = Sets .newHashSet(Category.class.getCanonicalName()); @@ -1691,11 +1751,14 @@ private List getNewPermissions(Permissionable parent, Permissionable for (final Permission permission : permissions) { - if (finalPermissionableType.equals(Folder.class.getCanonicalName()) - && classesToIgnoreFolder.contains(permission.getType())) { + if (finalPermissionableType.equals(Structure.class.getCanonicalName()) + && !ContentTypeInheritableClasses.contains(permission.getType())) { continue; } - + if (finalPermissionableType.equals(Folder.class.getCanonicalName()) + && classesToIgnoreFolder.contains(permission.getType())) { + continue; + } if (finalPermissionableType.equals(Host.class.getCanonicalName()) && classesToIgnoreHost.contains(permission.getType())) { continue; diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 35efe1febec8..43341a5ace45 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3025,6 +3025,7 @@ permissions-not-editable-for-role=Permissions are not editable for this Role Permissions-on-Children=Select what permissions this Role will have on the following children: Permissions-on-Children1=Select what permissions Permissions-on-Children2=will have on the following children: +permissions-on-contentType-children=These permissions will be inherited IF the content lives on the system host or there are no other inheritable permisions on the content's site or folder. permissions-saved=Permissions Saved permissions=Permissions Permissions=Permissions diff --git a/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_contentType_entry.html b/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_contentType_entry.html new file mode 100644 index 000000000000..c2fbd6d21642 --- /dev/null +++ b/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_contentType_entry.html @@ -0,0 +1,14 @@ + + + + + + + + + + + + + +
    ${contentWillInherit} (Children)NA
    ${permissionsOnContentTypeChildren}
    \ No newline at end of file diff --git a/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_entry.html b/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_entry.html index eddd7442e64a..4c3f851ca187 100644 --- a/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_entry.html +++ b/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_entry.html @@ -53,7 +53,8 @@ ${structureWillInherit} - NA + + diff --git a/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_tab_js_inc.jsp b/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_tab_js_inc.jsp index 48536677473c..5b25663c045b 100644 --- a/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_tab_js_inc.jsp +++ b/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_tab_js_inc.jsp @@ -33,11 +33,13 @@ Structure hostStrucuture = CacheLocator.getContentTypeCache().getStructureByVelocityVarName("Host"); Contentlet contentletAux = ((Contentlet)request.getAttribute(com.dotmarketing.util.WebKeys.CONTENTLET_EDIT)); %> + var languageId = '<%= ((UtilMethods.isSet(contentletAux) && UtilMethods.isSet(contentletAux.getLanguageId())) ? contentletAux.getLanguageId() : "") %>'; var assetId = '<%= asset.getPermissionId() %>'; var assetType = '<%= ((asset instanceof Contentlet) && ((Contentlet)asset).getStructureInode().equals(hostStrucuture.getInode()))?Host.class.getName():asset.getClass().getName() %>'; var isParentPermissionable = <%= (asset.isParentPermissionable()) %>; var isFolder = <%= (asset instanceof Folder) %>; + var isContentType = <%= (asset instanceof Structure ) %>; var isHost = <%= (asset instanceof Host) || ((asset instanceof Contentlet) && ((Contentlet)asset).getStructureInode().equals(hostStrucuture.getInode())) %>; var doesUserHavePermissionsToEdit = <%= permAPI.doesUserHavePermission(asset, PermissionAPI.PERMISSION_EDIT_PERMISSIONS, user) %>; var isNewAsset = assetId == 0 || assetId == '' || !assetId; @@ -68,6 +70,8 @@ var contentWillInheritMsg = '<%= UtilMethods.escapeSingleQuotes(LanguageUtil.get(pageContext, "Content-Files")) %>'; var permissionsOnChildrenMsg1 = '<%= UtilMethods.escapeSingleQuotes(LanguageUtil.get(pageContext, "Permissions-on-Children1")) %>'; var permissionsOnChildrenMsg2 = '<%= UtilMethods.escapeSingleQuotes(LanguageUtil.get(pageContext, "Permissions-on-Children2")) %>'; + var permissionsOnContentTypeChildren = '<%= UtilMethods.escapeSingleQuotes(LanguageUtil.get(pageContext, "permissions-on-contentType-children")) %>'; + var structureWillInheritMsg = '<%= UtilMethods.escapeSingleQuotes(LanguageUtil.get(pageContext, "Structure")) %>'; var noPermissionsSavedMsg = '<%= UtilMethods.escapeSingleQuotes(LanguageUtil.get(pageContext, "no-permissions-saved")) %>'; var categoriesWillInheritMsg = '<%= UtilMethods.escapeSingleQuotes(LanguageUtil.get(pageContext, "Category")) %>'; @@ -76,8 +80,10 @@ //HTML Templates var inheritedSourcesTemplate = ' ${path}'; var titleTemplateString = dojo._getText('/html/portlet/ext/common/edit_permissions_accordion_title.html'); - - if(isFolder){ + if(isContentType){ + var contentTemplateString = dojo._getText('/html/portlet/ext/common/edit_permissions_accordion_contentType_entry.html'); + } + else if(isFolder){ var contentTemplateString = dojo._getText('/html/portlet/ext/common/edit_permissions_accordion_folder_entry.html'); } else if(isHost){ @@ -193,7 +199,7 @@ var totalCollapsedHeight = 0; dojo.forEach(this.getChildren(), function(child){ totalCollapsedHeight += child._buttonWidget.getTitleHeight(); - if((!isFolder && !isHost) || (inheritingPermissions)) { + if((!isFolder && !isHost && !isContentType) || (inheritingPermissions)) { dojo.style(child.containerNode, { padding: '0' }); } }); @@ -202,6 +208,8 @@ this._verticalSpace = 280; }else if (isFolder && !inheritingPermissions) { this._verticalSpace = 200; + }else if (isContentType && !inheritingPermissions) { + this._verticalSpace = 100; }else { this._verticalSpace = 0; } @@ -254,7 +262,7 @@ } adjustAccordionHeigth(); - if(!inheritingPermissions && (isHost || isFolder)){ + if(!inheritingPermissions && (isHost || isFolder || isContentType)){ dojo.query(".accordionEntry").forEach(function(node, index, arr){ node.className = "permissionTable"; }); @@ -392,7 +400,7 @@ var role = currentPermissions[i]; var rolePermission = { roleId: role.id } rolePermission.individualPermission = retrievePermissionChecks(role.id); - if(isFolder || isHost) { + if(isFolder || isHost ) { rolePermission.foldersPermission = retrievePermissionChecks(role.id, 'folders'); rolePermission.containersPermission = retrievePermissionChecks(role.id, 'containers'); rolePermission.templatesPermission = retrievePermissionChecks(role.id, 'templates'); @@ -404,6 +412,9 @@ rolePermission.categoriesPermissions = retrievePermissionChecks(role.id, 'categories'); rolePermission.rulesPermissions = retrievePermissionChecks(role.id, 'rules'); } + if(isContentType){ + rolePermission.contentPermission = retrievePermissionChecks(role.id, 'content'); + } dojo.forEach(rolePermission, function(value){ console.log("rolePermission: " + value); @@ -720,8 +731,7 @@ } function permissionsIndividually () { - if(assetType == 'com.dotmarketing.portlets.folders.model.Folder' || - assetType == 'com.dotmarketing.beans.Host') { + if(isFolder || isHost || isContentType) { dijit.byId('savingPermissionsDialog').show(); changesMadeToPermissions=false; permissionsLoaded = false; @@ -771,14 +781,19 @@ var totalCollapsedHeight = 0; dojo.forEach(this.getChildren(), function(child){ totalCollapsedHeight += child._buttonWidget.getTitleHeight(); - if((!isFolder && !isHost)) { + if((!isFolder && !isHost && !isContentType)) { dojo.style(child.containerNode, { padding: '0' }); } }); var mySize = this._contentBox; if(isFolder || isHost) { this._verticalSpace = 200; - } else { + } + else if (isContentType){ + this._verticalSpace = 120; + } + + else { this._verticalSpace = 0; } @@ -895,8 +910,6 @@ role["publish-permission-style"] = 'display:none'; } else if(assetType == 'com.dotmarketing.beans.Host') { role["publish-permission-style"] = 'display:none'; - } else if(assetType == 'com.dotmarketing.portlets.structure.model.Structure') { - role["add-children-permission-style"] = 'display: none' } else if(assetType == 'com.dotmarketing.portlets.categories.model.Category') { role["publish-permission-style"] = 'display:none'; role["add-children-permission-style"] = 'display: none' @@ -926,6 +939,7 @@ role.contentWillInherit = contentWillInheritMsg; role.permissionsOnChildren1=permissionsOnChildrenMsg1; role.permissionsOnChildren2=permissionsOnChildrenMsg2; + role.permissionsOnContentTypeChildren=permissionsOnContentTypeChildren; role.structureWillInherit = structureWillInheritMsg; role.categoriesWillInherit = categoriesWillInheritMsg; role.rulesWillInherit = rulesWillInheritMsg; From 0bc51e3626917a91fb7cac0640e3fe618be92d13 Mon Sep 17 00:00:00 2001 From: Fabrizzio Araya <37148755+fabrizzio-dotCMS@users.noreply.github.com> Date: Fri, 26 May 2023 09:20:05 -0600 Subject: [PATCH 16/63] Issue 25008 dateformat on content resource (#25017) * #25008 adding a strategy to normilize date, date-time, and time fields read from the contetlet as json * #25008 adding test --- .../transform/ContentletTransformerTest.java | 70 +++++++++++++++++++ .../transform/DotTransformerBuilder.java | 4 +- .../DateTimeFieldsToTimeStampStrategy.java | 56 +++++++++++++++ .../strategy/StrategyResolverImpl.java | 3 +- .../transform/strategy/TransformOptions.java | 3 +- 5 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DateTimeFieldsToTimeStampStrategy.java diff --git a/dotCMS/src/integration-test/java/com/dotmarketing/portlets/contentlet/transform/ContentletTransformerTest.java b/dotCMS/src/integration-test/java/com/dotmarketing/portlets/contentlet/transform/ContentletTransformerTest.java index 3318ca37cc60..c735643a47bb 100644 --- a/dotCMS/src/integration-test/java/com/dotmarketing/portlets/contentlet/transform/ContentletTransformerTest.java +++ b/dotCMS/src/integration-test/java/com/dotmarketing/portlets/contentlet/transform/ContentletTransformerTest.java @@ -67,6 +67,7 @@ import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; import com.dotmarketing.util.UUIDGenerator; +import com.dotmarketing.util.json.JSONObject; import com.liferay.portal.model.User; import com.liferay.util.EncryptorException; import com.liferay.util.StringPool; @@ -82,6 +83,10 @@ import java.io.ObjectOutputStream; import java.nio.file.Path; import java.security.Key; +import java.sql.Timestamp; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.time.Instant; import java.util.ArrayList; import java.util.Date; import java.util.EnumSet; @@ -864,4 +869,69 @@ private Contentlet readSerializedContentlet(final File file) throws IOException, } } + /** + * Given Scenario: This tests that the transformer used to handle serialization for the legacy content-resource is configured properly + * to handle the date formats returned from the regular database columns and also the fields loaded from the contentlet-as-json column + * Expected Result: The transformer instantiated through contentResourceOptions method should be able to convert from Date to Timestamp which the expected datatype used to feed JSONObject + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + public void Transformer_content_Resource_Date_Formats_Test() + throws Exception { + + final ContentType contentType = TestDataUtils.newContentTypeFieldTypesGalore(); + final ContentletDataGen contentletDataGen = new ContentletDataGen(contentType.inode()) + .setProperty("title", "Bicycle") + .setProperty("timeField", new Date()) + .setProperty("dateField", new Date()) + .setProperty("dateTimeField", new Date()); + final Contentlet contentlet = contentletDataGen.nextPersisted(); + + Assert.assertTrue(contentlet.getMap().get("timeField") instanceof Date); + Assert.assertTrue(contentlet.getMap().get("dateField") instanceof Date); + Assert.assertTrue(contentlet.getMap().get("dateTimeField") instanceof Date); + + final DotContentletTransformer transformer = new DotTransformerBuilder() + .contentResourceOptions(true) + .content(contentlet).build(); + + final Map map = transformer.toMaps().get(0); + + Assert.assertTrue(map.get("timeField") instanceof Timestamp); + Assert.assertTrue(map.get("dateField") instanceof Timestamp); + Assert.assertTrue(map.get("dateTimeField") instanceof Timestamp); + + final Map printableMap = ContentletUtil.getContentPrintableMap( + APILocator.systemUser(), contentlet); + + Assert.assertTrue(printableMap.get("timeField") instanceof Timestamp); + Assert.assertTrue(printableMap.get("dateField") instanceof Timestamp); + Assert.assertTrue(printableMap.get("dateTimeField") instanceof Timestamp); + + //This part simulates the JSON rendering that takes place in the ContentResource + + final JSONObject object = new JSONObject() + .put("timeField", map.get("timeField")) + .put("dateField", map.get("dateField")) + .put("dateTimeField", map.get("dateTimeField") + ); + + Assert.assertTrue(isValidStringDateISO8601(object.get("timeField").toString())); + Assert.assertTrue(isValidStringDateISO8601(object.get("dateField").toString())); + Assert.assertTrue(isValidStringDateISO8601(object.get("dateTimeField").toString())); + + } + + /** + * Utitlity method to validate a string date against the ISO8601 format + * @param dateString + * @return + */ + public static boolean isValidStringDateISO8601(final String dateString) { + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); + dateFormat.setLenient(false); // Strict date parsing + return null != Try.of(()-> dateFormat.parse(dateString)).getOrElse((Date)null); + } + } diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/DotTransformerBuilder.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/DotTransformerBuilder.java index cfad1aef9612..e5aff67f5822 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/DotTransformerBuilder.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/DotTransformerBuilder.java @@ -5,6 +5,7 @@ import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.CATEGORIES_INFO; import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.CATEGORIES_NAME; import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.CATEGORIES_VIEW; +import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.DATETIME_FIELDS_TO_TIMESTAMP; import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.FILEASSET_VIEW; import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.IDENTIFIER_VIEW; import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.COMMON_PROPS; @@ -198,7 +199,8 @@ public DotTransformerBuilder dotAssetOptions(){ */ public DotTransformerBuilder contentResourceOptions(final boolean allCategoriesInfo){ optionsHolder.clear(); - optionsHolder.addAll(EnumSet.of(COMMON_PROPS, CONSTANTS, VERSION_INFO, LOAD_META, BINARIES, CATEGORIES_NAME)); + optionsHolder.addAll(EnumSet.of(COMMON_PROPS, CONSTANTS, VERSION_INFO, LOAD_META, BINARIES, CATEGORIES_NAME, + DATETIME_FIELDS_TO_TIMESTAMP)); if(allCategoriesInfo){ optionsHolder.remove(CATEGORIES_NAME); optionsHolder.add(CATEGORIES_INFO); diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DateTimeFieldsToTimeStampStrategy.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DateTimeFieldsToTimeStampStrategy.java new file mode 100644 index 000000000000..9813fc4d39d9 --- /dev/null +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DateTimeFieldsToTimeStampStrategy.java @@ -0,0 +1,56 @@ +package com.dotmarketing.portlets.contentlet.transform.strategy; + +import com.dotcms.api.APIProvider; +import com.dotcms.contenttype.model.field.DateField; +import com.dotcms.contenttype.model.field.DateTimeField; +import com.dotcms.contenttype.model.field.Field; +import com.dotcms.contenttype.model.field.TimeField; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.liferay.portal.model.User; +import java.sql.Timestamp; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * This strategy will transform all DateTime fields into Timestamps. + * We had an issue where old ContentResource would show Datetime in an incorrect format. + * The error was originated by the fields loaded from contentlet as json which are mapped into a Date object. + * But the JSONObject formats Dates and TimeStamps differently. + */ +public class DateTimeFieldsToTimeStampStrategy extends AbstractTransformStrategy { + + DateTimeFieldsToTimeStampStrategy(APIProvider toolBox) { + super(toolBox); + } + + @Override + protected Map transform(Contentlet source, Map map, + Set options, User user) + throws DotDataException, DotSecurityException { + final ContentType contentType = source.getContentType(); + convertFieldsToTimestamp(contentType.fields(TimeField.class), map); + convertFieldsToTimestamp(contentType.fields(DateField.class), map); + convertFieldsToTimestamp(contentType.fields(DateTimeField.class), map); + return map; + } + + /** + * This method will convert all Date fields into Timestamps. + * @param fields list of fields to convert + * @param map map containing the values to convert + */ + private void convertFieldsToTimestamp(final List fields, Map map){ + fields.forEach(field -> { + final Object o = map.get(field.variable()); + if (o instanceof Date) { + map.put(field.variable(), new Timestamp(((Date) o).getTime())); + } + }); + } + +} diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/StrategyResolverImpl.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/StrategyResolverImpl.java index eea1d0d9e908..66b1528fd9d9 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/StrategyResolverImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/StrategyResolverImpl.java @@ -69,7 +69,8 @@ public StrategyResolverImpl(final APIProvider toolBox) { SITE_VIEW, ()-> new SiteViewStrategy(toolBox), STORY_BLOCK_VIEW,()-> new StoryBlockViewStrategy(toolBox), RENDER_FIELDS, ()-> new RenderFieldStrategy(toolBox), - JSON_VIEW, ()-> new JSONViewStrategy(toolBox) + JSON_VIEW, ()-> new JSONViewStrategy(toolBox), + DATETIME_FIELDS_TO_TIMESTAMP, ()-> new DateTimeFieldsToTimeStampStrategy(toolBox) ), ()-> new DefaultTransformStrategy(toolBox) ); diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/TransformOptions.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/TransformOptions.java index 8591a359384d..4a11baf89f24 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/TransformOptions.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/TransformOptions.java @@ -29,7 +29,8 @@ public enum TransformOptions { AVOID_MAP_SUFFIX_FOR_VIEWS, RENDER_FIELDS, // will velocity-render the render-able fields - JSON_VIEW; + JSON_VIEW, + DATETIME_FIELDS_TO_TIMESTAMP; private boolean defaultProperty; From 38b43b14cf13848cced4edfa9b660dd65e8bf59d Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Fri, 26 May 2023 13:43:25 -0300 Subject: [PATCH 17/63] Fix #25014: Template Builder Create Row Design (#25034) * add basic Template Builder Row * add material icons * add data to initialize storybook * add simple functionality for row of template-builder * implement template builder row * Update template-builder.component.html * added simple test cases * fix broken tests * add global variable * small css tweak * centralize mock values * resolve feedback --- core-web/libs/template-builder/project.json | 6 +- .../template-builder-row.component.html | 27 +++ .../template-builder-row.component.scss | 43 ++++ .../template-builder-row.component.spec.ts | 92 ++++++++ .../template-builder-row.component.stories.ts | 26 +++ .../template-builder-row.component.ts | 20 ++ .../store/template-builder.store.spec.ts | 21 +- .../template-builder.component.html | 39 ++-- .../template-builder.component.scss | 9 +- .../template-builder.component.spec.ts | 5 +- .../template-builder.component.stories.ts | 10 +- .../utils/gridstack-options.ts | 13 +- .../utils/gridstack-utils.spec.ts | 22 +- .../template-builder/utils/mocks.ts | 196 ++++++++++++++++++ .../src/lib/template-builder.module.ts | 3 +- 15 files changed, 454 insertions(+), 78 deletions(-) create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.html create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.scss create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.spec.ts create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.stories.ts create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.ts create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/utils/mocks.ts diff --git a/core-web/libs/template-builder/project.json b/core-web/libs/template-builder/project.json index 1509f543a3b7..743575b8eef6 100644 --- a/core-web/libs/template-builder/project.json +++ b/core-web/libs/template-builder/project.json @@ -41,7 +41,7 @@ }, "styles": [ "libs/dotcms-scss/angular/styles.scss", - "node_modules/primeicons/primeicons.css", + "apps/dotcms-ui/src/assets/material-icons.css", "node_modules/primeicons/primeicons.css", "node_modules/primeflex/primeflex.css", "node_modules/primeng/resources/primeng.min.css", @@ -67,10 +67,10 @@ }, "styles": [ "libs/dotcms-scss/angular/styles.scss", - "node_modules/primeicons/primeicons.css", + "node_modules/primeng/resources/primeng.min.css", "node_modules/primeicons/primeicons.css", "node_modules/primeflex/primeflex.css", - "node_modules/primeng/resources/primeng.min.css", + "apps/dotcms-ui/src/assets/material-icons.css", "node_modules/gridstack/dist/gridstack.min.css" ] }, diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.html b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.html new file mode 100644 index 000000000000..7648e72c0304 --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.html @@ -0,0 +1,27 @@ +
    + +
    +
    + +
    +
    + + + + +
    diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.scss b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.scss new file mode 100644 index 000000000000..97d8ac947004 --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.scss @@ -0,0 +1,43 @@ +@use "variables" as *; +:host { + display: flex; + height: 16rem; + border: 1px solid $color-palette-gray-300; + border-radius: $border-radius-md; +} + +.row__drag-container, +.row__actions-container { + display: flex; + justify-content: center; + align-items: center; + width: 2.625rem; + flex-direction: column; + gap: $spacing-1; +} + +.row__drag-container { + .drag-container__drag-handler { + color: $color-palette-gray-700; + } + + &:hover { + cursor: grab; + } + &:active { + cursor: grabbing; + } +} + +.row__content-container { + flex-grow: 1; + padding: $spacing-3 0; +} + +.row__actions-container { + p-button ::ng-deep { + .pi-trash { + color: $color-palette-primary-400; + } + } +} diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.spec.ts new file mode 100644 index 000000000000..4fc7e776beda --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.spec.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from '@jest/globals'; + +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { ButtonModule } from 'primeng/button'; + +import { DotIconModule } from '@dotcms/ui'; + +import { TemplateBuilderRowComponent } from './template-builder-row.component'; + +@Component({ + selector: 'dotcms-host-component', + template: ` +

    Some component

    +
    ` +}) +class HostComponent { + deleteRow() { + /* */ + } + editRowStyleClass() { + /* */ + } +} + +describe('TemplateBuilderRowComponent', () => { + let component: TemplateBuilderRowComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DotIconModule, ButtonModule, TemplateBuilderRowComponent], + declarations: [HostComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(HostComponent); + component = fixture.debugElement.query( + By.css('dotcms-template-builder-row') + ).componentInstance; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have a drag handler', () => { + expect( + fixture.debugElement.query(By.css('dot-icon[data-testid="row-drag-handler"]')) + ).toBeTruthy(); + }); + it('should have a style class edit button', () => { + expect( + fixture.debugElement.query(By.css('p-button[data-testid="row-style-class-button"]')) + ).toBeTruthy(); + }); + it('should have a trash button', () => { + expect( + fixture.debugElement.query(By.css('p-button[data-testid="row-trash-button"]')) + ).toBeTruthy(); + }); + it('should render child', () => { + expect(fixture.debugElement.query(By.css('p'))).toBeTruthy(); + }); + + it('should trigger deleteRow when clicking on delete button', () => { + jest.spyOn(fixture.componentInstance, 'deleteRow'); + const button = fixture.debugElement.query( + By.css('p-button[data-testid="row-trash-button"]') + ); + + button.nativeElement.dispatchEvent(new Event('onClick')); + + expect(fixture.componentInstance.deleteRow).toHaveBeenCalled(); + }); + it('should trigger editRowStyleClass when clicking on editStyleClass button', () => { + jest.spyOn(fixture.componentInstance, 'editRowStyleClass'); + const button = fixture.debugElement.query( + By.css('p-button[data-testid="row-style-class-button"]') + ); + + button.nativeElement.dispatchEvent(new Event('onClick')); + + expect(fixture.componentInstance.editRowStyleClass).toHaveBeenCalled(); + }); +}); diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.stories.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.stories.ts new file mode 100644 index 000000000000..f4a51b26518c --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.stories.ts @@ -0,0 +1,26 @@ +import { moduleMetadata, Story, Meta } from '@storybook/angular'; + +import { ButtonModule } from 'primeng/button'; + +import { DotIconModule } from '@dotcms/ui'; + +import { TemplateBuilderRowComponent } from './template-builder-row.component'; + +export default { + title: 'TemplateBuilderRowComponent', + component: TemplateBuilderRowComponent, + decorators: [ + moduleMetadata({ + imports: [DotIconModule, ButtonModule], + providers: [] + }) + ] +} as Meta; + +const Template: Story = (args: TemplateBuilderRowComponent) => ({ + props: args +}); + +export const Primary = Template.bind({}); + +Primary.args = {}; diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.ts new file mode 100644 index 000000000000..52740c065899 --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.ts @@ -0,0 +1,20 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; + +import { DotIconModule } from '@dotcms/ui'; + +@Component({ + selector: 'dotcms-template-builder-row', + standalone: true, + imports: [DotIconModule, ButtonModule], + templateUrl: './template-builder-row.component.html', + styleUrls: ['./template-builder-row.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TemplateBuilderRowComponent { + @Output() + editStyleClasses: EventEmitter = new EventEmitter(); + @Output() + deleteRow: EventEmitter = new EventEmitter(); +} diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/store/template-builder.store.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/store/template-builder.store.spec.ts index 46d6fd3f585e..1cfae6bdebe6 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/store/template-builder.store.spec.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/store/template-builder.store.spec.ts @@ -9,25 +9,12 @@ import { take } from 'rxjs/operators'; import { DotTemplateBuilderStore } from './template-builder.store'; import { DotGridStackNode, DotGridStackWidget } from '../models/models'; +import { GRIDSTACK_DATA_MOCK } from '../utils/mocks'; global.structuredClone = jest.fn((val) => { return JSON.parse(JSON.stringify(val)); }); -const mockInitialState: DotGridStackWidget[] = [ - { x: 0, y: 0, w: 12, id: uuid() }, - { x: 0, y: 1, w: 12, id: uuid() }, - { - x: 0, - y: 2, - w: 12, - id: uuid(), - subGridOpts: { - children: [{ x: 0, y: 0, w: 4, id: uuid() }] - } - } -]; - describe('DotTemplateBuilderStore', () => { let service: DotTemplateBuilderStore; let initialState: DotGridStackWidget[]; @@ -39,7 +26,7 @@ describe('DotTemplateBuilderStore', () => { service = TestBed.inject(DotTemplateBuilderStore); // Reset the state because is manipulated by reference - service.init(mockInitialState); + service.init(GRIDSTACK_DATA_MOCK); // Get the initial state service.items$.pipe(take(1)).subscribe((items) => { @@ -194,7 +181,7 @@ describe('DotTemplateBuilderStore', () => { const parentId = uuid(); const [firstId, secondId, thirdId, fourthId] = [1, 2, 3, 4].map(() => uuid()); - const mockInitialState = [ + const GRIDSTACK_DATA_MOCK = [ { x: 0, y: 0, @@ -211,7 +198,7 @@ describe('DotTemplateBuilderStore', () => { } ]; - service.setState({ items: mockInitialState }); + service.setState({ items: GRIDSTACK_DATA_MOCK }); const affectedColumns: DotGridStackNode[] = [ { diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.html b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.html index 13d09bacd43a..dffe70295acb 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.html +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.html @@ -25,26 +25,27 @@ [attr.gs-w]="row.w" [attr.gs-h]="row.h" > - id: {{ row.id }} - y: {{ row.y }} - styleClass: {{ row.styleClass?.join(' ') }} -
    -
    -
    - styleClass: {{ box.styleClass?.join(' ') }} -

    - identifier: {{ container.identifier }} -

    + +
    +
    +
    + styleClass: {{ box.styleClass?.join(' ') }} +

    + identifier: {{ container.identifier }} +

    +
    -
    +
    diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.scss b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.scss index 4211951bde4e..6dfb009f06a5 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.scss +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.scss @@ -4,10 +4,6 @@ display: block; } -.grid-stack-item { - border: solid 1px black; -} - .grid-stack-item.sub .grid-stack-item-content { background: pink; } @@ -26,9 +22,6 @@ .add { width: 100px; + border: 1px solid black; } } - -.grid-stack-item { - border: solid 1px black; -} diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.spec.ts index cd3b8f43df68..a5f7babe1bb2 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.spec.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DotTemplateBuilderStore } from './store/template-builder.store'; import { TemplateBuilderComponent } from './template-builder.component'; +import { FULL_DATA_MOCK } from './utils/mocks'; describe('TemplateBuilderComponent', () => { let component: TemplateBuilderComponent; @@ -17,9 +18,7 @@ describe('TemplateBuilderComponent', () => { component = fixture.componentInstance; component.templateLayout = { - body: { - rows: [] - }, + body: FULL_DATA_MOCK, footer: false, header: false, sidebar: {}, diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts index 3ea3247a8402..b0e59a41f4af 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts @@ -1,14 +1,18 @@ import { moduleMetadata, Story, Meta } from '@storybook/angular'; +import { NgFor, AsyncPipe } from '@angular/common'; + +import { TemplateBuilderRowComponent } from './components/template-builder-row/template-builder-row.component'; import { DotTemplateBuilderStore } from './store/template-builder.store'; import { TemplateBuilderComponent } from './template-builder.component'; +import { FULL_DATA_MOCK } from './utils/mocks'; export default { title: 'TemplateBuilderComponent', component: TemplateBuilderComponent, decorators: [ moduleMetadata({ - imports: [], + imports: [NgFor, AsyncPipe, TemplateBuilderRowComponent], providers: [DotTemplateBuilderStore] }) ] @@ -20,4 +24,6 @@ const Template: Story = (args: TemplateBuilderComponen export const Primary = Template.bind({}); -Primary.args = {}; +Primary.args = { + templateLayout: { body: FULL_DATA_MOCK } +}; diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/utils/gridstack-options.ts b/core-web/libs/template-builder/src/lib/components/template-builder/utils/gridstack-options.ts index 2b0d185480f4..2d033ff17833 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/utils/gridstack-options.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/utils/gridstack-options.ts @@ -30,9 +30,9 @@ function isARowWidget(el: Element): boolean { } export const subGridOptions: GridStackOptions = { - cellHeight: 85, + cellHeight: 224, column: 'auto', - margin: 10, + margin: 16, minRow: 1, maxRow: 1, acceptWidgets: isAColumnWidget @@ -40,8 +40,11 @@ export const subGridOptions: GridStackOptions = { export const gridOptions: GridStackOptions = { disableResize: true, - cellHeight: 100, - margin: 10, + cellHeight: 264, // 8px more so it overflows and we can see the 8px of space between rows + margin: 8, minRow: 1, - acceptWidgets: isARowWidget + acceptWidgets: isARowWidget, + draggable: { + handle: '.row__drag-container' + } }; diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/utils/gridstack-utils.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/utils/gridstack-utils.spec.ts index 0bf9e2ba8c8c..3b2d59ca7e66 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/utils/gridstack-utils.spec.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/utils/gridstack-utils.spec.ts @@ -1,29 +1,11 @@ import { describe, expect, it } from '@jest/globals'; import { parseFromDotObjectToGridStack } from './gridstack-utils'; +import { MINIMAL_DATA_MOCK } from './mocks'; describe('parseFromDotObjectToGridStack', () => { it('should parse the backend object to gridStack', () => { - const data = { - rows: [ - { - columns: [ - { - containers: [ - { - identifier: '//demo.dotcms.com/application/containers/banner/', - uuid: '1' - } - ], - leftOffset: 1, - width: 12, - styleClass: 'banner-tall' - } - ], - styleClass: 'p-0 banner-tall' - } - ] - }; + const data = MINIMAL_DATA_MOCK; const result = parseFromDotObjectToGridStack(data); diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/utils/mocks.ts b/core-web/libs/template-builder/src/lib/components/template-builder/utils/mocks.ts new file mode 100644 index 000000000000..e6cd6be6df5b --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/utils/mocks.ts @@ -0,0 +1,196 @@ +import { v4 as uuid } from 'uuid'; + +import { DotLayoutBody } from '@dotcms/dotcms-models'; + +import { DotGridStackWidget } from '../models/models'; + +export const GRIDSTACK_DATA_MOCK: DotGridStackWidget[] = [ + { x: 0, y: 0, w: 12, id: uuid() }, + { x: 0, y: 1, w: 12, id: uuid() }, + { + x: 0, + y: 2, + w: 12, + id: uuid(), + subGridOpts: { + children: [{ x: 0, y: 0, w: 4, id: uuid() }] + } + } +]; + +export const MINIMAL_DATA_MOCK: DotLayoutBody = { + rows: [ + { + columns: [ + { + containers: [ + { + identifier: '//demo.dotcms.com/application/containers/banner/', + uuid: '1' + } + ], + leftOffset: 1, + width: 12, + styleClass: 'banner-tall' + } + ], + styleClass: 'p-0 banner-tall' + } + ] +}; + +export const FULL_DATA_MOCK: DotLayoutBody = { + rows: [ + { + columns: [ + { + containers: [ + { + identifier: '//demo.dotcms.com/application/containers/banner/', + uuid: '1' + } + ], + leftOffset: 1, + width: 12, + styleClass: 'banner-tall' + } + ], + styleClass: 'p-0 banner-tall' + }, + { + columns: [ + { + containers: [ + { + identifier: '//demo.dotcms.com/application/containers/default/', + uuid: '1' + } + ], + leftOffset: 1, + width: 12, + styleClass: 'mt-70 booking-form' + } + ], + styleClass: null + }, + { + columns: [ + { + containers: [ + { + identifier: '//demo.dotcms.com/application/containers/default/', + uuid: '2' + } + ], + leftOffset: 1, + width: 3, + styleClass: '' + }, + { + containers: [ + { + identifier: '//demo.dotcms.com/application/containers/default/', + uuid: '3' + } + ], + leftOffset: 4, + width: 3, + styleClass: '' + }, + { + containers: [ + { + identifier: '//demo.dotcms.com/application/containers/default/', + uuid: '4' + } + ], + leftOffset: 7, + width: 3, + styleClass: '' + }, + { + containers: [ + { + identifier: '//demo.dotcms.com/application/containers/default/', + uuid: '5' + } + ], + leftOffset: 10, + width: 3, + styleClass: '' + } + ], + styleClass: null + }, + { + columns: [ + { + containers: [ + { + identifier: '//demo.dotcms.com/application/containers/default/', + uuid: '6' + } + ], + leftOffset: 1, + width: 6, + styleClass: '' + }, + { + containers: [ + { + identifier: '//demo.dotcms.com/application/containers/default/', + uuid: '7' + } + ], + leftOffset: 7, + width: 3, + styleClass: '' + }, + { + containers: [ + { + identifier: '//demo.dotcms.com/application/containers/default/', + uuid: '8' + } + ], + leftOffset: 10, + width: 3, + styleClass: '' + } + ], + styleClass: null + }, + { + columns: [ + { + containers: [ + { + identifier: '//demo.dotcms.com/application/containers/default/', + uuid: '9' + } + ], + leftOffset: 1, + width: 12, + styleClass: '' + } + ], + styleClass: 'bg-white py-5' + }, + { + columns: [ + { + containers: [ + { + identifier: '//demo.dotcms.com/application/containers/default/', + uuid: '10' + } + ], + leftOffset: 1, + width: 12, + styleClass: '' + } + ], + styleClass: null + } + ] +}; diff --git a/core-web/libs/template-builder/src/lib/template-builder.module.ts b/core-web/libs/template-builder/src/lib/template-builder.module.ts index 8480bd34d790..10e8cbd7c6f6 100644 --- a/core-web/libs/template-builder/src/lib/template-builder.module.ts +++ b/core-web/libs/template-builder/src/lib/template-builder.module.ts @@ -1,11 +1,12 @@ import { AsyncPipe, NgFor } from '@angular/common'; import { NgModule } from '@angular/core'; +import { TemplateBuilderRowComponent } from './components/template-builder/components/template-builder-row/template-builder-row.component'; import { DotTemplateBuilderStore } from './components/template-builder/store/template-builder.store'; import { TemplateBuilderComponent } from './components/template-builder/template-builder.component'; @NgModule({ - imports: [NgFor, AsyncPipe], + imports: [NgFor, AsyncPipe, TemplateBuilderRowComponent], declarations: [TemplateBuilderComponent], providers: [DotTemplateBuilderStore], exports: [TemplateBuilderComponent] From ffaef7a7fe3f78e66f3a3cb7e538a158f367f91f Mon Sep 17 00:00:00 2001 From: Humberto Morera <31667212+hmoreras@users.noreply.github.com> Date: Fri, 26 May 2023 15:01:19 -0300 Subject: [PATCH 18/63] add config props to white list (#25046) --- .../dotcms/rest/api/v1/system/ConfigurationResource.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java index fa21f8972049..90573143aa0f 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java @@ -48,7 +48,7 @@ * be programmatically read and returned. * * This is a public endpoint and requires no authentiction - * + * * @author Jose Castro * @version 3.7 * @since Jul 22, 2016 @@ -63,7 +63,8 @@ public class ConfigurationResource implements Serializable { private static final Set WHITE_LIST = ImmutableSet.copyOf( Config.getStringArrayProperty("CONFIGURATION_WHITE_LIST", new String[] {"EMAIL_SYSTEM_ADDRESS", "CHARSET","CONTENT_PALETTE_HIDDEN_CONTENT_TYPES", - "FEATURE_FLAG_EXPERIMENTS", "DOTFAVORITEPAGE_FEATURE_ENABLE", "FEATURE_FLAG_TEMPLATE_BUILDER_2", "SHOW_VIDEO_THUMBNAIL"})); + "FEATURE_FLAG_EXPERIMENTS", "DOTFAVORITEPAGE_FEATURE_ENABLE", "FEATURE_FLAG_TEMPLATE_BUILDER_2", + "SHOW_VIDEO_THUMBNAIL", "EXPERIMENTS_MIN_DURATION", "EXPERIMENTS_MAX_DURATION"})); private boolean isOnBlackList(final String key) { @@ -145,7 +146,7 @@ private Object recoveryFromConfig (final String key) { /** * Returns the list of system properties that are set through the dotCMS * configuration files. - * + * * @param request * - The {@link HttpServletRequest} object. * @return The JSON representation of configuration parameters. From 7c32fc023382ed6d97c726a2d354e3c0c83f23ea Mon Sep 17 00:00:00 2001 From: Nollymar Longa Date: Fri, 26 May 2023 13:58:45 -0500 Subject: [PATCH 19/63] Updating empty starter for tests --- dotCMS/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotCMS/build.gradle b/dotCMS/build.gradle index ded423569dd3..88753e3b00f9 100644 --- a/dotCMS/build.gradle +++ b/dotCMS/build.gradle @@ -295,7 +295,7 @@ dependencies { starter group: 'com.dotcms', name: 'starter', version: 'empty_20230525', ext: 'zip' //Uncomment this line if you want to download the starter that comes with data // starter group: 'com.dotcms', name: 'starter', version: '20230518', ext: 'zip' - testsStarter group: 'com.dotcms', name: 'starter', version: 'empty_20220718', ext: 'zip' + testsStarter group: 'com.dotcms', name: 'starter', version: 'empty_20230525', ext: 'zip' profiler group: 'glowroot-custom', name: 'glowroot-agent', version: '0.13.1' profilerDependencies group: 'glowroot-custom', name: 'collector-https-linux', version: '0.13.1' From dd6e6ffddfcf5d73cc3d9cfe5b0284ba89b2b9fa Mon Sep 17 00:00:00 2001 From: Manuel Rojas Date: Fri, 19 May 2023 11:18:28 -0600 Subject: [PATCH 20/63] Fix #24916 favorite pages unable to create bookmark image for members page - Fix for Firefox (#24993) * Fixing issue with Firefox * Adding firefox fix --- .../dot-html-to-image/dot-html-to-image.tsx | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/core-web/libs/dotcms-webcomponents/src/components/dot-html-to-image/dot-html-to-image.tsx b/core-web/libs/dotcms-webcomponents/src/components/dot-html-to-image/dot-html-to-image.tsx index 3ac372b0675b..68906863e34f 100644 --- a/core-web/libs/dotcms-webcomponents/src/components/dot-html-to-image/dot-html-to-image.tsx +++ b/core-web/libs/dotcms-webcomponents/src/components/dot-html-to-image/dot-html-to-image.tsx @@ -1,6 +1,11 @@ import { Component, Prop, h, Host, Event, EventEmitter, State } from '@stencil/core'; import '@material/mwc-circular-progress'; +type HtmlIframeDoc = { + doc: Document; + iframe: HTMLIFrameElement; +}; + @Component({ tag: 'dot-html-to-image', styleUrl: 'dot-html-to-image.scss', @@ -21,8 +26,6 @@ export class DotHtmlToImage { error?: string; }>; @State() previewImg: string; - @State() iframe: HTMLIFrameElement; - @State() doc: Document; boundOnMessageHandler = null; iframeId = `iframe_${Math.floor(Date.now() / 1000).toString()}`; @@ -53,24 +56,23 @@ export class DotHtmlToImage { ;`; componentDidLoad() { + const { doc } = this.getIframeDocument(); try { - this.doc.open(); - this.doc.write(this.value); - this.doc.close(); + doc.open(); + doc.write(this.value); + doc.close(); } catch (error) { this.pageThumbnail.emit({ file: null, error }); } } private onLoad() { + const { doc, iframe } = this.getIframeDocument(); try { - this.iframe = document.querySelector(`#${this.iframeId}`) as HTMLIFrameElement; - this.doc = this.iframe.contentDocument || this.iframe.contentWindow.document; - const scriptLib = document.createElement('script') as HTMLScriptElement; scriptLib.src = '/html/js/html2canvas/html2canvas.min.js'; scriptLib.type = 'text/javascript'; - this.doc.body.appendChild(scriptLib); + doc.body.appendChild(scriptLib); scriptLib.onload = () => { const script: HTMLScriptElement = document.createElement('script'); @@ -81,9 +83,9 @@ export class DotHtmlToImage { .replace(/IMG_WIDTH/g, this.width) : this.loadScript; - this.doc.body.appendChild(script); + doc.body.appendChild(script); - this.boundOnMessageHandler = this.onMessageHandler.bind(null, this.iframe, this); + this.boundOnMessageHandler = this.onMessageHandler.bind(null, iframe, this); window.addEventListener('message', this.boundOnMessageHandler); }; } catch (error) { @@ -91,6 +93,13 @@ export class DotHtmlToImage { } } + private getIframeDocument(): HtmlIframeDoc { + const iframe = document.querySelector(`#${this.iframeId}`) as HTMLIFrameElement; + const doc = iframe.contentDocument || iframe.contentWindow.document; + + return { doc, iframe }; + } + render() { const iframeStyle = { width: `${this.width}px`, height: `${this.height}px`, opacity: '0' }; return ( From fe65b91e7fa8eac031e8bb0ef43fffc3136d7877 Mon Sep 17 00:00:00 2001 From: Rafael Velazco Date: Fri, 19 May 2023 13:19:02 -0400 Subject: [PATCH 21/63] Fix #24985 UI - Favorite Page: Contentlets infinite scroll table on Pages Portlet (#24985) * fix table #23792 * remove: unnecessary styles * remove: unnecessary styles v2 --- .../dot-pages-listing-panel.component.html | 4 ++-- .../dot-pages-listing-panel.component.scss | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.html index c8e377212a34..c7f702bc94dc 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.html @@ -78,7 +78,6 @@ {{ rowData['title'] }} @@ -133,7 +133,7 @@ - + diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.scss b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.scss index 2b7b99c0a71c..af7983661bed 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.scss +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.scss @@ -11,6 +11,7 @@ overflow: hidden; text-overflow: ellipsis; max-width: 0; + padding: 0 $spacing-2; &:last-child { padding-right: $spacing-2; @@ -24,10 +25,6 @@ padding: $spacing-3; } - .p-datatable .p-datatable-tbody tr.dot-pages-listing-content__row { - height: 47px; - } - .dot-pages-listing-header__language-input .p-dropdown-label.p-inputtext { min-width: 115px; } From 8c0874df09dc8a37aac82a40271938eec7483288 Mon Sep 17 00:00:00 2001 From: alfredo-dotcms <37185433+alfredo-dotcms@users.noreply.github.com> Date: Fri, 19 May 2023 11:20:38 -0600 Subject: [PATCH 22/63] dotCMS/core#24911 fix DotFavorites-set-collapsed-panel-on-localstorage (#24981) --- .../dot-pages-favorite-panel.component.html | 144 +++++++++--------- ...dot-pages-favorite-panel.component.spec.ts | 21 +++ .../dot-pages-favorite-panel.component.ts | 10 ++ .../dot-pages-store/dot-pages.store.spec.ts | 27 +++- .../dot-pages-store/dot-pages.store.ts | 63 +++++++- 5 files changed, 187 insertions(+), 78 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.html index 0a4e7f0d9abc..93f4592f5880 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.html @@ -1,74 +1,76 @@ - - - - + + + + + - - - + + + - -
    - -
    - {{ 'favoritePage.listing.empty.header' | dm }} + +
    + +
    + {{ 'favoritePage.listing.empty.header' | dm }} +
    +

    -

    -
    - - + + + diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.spec.ts index 462efa3dbbcc..fcc4df41109b 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.spec.ts @@ -106,6 +106,9 @@ describe('DotPagesFavoritePanelComponent', () => { } }); } + setLocalStorageFavoritePanelCollapsedParams(_collapsed: boolean): void { + /* */ + } } describe('Empty state', () => { @@ -150,6 +153,18 @@ describe('DotPagesFavoritePanelComponent', () => { ).toBeTruthy(); }); + it('should set panel collapsed state', () => { + spyOn(store, 'setLocalStorageFavoritePanelCollapsedParams'); + component.toggleFavoritePagesPanel( + new Event('myevent', { + bubbles: true, + cancelable: true, + composed: false + }) + ); + expect(store.setLocalStorageFavoritePanelCollapsedParams).toHaveBeenCalledTimes(1); + }); + it('should load empty pages cards container', () => { expect( de @@ -193,6 +208,9 @@ describe('DotPagesFavoritePanelComponent', () => { getFavoritePages(_itemsPerPage: number): void { /* */ } + setLocalStorageFavoritePanelCollapsedParams(_collapsed: boolean): void { + /* */ + } } beforeEach(() => { TestBed.configureTestingModule({ @@ -382,6 +400,9 @@ describe('DotPagesFavoritePanelComponent', () => { limitFavoritePages(_limit: number): void { /* */ } + setLocalStorageFavoritePanelCollapsedParams(_collapsed: boolean): void { + /* */ + } } beforeEach(() => { TestBed.configureTestingModule({ diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.ts index de7dd8269ba5..007c86ee5172 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.ts @@ -64,6 +64,16 @@ export class DotPagesFavoritePanelComponent { this.currentLimitSize = FAVORITE_PAGE_LIMIT; } + /** + * Event to collapse or not Favorite Page panel + * + * @param {Event} event + * @memberof DotPagesComponent + */ + toggleFavoritePagesPanel($event: Event): void { + this.store.setLocalStorageFavoritePanelCollapsedParams($event['collapsed']); + } + /** * Event that opens dialog to edit/delete Favorite Page * diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts index fdb94293b87a..33a06ebe1ff8 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts @@ -24,6 +24,7 @@ import { DotEventsService, DotLanguagesService, DotLicenseService, + DotLocalstorageService, DotPageTypesService, DotPageWorkflowsActionsService, DotRenderMode, @@ -61,7 +62,11 @@ import { mockWorkflowsActions } from '@dotcms/utils-testing'; -import { DotPageStore, SESSION_STORAGE_FAVORITES_KEY } from './dot-pages.store'; +import { + DotPageStore, + LOCAL_STORAGE_FAVORITES_PANEL_KEY, + SESSION_STORAGE_FAVORITES_KEY +} from './dot-pages.store'; import { contentTypeDataMock } from '../../dot-edit-page/components/dot-palette/dot-palette-content-type/dot-palette-content-type.component.spec'; import { DotLicenseServiceMock } from '../../dot-edit-page/content/services/html/dot-edit-content-toolbar-html.service.spec'; @@ -104,6 +109,7 @@ describe('DotPageStore', () => { let dotWorkflowActionsFireService: DotWorkflowActionsFireService; let dotHttpErrorManagerService: DotHttpErrorManagerService; let dotFavoritePageService: DotFavoritePageService; + let dotLocalstorageService: DotLocalstorageService; beforeEach(() => { TestBed.configureTestingModule({ @@ -122,6 +128,7 @@ describe('DotPageStore', () => { LoggerService, StringUtils, DotFavoritePageService, + DotLocalstorageService, { provide: DialogService, useClass: DialogServiceMock }, { provide: DotcmsEventsService, useClass: DotcmsEventsServiceMock }, { provide: CoreWebService, useClass: CoreWebServiceMock }, @@ -147,10 +154,12 @@ describe('DotPageStore', () => { dotPageWorkflowsActionsService = TestBed.inject(DotPageWorkflowsActionsService); dotWorkflowActionsFireService = TestBed.inject(DotWorkflowActionsFireService); dotFavoritePageService = TestBed.inject(DotFavoritePageService); + dotLocalstorageService = TestBed.inject(DotLocalstorageService); spyOn(dialogService, 'open').and.callThrough(); spyOn(dotHttpErrorManagerService, 'handle'); + spyOn(dotLocalstorageService, 'getItem').and.returnValue(`true`); dotPageStore.setInitialStateData(5); dotPageStore.setKeyword('test'); @@ -257,6 +266,12 @@ describe('DotPageStore', () => { }); }); + it('should get isFavoritePanelCollaped Params', () => { + dotPageStore.isFavoritePanelCollaped$.subscribe((data) => { + expect(data).toEqual(true); + }); + }); + it('should get pages loading status', () => { dotPageStore.isPagesLoading$.subscribe((data) => { expect(data).toEqual(true); @@ -314,6 +329,15 @@ describe('DotPageStore', () => { ); }); + it('should update Local Storage Panel Collapsed Params', () => { + spyOn(dotLocalstorageService, 'setItem').and.callThrough(); + dotPageStore.setLocalStorageFavoritePanelCollapsedParams(true); + expect(dotLocalstorageService.setItem).toHaveBeenCalledWith( + LOCAL_STORAGE_FAVORITES_PANEL_KEY, + 'true' + ); + }); + it('should update Pages Status', () => { dotPageStore.setPagesStatus(ComponentStatus.LOADING); dotPageStore.state$.subscribe((data) => { @@ -374,6 +398,7 @@ describe('DotPageStore', () => { expect(data.favoritePages.items).toEqual(expectedInputArray); expect(data.favoritePages.showLoadMoreButton).toEqual(true); expect(data.favoritePages.total).toEqual(expectedInputArray.length); + expect(data.favoritePages.collapsed).toEqual(undefined); }); expect(dotFavoritePageService.get).toHaveBeenCalledTimes(1); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts index a09f48a7e9a1..a3a03f81d169 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts @@ -32,6 +32,7 @@ import { DotEventsService, DotLanguagesService, DotLicenseService, + DotLocalstorageService, DotMessageService, DotPageTypesService, DotPageWorkflowsActionsService, @@ -61,6 +62,7 @@ import { DotPagesCreatePageDialogComponent } from '../dot-pages-create-page-dial export interface DotPagesState { favoritePages: { + collapsed?: boolean; items: DotCMSContentlet[]; showLoadMoreButton: boolean; total: number; @@ -95,6 +97,8 @@ export interface DotSessionStorageFilter { export const FAVORITE_PAGE_LIMIT = 5; +export const LOCAL_STORAGE_FAVORITES_PANEL_KEY = 'FavoritesPanelCollapsed'; + export const SESSION_STORAGE_FAVORITES_KEY = 'FavoritesSearchTerms'; @Injectable() @@ -109,6 +113,10 @@ export class DotPageStore extends ComponentStore { }; }); + readonly isFavoritePanelCollaped$: Observable = this.select((state) => { + return state.favoritePages.collapsed; + }); + readonly isPagesLoading$: Observable = this.select( (state) => state.pages.status === ComponentStatus.LOADING || @@ -520,6 +528,7 @@ export class DotPageStore extends ComponentStore { readonly vm$: Observable = this.select( this.state$, + this.isFavoritePanelCollaped$, this.isPagesLoading$, this.isPortletLoading$, this.languageOptions$, @@ -538,6 +547,7 @@ export class DotPageStore extends ComponentStore { pages, portletStatus }, + isFavoritePanelCollaped, isPagesLoading, isPortletLoading, languageOptions, @@ -554,6 +564,7 @@ export class DotPageStore extends ComponentStore { loggedUser, pages, portletStatus, + isFavoritePanelCollaped, isPagesLoading, isPortletLoading, languageOptions, @@ -677,6 +688,14 @@ export class DotPageStore extends ComponentStore { return of(params); } + private getLocalStorageFavoritePanelParams(): Observable { + const collapsed = JSON.parse( + this.dotLocalstorageService.getItem(LOCAL_STORAGE_FAVORITES_PANEL_KEY) + ); + + return of(collapsed); + } + private getSelectActions( actions: DotCMSWorkflowAction[], item: DotCMSContentlet, @@ -829,7 +848,8 @@ export class DotPageStore extends ComponentStore { private dotEventsService: DotEventsService, private pushPublishService: PushPublishService, private siteService: SiteService, - private dotFavoritePageService: DotFavoritePageService + private dotFavoritePageService: DotFavoritePageService, + private dotLocalstorageService: DotLocalstorageService ) { super(null); } @@ -848,7 +868,8 @@ export class DotPageStore extends ComponentStore { this.pushPublishService .getEnvironments() .pipe(map((environments: DotEnvironment[]) => !!environments.length)), - this.getSessionStorageFilterParams() + this.getSessionStorageFilterParams(), + this.getLocalStorageFavoritePanelParams() ]) .pipe( take(1), @@ -859,7 +880,16 @@ export class DotPageStore extends ComponentStore { languages, isEnterprise, environments, - filterParams + filterParams, + collapsedParam + ]: [ + ESContent, + DotCurrentUser, + DotLanguage[], + boolean, + boolean, + DotSessionStorageFilter, + boolean ]) => { return this.dotCurrentUser .getUserPermissions( @@ -877,7 +907,8 @@ export class DotPageStore extends ComponentStore { isEnterprise, environments, permissionsType, - filterParams + filterParams, + collapsedParam ]; }) ); @@ -892,7 +923,8 @@ export class DotPageStore extends ComponentStore { isEnterprise, environments, permissions, - filterParams + filterParams, + collapsedParam ]: [ ESContent, DotCurrentUser, @@ -900,10 +932,12 @@ export class DotPageStore extends ComponentStore { boolean, boolean, DotPermissionsType, - DotSessionStorageFilter + DotSessionStorageFilter, + boolean ]): void => { this.setState({ favoritePages: { + collapsed: collapsedParam, items: favoritePages?.jsonObjectView.contentlets, showLoadMoreButton: favoritePages.jsonObjectView.contentlets.length < @@ -937,6 +971,7 @@ export class DotPageStore extends ComponentStore { () => { this.setState({ favoritePages: { + collapsed: true, items: [], showLoadMoreButton: false, total: 0 @@ -976,6 +1011,22 @@ export class DotPageStore extends ComponentStore { this.setFavoritePages(favoritePages.slice(0, limit)); } + /** + * Sets on LocalStorage Favorite Page panel collapsed state + * @param boolean collapsed + * @memberof DotFavoritePageStore + */ + setLocalStorageFavoritePanelCollapsedParams(collapsed: boolean): void { + this.dotLocalstorageService.setItem( + LOCAL_STORAGE_FAVORITES_PANEL_KEY, + collapsed.toString() + ); + } + + /** + * Sets on Session Storage Page's table filter params + * @memberof DotFavoritePageStore + */ setSessionStorageFilterParams(): void { const { keyword, languageId, archived } = this.get().pages; From baf0c2b10e534996717d33b4dcb6c2d570807f68 Mon Sep 17 00:00:00 2001 From: alfredo-dotcms <37185433+alfredo-dotcms@users.noreply.github.com> Date: Mon, 22 May 2023 10:29:49 -0600 Subject: [PATCH 23/63] dotCMS/core#24984 fix Archived pages should not be able to ADD/EDIT bookmarks (#24995) --- .../dot-pages-store/dot-pages.store.spec.ts | 47 +++++++++++-- .../dot-pages-store/dot-pages.store.ts | 68 ++++++++++--------- 2 files changed, 79 insertions(+), 36 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts index 33a06ebe1ff8..6e0368dac816 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts @@ -156,7 +156,6 @@ describe('DotPageStore', () => { dotFavoritePageService = TestBed.inject(DotFavoritePageService); dotLocalstorageService = TestBed.inject(DotLocalstorageService); - spyOn(dialogService, 'open').and.callThrough(); spyOn(dotHttpErrorManagerService, 'handle'); spyOn(dotLocalstorageService, 'getItem').and.returnValue(`true`); @@ -639,12 +638,45 @@ describe('DotPageStore', () => { }); }); + it('should not have Add/Edit Bookmark actions in context menu when contentlet is archived', () => { + spyOn(dotPageWorkflowsActionsService, 'getByUrl').and.returnValue( + of({ actions: mockWorkflowsActions, page: dotcmsContentletMock }) + ); + + dotPageStore.showActionsMenu({ + item: { + ...favoritePagesInitialTestData[1], + url: '/index2?host_id=A&language_id=1&device_inode=123', + contentType: 'dotFavoritePage', + archived: true + }, + actionMenuDomId: 'test1' + }); + + expect(dotPageWorkflowsActionsService.getByUrl).toHaveBeenCalledWith({ + host_id: 'A', + language_id: '1', + url: '/index2' + }); + + dotPageStore.state$.subscribe((data) => { + expect(data.pages.menuActions.length).toEqual(8); + expect(data.pages.menuActions[0].label).toEqual('favoritePage.contextMenu.action.edit'); + expect(data.pages.menuActions[1].label).toEqual('favoritePage.dialog.delete.button'); + }); + }); + it('should get all menu actions from a favorite page when page is archived', () => { - const error404 = mockResponseView(404, '/page', null, { message: 'error' }); - spyOn(dotPageWorkflowsActionsService, 'getByUrl').and.returnValue(throwError(error404)); + spyOn(dotPageWorkflowsActionsService, 'getByUrl').and.returnValue( + of({ actions: mockWorkflowsActions, page: dotcmsContentletMock }) + ); dotPageStore.showActionsMenu({ - item: { ...favoritePagesInitialTestData[0], contentType: 'dotFavoritePage' }, + item: { + ...favoritePagesInitialTestData[0], + contentType: 'dotFavoritePage', + archived: true + }, actionMenuDomId: 'test1' }); @@ -655,9 +687,14 @@ describe('DotPageStore', () => { }); dotPageStore.state$.subscribe((data) => { - expect(data.pages.menuActions.length).toEqual(2); expect(data.pages.menuActions[0].label).toEqual('favoritePage.contextMenu.action.edit'); expect(data.pages.menuActions[1].label).toEqual('favoritePage.dialog.delete.button'); + expect(data.pages.menuActions[2]).toEqual({ separator: true }); + expect(data.pages.menuActions[3].label).toEqual('Assign Workflow'); + expect(data.pages.menuActions[4].label).toEqual('Save'); + expect(data.pages.menuActions[5].label).toEqual('Save / Publish'); + expect(data.pages.menuActions[6].label).toEqual('contenttypes.content.push_publish'); + expect(data.pages.menuActions[7].label).toEqual('contenttypes.content.add_to_bundle'); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts index a3a03f81d169..fff7c7bf8c1b 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts @@ -509,14 +509,16 @@ export class DotPageStore extends ComponentStore { take(1), tapResponse( ({ workflowsData, dotFavorite }) => { - this.setMenuActions({ - actions: this.getSelectActions( - workflowsData?.actions, - workflowsData?.page, - dotFavorite.jsonObjectView.contentlets[0] - ), - actionMenuDomId - }); + if (workflowsData) { + this.setMenuActions({ + actions: this.getSelectActions( + workflowsData?.actions, + workflowsData?.page, + dotFavorite.jsonObjectView.contentlets[0] + ), + actionMenuDomId + }); + } }, (error: HttpErrorResponse) => this.httpErrorManagerService.handle(error) ) @@ -712,29 +714,31 @@ export class DotPageStore extends ComponentStore { }); // Adding DotFavorite actions - actionsMenu.push({ - label: favoritePage - ? this.dotMessageService.get('favoritePage.contextMenu.action.edit') - : this.dotMessageService.get('favoritePage.contextMenu.action.add'), - command: () => { - this.dialogService.open(DotFavoritePageComponent, { - header: this.dotMessageService.get('favoritePage.dialog.header'), - width: '80rem', - data: { - page: { - favoritePageUrl, - favoritePage - }, - onSave: () => { - this.getFavoritePages(FAVORITE_PAGE_LIMIT); - }, - onDelete: () => { - this.getFavoritePages(FAVORITE_PAGE_LIMIT); + if (!item.archived) { + actionsMenu.push({ + label: favoritePage + ? this.dotMessageService.get('favoritePage.contextMenu.action.edit') + : this.dotMessageService.get('favoritePage.contextMenu.action.add'), + command: () => { + this.dialogService.open(DotFavoritePageComponent, { + header: this.dotMessageService.get('favoritePage.dialog.header'), + width: '80rem', + data: { + page: { + favoritePageUrl, + favoritePage + }, + onSave: () => { + this.getFavoritePages(FAVORITE_PAGE_LIMIT); + }, + onDelete: () => { + this.getFavoritePages(FAVORITE_PAGE_LIMIT); + } } - } - }); - } - }); + }); + } + }); + } if (favoritePage) { actionsMenu.push({ @@ -749,7 +753,9 @@ export class DotPageStore extends ComponentStore { return actionsMenu; } - actionsMenu.push({ separator: true }); + if (actionsMenu?.length > 0) { + actionsMenu.push({ separator: true }); + } // Adding Edit & View actions const { loggedUser, isEnterprise, environments } = this.get(); From 84fcff9345f45007bf0dc6fd10fa5f8f376b6c90 Mon Sep 17 00:00:00 2001 From: alfredo-dotcms <37185433+alfredo-dotcms@users.noreply.github.com> Date: Mon, 22 May 2023 17:32:46 -0600 Subject: [PATCH 24/63] Fix #24991 dot favorite pages table stuck when site changed (#25011) * dotCMS/core#24984 fix Archived pages should not be able to ADD/EDIT bookmarks * dotCMS/core#24991 fix Fix Stuck State in the New Pages Portlet When Changing Sites --- .../dot-pages-store/dot-pages.store.spec.ts | 30 +++++++++++++++++++ .../dot-pages-store/dot-pages.store.ts | 8 ++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts index 6e0368dac816..aa028ef7a937 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts @@ -454,6 +454,36 @@ describe('DotPageStore', () => { }); }); + it('should set Pages to empty when changed from a Site with data to an empty one', () => { + const pagesData = [ + { + ...favoritePagesInitialTestData[0] + }, + { + ...favoritePagesInitialTestData[1] + } + ]; + + dotPageStore.setPages(pagesData); + + spyOn(dotESContentService, 'get').and.returnValue( + of({ + contentTook: 0, + jsonObjectView: { + contentlets: [] + }, + queryTook: 1, + resultsSize: 0 + }) + ); + dotPageStore.getPages({ offset: 0, sortField: 'title', sortOrder: 1 }); + + dotPageStore.state$.subscribe((data) => { + expect(data.pages.items).toEqual([]); + }); + expect(dotESContentService.get).toHaveBeenCalledTimes(1); + }); + it('should handle error when get Pages value fails', () => { const error500 = mockResponseView(500, '/test', null, { message: 'error' }); spyOn(dotESContentService, 'get').and.returnValue(throwError(error500)); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts index fff7c7bf8c1b..54512125093a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts @@ -459,17 +459,13 @@ export class DotPageStore extends ComponentStore { return this.getPagesData(offset, sortOrderValue, sortField).pipe( tapResponse( (items) => { - let currentPages = this.get().pages.items; - - if (currentPages.length === 0) { - currentPages = Array.from({ length: items.resultsSize }); - } + const currentPages = Array.from({ length: items.resultsSize }); Array.prototype.splice.apply(currentPages, [ ...[offset, 40], ...items.jsonObjectView.contentlets ]); - this.setPages(currentPages); + this.setPages(currentPages as DotCMSContentlet[]); }, (error: HttpErrorResponse) => { this.setPagesStatus(ComponentStatus.LOADED); From e9937eff0867c476a68f916deddcf0ca8856dbf3 Mon Sep 17 00:00:00 2001 From: Manuel Rojas Date: Mon, 22 May 2023 17:33:20 -0600 Subject: [PATCH 25/63] Adding fix for sonarqube (#25012) --- .../src/components/dot-html-to-image/dot-html-to-image.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core-web/libs/dotcms-webcomponents/src/components/dot-html-to-image/dot-html-to-image.tsx b/core-web/libs/dotcms-webcomponents/src/components/dot-html-to-image/dot-html-to-image.tsx index 68906863e34f..07d883d01379 100644 --- a/core-web/libs/dotcms-webcomponents/src/components/dot-html-to-image/dot-html-to-image.tsx +++ b/core-web/libs/dotcms-webcomponents/src/components/dot-html-to-image/dot-html-to-image.tsx @@ -94,7 +94,7 @@ export class DotHtmlToImage { } private getIframeDocument(): HtmlIframeDoc { - const iframe = document.querySelector(`#${this.iframeId}`) as HTMLIFrameElement; + const iframe: HTMLIFrameElement = document.querySelector(`#${this.iframeId}`); const doc = iframe.contentDocument || iframe.contentWindow.document; return { doc, iframe }; From 54ec6135ead19ccba0a14ed5f56d9f36f8c4117d Mon Sep 17 00:00:00 2001 From: Rafael Velazco Date: Wed, 24 May 2023 12:07:13 -0400 Subject: [PATCH 26/63] Favorite Pages: Allow User Cross-Site Page Editing (#25005) * dev: allow user cross site page editing #24992 * dev: set site in edit page route resolver * dev: handler error * fix: test * feedback v1 * feedback v2 * fix: docs * fix: docs v2 --- .../dot-edit-page-resolver.service.spec.ts | 46 ++++++++++- .../dot-edit-page-resolver.service.ts | 79 ++++++++++++------- ...dot-experiment-experiment.resolver.spec.ts | 9 ++- .../portlets/dot-pages/dot-pages.module.ts | 2 - .../dot-toolbar/dot-toolbar.component.ts | 12 +-- .../src/lib/core/site.service.mock.ts | 12 ++- .../dotcms-js/src/lib/core/site.service.ts | 53 +++++++++---- .../src/lib/site-service.mock.ts | 13 ++- 8 files changed, 167 insertions(+), 59 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/shared/services/dot-edit-page-resolver/dot-edit-page-resolver.service.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/shared/services/dot-edit-page-resolver/dot-edit-page-resolver.service.spec.ts index f1ad8e1daaf0..de3162795a00 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/shared/services/dot-edit-page-resolver/dot-edit-page-resolver.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/shared/services/dot-edit-page-resolver/dot-edit-page-resolver.service.spec.ts @@ -22,7 +22,7 @@ import { DotESContentService, DotPageRenderService } from '@dotcms/data-access'; -import { CoreWebService, HttpCode, LoginService } from '@dotcms/dotcms-js'; +import { CoreWebService, HttpCode, LoginService, SiteService } from '@dotcms/dotcms-js'; import { DotPageMode, DotPageRender, DotPageRenderState } from '@dotcms/dotcms-models'; import { CoreWebServiceMock, @@ -30,7 +30,8 @@ import { mockDotRenderedPage, MockDotRouterService, mockResponseView, - mockUser + mockUser, + SiteServiceMock } from '@dotcms/utils-testing'; import { DotEditPageResolver } from './dot-edit-page-resolver.service'; @@ -51,6 +52,7 @@ describe('DotEditPageResolver', () => { let injector: TestBed; let dotEditPageResolver: DotEditPageResolver; + let siteService: SiteService; beforeEach(() => { TestBed.configureTestingModule({ @@ -72,6 +74,7 @@ describe('DotEditPageResolver', () => { DotFavoritePageService, { provide: DotRouterService, useClass: MockDotRouterService }, { provide: DotMessageDisplayService, useClass: DotMessageDisplayServiceMock }, + { provide: SiteService, useClass: SiteServiceMock }, { provide: ActivatedRouteSnapshot, useValue: route @@ -88,6 +91,7 @@ describe('DotEditPageResolver', () => { dotPageStateService = injector.get(DotPageStateService); dotPageStateServiceRequestPageSpy = spyOn(dotPageStateService, 'requestPage'); dotRouterService = injector.get(DotRouterService); + siteService = injector.get(SiteService); spyOn(dotHttpErrorManagerService, 'handle').and.returnValue(of()); }); @@ -144,6 +148,44 @@ describe('DotEditPageResolver', () => { expect(dotPageStateServiceRequestPageSpy).not.toHaveBeenCalled(); }); + describe('Switch Site', () => { + it('should switch site when host_id is present in queryparams', () => { + route.queryParams.host_id = '123'; + spyOn(siteService, 'switchSiteById').and.returnValue(of(null)); + const mock = new DotPageRenderState( + mockUser(), + new DotPageRender(mockDotRenderedPage()) + ); + dotPageStateServiceRequestPageSpy.and.returnValue(of(mock)); + dotEditPageResolver.resolve(route).subscribe(); + expect(siteService.switchSiteById).toHaveBeenCalledWith('123'); + }); + + it('should not switch site when host_id is not present in queryparams', () => { + route.queryParams = {}; + spyOn(siteService, 'switchSiteById').and.returnValue(of(null)); + const mock = new DotPageRenderState( + mockUser(), + new DotPageRender(mockDotRenderedPage()) + ); + dotPageStateServiceRequestPageSpy.and.returnValue(of(mock)); + dotEditPageResolver.resolve(route).subscribe(); + expect(siteService.switchSiteById).not.toHaveBeenCalled(); + }); + + it('should not switch site when host_id is equal to current site id', () => { + route.queryParams.host_id = siteService.currentSite.identifier; + spyOn(siteService, 'switchSiteById').and.returnValue(of(null)); + const mock = new DotPageRenderState( + mockUser(), + new DotPageRender(mockDotRenderedPage()) + ); + dotPageStateServiceRequestPageSpy.and.returnValue(of(mock)); + dotEditPageResolver.resolve(route).subscribe(); + expect(siteService.switchSiteById).not.toHaveBeenCalled(); + }); + }); + describe('handle layout', () => { beforeEach(() => { route.children = [ diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/shared/services/dot-edit-page-resolver/dot-edit-page-resolver.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/shared/services/dot-edit-page-resolver/dot-edit-page-resolver.service.ts index da5c7b10f0de..d8035cb1add4 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/shared/services/dot-edit-page-resolver/dot-edit-page-resolver.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/shared/services/dot-edit-page-resolver/dot-edit-page-resolver.service.ts @@ -1,14 +1,14 @@ -import { Observable, of, throwError } from 'rxjs'; +import { Observable, forkJoin, of, throwError } from 'rxjs'; import { HttpErrorResponse, HttpHeaders, HttpResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; -import { catchError, filter, map, switchMap, tap } from 'rxjs/operators'; +import { catchError, filter, map, switchMap } from 'rxjs/operators'; import { DotHttpErrorManagerService } from '@dotcms/app/api/services/dot-http-error-manager/dot-http-error-manager.service'; import { DotRouterService } from '@dotcms/app/api/services/dot-router/dot-router.service'; -import { DotCMSResponse, HttpCode } from '@dotcms/dotcms-js'; +import { DotCMSResponse, HttpCode, Site, SiteService } from '@dotcms/dotcms-js'; import { DotPageRenderOptions, DotPageRenderState } from '@dotcms/dotcms-models'; import { DotPageStateService } from '../../../content/services/dot-page-state/dot-page-state.service'; @@ -25,37 +25,21 @@ export class DotEditPageResolver implements Resolve { constructor( private dotPageStateService: DotPageStateService, private dotRouterService: DotRouterService, - private dotHttpErrorManagerService: DotHttpErrorManagerService + private dotHttpErrorManagerService: DotHttpErrorManagerService, + private siteService: SiteService ) {} resolve(route: ActivatedRouteSnapshot): Observable { const data = this.dotPageStateService.getInternalNavigationState(); + const renderOptions = this.getDotPageRenderOptions(route); + const currentSection = route.children[0].url[0].path; + const isLayout = currentSection === 'layout'; + const hostId = route.queryParams?.host_id; - if (data) { - return of(data); - } else { - return this.dotPageStateService.requestPage(this.getDotPageRenderOptions(route)).pipe( - tap((state: DotPageRenderState) => { - if (!state) { - this.dotRouterService.goToSiteBrowser(); - } - }), - filter((state: DotPageRenderState) => !!state), - switchMap((dotRenderedPageState: DotPageRenderState) => { - const currentSection = route.children[0].url[0].path; - const isLayout = currentSection === 'layout'; - - return isLayout - ? this.checkUserCanGoToLayout(dotRenderedPageState) - : of(dotRenderedPageState); - }), - catchError((err: HttpErrorResponse) => { - this.dotRouterService.goToSiteBrowser(); - - return this.dotHttpErrorManagerService.handle(err).pipe(map(() => null)); - }) - ); - } + // If we have data, we don't need to request the page again + const data$ = data ? of(data) : this.getPageRenderState(renderOptions, isLayout); + + return forkJoin([this.setSite(hostId), data$]).pipe(map(([_, pageRender]) => pageRender)); } private checkUserCanGoToLayout( @@ -104,4 +88,41 @@ export class DotEditPageResolver implements Resolve { return renderOptions; } + + private setSite(id: string): Observable { + const currentSiteId = this.siteService.currentSite?.identifier; + const shouldSwitchSite = id && id !== currentSiteId; + + // If we have a site id and is different from the current one, we switch + return shouldSwitchSite + ? this.siteService.switchSiteById(id).pipe( + catchError((err: HttpErrorResponse) => { + return this.dotHttpErrorManagerService.handle(err).pipe(map(() => null)); + }) + ) + : of(null); + } + + private getPageRenderState( + renderOptions: DotPageRenderOptions, + isLayout: boolean + ): Observable { + return this.dotPageStateService.requestPage(renderOptions).pipe( + filter((state: DotPageRenderState) => { + if (!state) this.dotRouterService.goToSiteBrowser(); + + return !!state; + }), + switchMap((dotRenderedPageState: DotPageRenderState) => { + return isLayout + ? this.checkUserCanGoToLayout(dotRenderedPageState) + : of(dotRenderedPageState); + }), + catchError((err: HttpErrorResponse) => { + this.dotRouterService.goToSiteBrowser(); + + return this.dotHttpErrorManagerService.handle(err).pipe(map(() => null)); + }) + ); + } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/resolvers/dot-experiment-experiment.resolver.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/resolvers/dot-experiment-experiment.resolver.spec.ts index b3136ebeca9f..558e547f7135 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/resolvers/dot-experiment-experiment.resolver.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/resolvers/dot-experiment-experiment.resolver.spec.ts @@ -18,7 +18,13 @@ import { DotESContentService, DotPageRenderService } from '@dotcms/data-access'; -import { CoreWebService, HttpCode, LoginService } from '@dotcms/dotcms-js'; +import { + CoreWebService, + HttpCode, + LoginService, + SiteService, + SiteServiceMock +} from '@dotcms/dotcms-js'; import { DotPageRender, DotPageRenderState } from '@dotcms/dotcms-models'; import { CoreWebServiceMock, @@ -66,6 +72,7 @@ describe('DotExperimentExperimentResolver', () => { DotFavoritePageService, { provide: DotMessageDisplayService, useClass: DotMessageDisplayServiceMock }, { provide: DotRouterService, useClass: MockDotRouterService }, + { provide: SiteService, useClass: SiteServiceMock }, { provide: ActivatedRouteSnapshot, useValue: route diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.module.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.module.ts index 376347b86148..9bb14ba61c40 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.module.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.module.ts @@ -19,7 +19,6 @@ import { DotPageTypesService, DotPageWorkflowsActionsService } from '@dotcms/data-access'; -import { SiteService } from '@dotcms/dotcms-js'; import { DotPagesCreatePageDialogComponent } from './dot-pages-create-page-dialog/dot-pages-create-page-dialog.component'; import { DotPagesFavoritePanelModule } from './dot-pages-favorite-panel/dot-pages-favorite-panel.module'; @@ -51,7 +50,6 @@ import { DotPagesComponent } from './dot-pages.component'; DotWorkflowActionsFireService, DotWorkflowEventHandlerService, DotRouterService, - SiteService, DotFavoritePageService ] }) diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.component.ts index cd70fcd3c782..019acb12f86e 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.component.ts @@ -39,11 +39,13 @@ export class DotToolbarComponent implements OnInit { } siteChange(site: Site): void { - this.siteService.switchSite(site); - - if (this.dotRouterService.isEditPage()) { - this.dotRouterService.goToSiteBrowser(); - } + this.siteService.switchSite(site).subscribe(() => { + // wait for the site to be switched + // before redirecting to the site browser + if (this.dotRouterService.isEditPage()) { + this.dotRouterService.goToSiteBrowser(); + } + }); } handleMainButtonClick(): void { diff --git a/core-web/libs/dotcms-js/src/lib/core/site.service.mock.ts b/core-web/libs/dotcms-js/src/lib/core/site.service.mock.ts index dc0590ca7889..3bb688da0a85 100644 --- a/core-web/libs/dotcms-js/src/lib/core/site.service.mock.ts +++ b/core-web/libs/dotcms-js/src/lib/core/site.service.mock.ts @@ -1,5 +1,6 @@ -import { of as observableOf, Observable, Subject, merge } from 'rxjs'; +import { of as observableOf, Observable, Subject, merge, of } from 'rxjs'; import { Site } from '@dotcms/dotcms-js'; +import { switchMap } from 'rxjs/operators'; export const mockSites: Site[] = [ { @@ -37,8 +38,13 @@ export class SiteServiceMock { this._switchSite$.next(site || mockSites[0]); } - // eslint-disable-next-line @typescript-eslint/no-empty-function - switchSite(_site: Site) {} + switchSiteById(): Observable { + return this.getSiteById().pipe(switchMap((site) => this.switchSite(site))); + } + + switchSite(site: Site): Observable { + return of(site); + } getCurrentSite(): Observable { return merge(observableOf(mockSites[0]), this.switchSite$); diff --git a/core-web/libs/dotcms-js/src/lib/core/site.service.ts b/core-web/libs/dotcms-js/src/lib/core/site.service.ts index 9867923629e9..4fff9cde49fc 100644 --- a/core-web/libs/dotcms-js/src/lib/core/site.service.ts +++ b/core-web/libs/dotcms-js/src/lib/core/site.service.ts @@ -2,7 +2,7 @@ import { Observable, Subject, of, merge } from 'rxjs'; import { Injectable } from '@angular/core'; -import { pluck, map, take } from 'rxjs/operators'; +import { pluck, map, take, switchMap, tap } from 'rxjs/operators'; import { CoreWebService } from './core-web.service'; import { DotcmsEventsService } from './dotcms-events.service'; @@ -70,10 +70,11 @@ export class SiteService { if (siteIdentifier === this.selectedSite.identifier) { name === 'ARCHIVE_SITE' ? this.switchToDefaultSite() - .pipe(take(1)) - .subscribe((currentSite: Site) => { - this.switchSite(currentSite); - }) + .pipe( + take(1), + switchMap((site) => this.switchSite(site)) + ) + .subscribe() : this.loadCurrentSite(); } } @@ -132,8 +133,8 @@ export class SiteService { /** * Get a site by the id * - * @param string id - * @returns Observable + * @param {string} id + * @return {*} {Observable} * @memberof SiteService */ getSiteById(id: string): Observable { @@ -147,22 +148,46 @@ export class SiteService { ); } + /** + * Switch site by the id + * This method gets a new site by the id and switch to it + * + * @param {string} id + * @return {*} {Observable} + * @memberof SiteService + */ + switchSiteById(id: string): Observable { + this.loggerService.debug('Applying a Site Switch'); + + return this.getSiteById(id).pipe( + switchMap((site) => { + // If there is a site we switch to it + return site ? this.switchSite(site) : of(null); + }), + take(1) + ); + } + /** * Change the current site - * @param Site site + * + * @param {Site} site + * @return {*} {Observable} * @memberof SiteService */ - switchSite(site: Site): void { + switchSite(site: Site): Observable { this.loggerService.debug('Applying a Site Switch', site.identifier); - this.coreWebService + + return this.coreWebService .requestView({ method: 'PUT', url: `${this.urls.switchSiteUrl}/${site.identifier}` }) - .pipe(take(1)) - .subscribe(() => { - this.setCurrentSite(site); - }); + .pipe( + take(1), + tap(() => this.setCurrentSite(site)), + map(() => site) + ); } /** diff --git a/core-web/libs/utils-testing/src/lib/site-service.mock.ts b/core-web/libs/utils-testing/src/lib/site-service.mock.ts index a829852e6bbe..f12001738f14 100644 --- a/core-web/libs/utils-testing/src/lib/site-service.mock.ts +++ b/core-web/libs/utils-testing/src/lib/site-service.mock.ts @@ -1,4 +1,7 @@ import { of as observableOf, Observable, Subject, merge } from 'rxjs'; + +import { switchMap } from 'rxjs/operators'; + import { Site } from '@dotcms/dotcms-js'; export const mockSites: Site[] = [ @@ -17,7 +20,7 @@ export const mockSites: Site[] = [ ]; export class SiteServiceMock { - _currentSite: Site; + _currentSite!: Site; private _switchSite$: Subject = new Subject(); get currentSite(): Site { @@ -37,8 +40,12 @@ export class SiteServiceMock { this._switchSite$.next(site || mockSites[0]); } - switchSite(_site: Site) { - /* */ + switchSiteById(_id: string): Observable { + return this.getSiteById().pipe(switchMap((site) => this.switchSite(site))); + } + + switchSite(site: Site): Observable { + return observableOf(site); } get loadedSites(): Site[] { From 63b5f3bd91040466b9dd09ed09eef0315371e859 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Wed, 24 May 2023 16:10:27 -0600 Subject: [PATCH 27/63] =?UTF-8?q?#18123=20adding=20the=20ability=20to=20ad?= =?UTF-8?q?d=20children=20relationships=20to=20the=20urlmap=E2=80=A6=20(#2?= =?UTF-8?q?4939)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #18123 adding the ability to add children relationships to the urlmap on the page render * #18123 feedback and itest done * #18213 adding feedback --- .../content/util/ContentUtilsTest.java | 65 +++++++++++++++ .../velocity/services/PageRenderUtil.java | 32 +------- .../viewtools/content/util/ContentUtils.java | 82 ++++++++++++++++++- .../render/page/PageViewSerializer.java | 41 ++++++++-- .../java/com/dotmarketing/util/WebKeys.java | 1 + 5 files changed, 181 insertions(+), 40 deletions(-) diff --git a/dotCMS/src/integration-test/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtilsTest.java b/dotCMS/src/integration-test/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtilsTest.java index 8c7f248c8885..3e995b604551 100644 --- a/dotCMS/src/integration-test/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtilsTest.java +++ b/dotCMS/src/integration-test/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtilsTest.java @@ -18,9 +18,14 @@ import com.dotcms.contenttype.model.type.ContentType; import com.dotcms.contenttype.model.type.ContentTypeBuilder; import com.dotcms.contenttype.model.type.SimpleContentType; +import com.dotcms.datagen.ContentTypeDataGen; import com.dotcms.datagen.ContentletDataGen; +import com.dotcms.datagen.FieldDataGen; import com.dotcms.datagen.SiteDataGen; import com.dotcms.datagen.TestDataUtils; +import com.dotcms.mock.request.MockHttpRequestIntegrationTest; +import com.dotcms.mock.request.MockParameterRequest; +import com.dotcms.mock.response.MockHttpResponse; import com.dotcms.rendering.velocity.viewtools.content.util.ContentUtilsTest.TestCase.LANGUAGE_TYPE_FILTER; import com.dotcms.rendering.velocity.viewtools.content.util.ContentUtilsTest.TestCase.PUBLISH_TYPE_FILTER; import com.dotcms.util.IntegrationTestInitService; @@ -39,8 +44,10 @@ import com.dotmarketing.portlets.languagesmanager.model.Language; import com.dotmarketing.portlets.structure.model.ContentletRelationships; import com.dotmarketing.portlets.structure.model.Relationship; +import com.dotmarketing.util.PageMode; import com.dotmarketing.util.UtilMethods; import com.dotmarketing.util.WebKeys.Relationship.RELATIONSHIP_CARDINALITY; +import com.google.common.collect.ImmutableMap; import com.liferay.portal.model.User; import com.liferay.util.StringPool; import com.tngtech.java.junit.dataprovider.DataProvider; @@ -48,15 +55,23 @@ import com.tngtech.java.junit.dataprovider.UseDataProvider; import java.util.ArrayList; +import java.util.Arrays; import java.util.Calendar; +import java.util.Collection; import java.util.Date; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + /** * @author nollymar */ @@ -1087,4 +1102,54 @@ public void testPullRelatedFieldShouldRespectDefaultOrder() } } } + + /** + * Method to test: {@link ContentUtils#addRelationships(Contentlet, User, PageMode, long, int, HttpServletRequest, HttpServletResponse)} + * Given Scenario: Creates a content parent with a children many to many relationship, create a few instances of the child type and related to the parent + * ExpectedResult: Calling the parameter with depth in 0 should retrieve the children contentlets + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + public void test_add_relationships_on_parent_children_related_contents() + throws DotDataException, DotSecurityException { + + // 1) create the child content type + final ContentType childContentType = new ContentTypeDataGen().velocityVarName("child"+System.currentTimeMillis()).nextPersisted(); + // 2) create parent content type and add a relationship to the child content type + final ContentType parentContentType = new ContentTypeDataGen().velocityVarName("parent"+System.currentTimeMillis()).nextPersisted(); + Field field = FieldBuilder.builder(RelationshipField.class).name("children") + .contentTypeId(parentContentType.id()).values(String.valueOf(RELATIONSHIP_CARDINALITY.MANY_TO_MANY.ordinal())) + .relationType(childContentType.variable()).build(); + + APILocator.getContentTypeFieldAPI().save(field, APILocator.systemUser()); + // 3) create a few child instances + + final Contentlet child1 = new ContentletDataGen(childContentType.id()).nextPersisted(); + final Contentlet child2 = new ContentletDataGen(childContentType.id()).nextPersisted(); + final Contentlet child3 = new ContentletDataGen(childContentType.id()).nextPersisted(); + // 4) create a instance of the parent and add the child instances to the relationship + // 5) save it + final Contentlet parent = new ContentletDataGen(parentContentType.id()).setProperty("children", Arrays.asList(child1, child2, child3)).nextPersisted(); + + // 6) retrieve again + final Contentlet parentRetrieved = APILocator.getContentletAPI().findContentletByIdentifierAnyLanguage(parent.getIdentifier()); + Assert.assertNotNull(parentRetrieved); + Assert.assertEquals(parent.getIdentifier(), parentRetrieved.getIdentifier()); + // 7) call the addRelationships method with depth = 1 + final int depth = 0; // only ids + final HttpServletRequest request = new MockHttpRequestIntegrationTest("localhost", "/api/v1/test").request(); + final HttpServletResponse response = new MockHttpResponse().response(); + ContentUtils.addRelationships(parentRetrieved, user, PageMode.EDIT_MODE, + APILocator.getLanguageAPI().getDefaultLanguage().getId(), depth, request, response); + // 8) check the children contentlets are there + Assert.assertTrue(parentRetrieved.getMap().containsKey("children")); + final Object children = parentRetrieved.get("children"); + Assert.assertNotNull(children); + Assert.assertTrue(children instanceof Collection); + final Set childrenIds = new HashSet<>((Collection) children); + Assert.assertTrue(childrenIds.contains(child1.getIdentifier())); + Assert.assertTrue(childrenIds.contains(child2.getIdentifier())); + Assert.assertTrue(childrenIds.contains(child3.getIdentifier())); + } } diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/services/PageRenderUtil.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/services/PageRenderUtil.java index ec1648a55743..6cc6c22f3845 100644 --- a/dotCMS/src/main/java/com/dotcms/rendering/velocity/services/PageRenderUtil.java +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/services/PageRenderUtil.java @@ -11,6 +11,7 @@ import com.dotcms.publisher.endpoint.bean.PublishingEndPoint; import com.dotcms.rendering.velocity.directive.ParseContainer; import com.dotcms.rendering.velocity.viewtools.DotTemplateTool; +import com.dotcms.rendering.velocity.viewtools.content.util.ContentUtils; import com.dotcms.repackage.com.google.common.collect.Lists; import com.dotcms.rest.ContentResource; import com.dotcms.rest.api.v1.DotObjectMapperProvider; @@ -110,8 +111,6 @@ public class PageRenderUtil implements Serializable { final TemplateLayout templateLayout; - // it is true, even if the pattern is false because the client has to include the depth parameter to activate it - private static final Lazy ADD_RELATIONSHIPS_ON_PAGE = Lazy.of(()->Config.getBooleanProperty("ADD_RELATIONSHIPS_ON_PAGE", true)); /** * Creates an instance of this class for a given HTML Page. @@ -356,34 +355,7 @@ private List populateContainers() throws DotDataException, DotSecu private void addRelationships(final Contentlet contentlet) { - final HttpServletRequest request = HttpServletRequestThreadLocal.INSTANCE.getRequest(); - final HttpServletResponse response = HttpServletResponseThreadLocal.INSTANCE.getResponse(); - if (ADD_RELATIONSHIPS_ON_PAGE.get() && null != response && null != request && null != request.getParameter("depth")) { - - final int depth = ConversionUtils.toInt(request.getParameter("depth"), -1); - if (depth >= 0 && depth <= 3) { - - try { - - final JSONObject jsonWithRelationShips = ContentResource.addRelationshipsToJSON(request, response, - request.getParameter("render"), user, depth, mode.respectAnonPerms, contentlet, - new JSONObject(), null, languageId, mode.showLive, false, - true); - - final HashMap relationshipsMap = DotObjectMapperProvider.getInstance() - .getDefaultObjectMapper().readValue(jsonWithRelationShips.toString(), HashMap.class); - - if (UtilMethods.isSet(relationshipsMap)) { - contentlet.getMap().putAll(relationshipsMap); - } - } catch (Exception e) { - - Logger.error(this, "Error, contentlet id:" + - contentlet.getIdentifier() + ", msg:" + e.getMessage(), e); - throw new RuntimeException(e); - } - } - } + ContentUtils.addRelationships(contentlet, user, mode, languageId); } private Contentlet getContentletByVariantFallback(final String currentVariantId, diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtils.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtils.java index 90d6f9a46bb3..23e3ae423ac7 100644 --- a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtils.java +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtils.java @@ -1,31 +1,43 @@ package com.dotcms.rendering.velocity.viewtools.content.util; +import com.dotcms.api.web.HttpServletRequestThreadLocal; +import com.dotcms.api.web.HttpServletResponseThreadLocal; import com.dotcms.content.elasticsearch.business.ESMappingAPIImpl; import com.dotcms.rendering.velocity.viewtools.content.PaginatedContentList; +import com.dotcms.rest.ContentResource; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotcms.util.ConversionUtils; import com.dotcms.util.TimeMachineUtil; import com.dotmarketing.beans.Identifier; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.web.WebAPILocator; import com.dotmarketing.common.model.ContentletSearch; import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.portlets.calendar.business.RecurrenceUtil; import com.dotmarketing.portlets.contentlet.business.ContentletAPI; import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.contentlet.model.ContentletVersionInfo; import com.dotmarketing.portlets.structure.model.Relationship; +import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; import com.dotmarketing.util.PageMode; import com.dotmarketing.util.PaginatedArrayList; import com.dotmarketing.util.UtilMethods; +import com.dotmarketing.util.WebKeys; +import com.dotmarketing.util.json.JSONObject; import com.liferay.portal.model.User; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -37,8 +49,11 @@ * @since 1.9.3 */ public class ContentUtils { - - private static ContentletAPI conAPI; + + // it is true, even if the pattern is false because the client has to include the depth parameter to activate it + private static final boolean addRelationshipsOnPage = Config.getBooleanProperty("ADD_RELATIONSHIPS_ON_PAGE", true); + + private static ContentletAPI conAPI; public static final ContentUtils INSTANCE = new ContentUtils(); private ContentUtils() { @@ -749,5 +764,68 @@ public static String addDefaultsToQuery(String query, final boolean editOrPrevie } return q; } + + + /** + * Adds the relationships to the contentlet based on the request parameters depth + * @param contentlet + * @param user + * @param mode + * @param languageId + */ + public static void addRelationships(final Contentlet contentlet, final User user, final PageMode mode, final long languageId) { + + final HttpServletRequest request = HttpServletRequestThreadLocal.INSTANCE.getRequest(); + final HttpServletResponse response = HttpServletResponseThreadLocal.INSTANCE.getResponse(); + if (addRelationshipsOnPage && + Objects.nonNull(response) && + Objects.nonNull(request) && + Objects.nonNull(user) && + Objects.nonNull(request.getParameter(WebKeys.HTMLPAGE_DEPTH)) + ) { + + final int depth = ConversionUtils.toInt(request.getParameter(WebKeys.HTMLPAGE_DEPTH), -1); + addRelationships(contentlet, user, mode, languageId, depth, request, response); + } + } + + /** + * Adds the relationships to the contentlet based on depth argument + * @param contentlet + * @param user + * @param mode + * @param languageId + * @param depth + * @param request + * @param response + */ + public static void addRelationships(final Contentlet contentlet, final User user, final PageMode mode, + final long languageId, final int depth, final HttpServletRequest request, + final HttpServletResponse response) { + + if (depth >= 0 && depth <= 3) { + + try { + + final JSONObject jsonWithRelationShips = ContentResource.addRelationshipsToJSON(request, response, + request.getParameter("render"), user, depth, mode.respectAnonPerms, contentlet, + new JSONObject(), null, languageId, mode.showLive, false, + true); + + final HashMap relationshipsMap = DotObjectMapperProvider.getInstance() + .getDefaultObjectMapper().readValue(jsonWithRelationShips.toString(), HashMap.class); + + if (UtilMethods.isSet(relationshipsMap)) { + contentlet.getMap().putAll(relationshipsMap); + } + } catch (Exception e) { + + Logger.error(ContentUtils.class, "Error, contentlet id:" + + contentlet.getIdentifier() + ", msg:" + e.getMessage(), e); + throw new DotRuntimeException(e); + } + } + + } } diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/htmlpageasset/business/render/page/PageViewSerializer.java b/dotCMS/src/main/java/com/dotmarketing/portlets/htmlpageasset/business/render/page/PageViewSerializer.java index 09de64f4ea92..88e3e4da963c 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/htmlpageasset/business/render/page/PageViewSerializer.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/htmlpageasset/business/render/page/PageViewSerializer.java @@ -1,31 +1,35 @@ package com.dotmarketing.portlets.htmlpageasset.business.render.page; -import com.dotcms.contenttype.model.field.Field; -import com.dotcms.contenttype.model.field.KeyValueField; -import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.api.web.HttpServletRequestThreadLocal; +import com.dotcms.api.web.HttpServletResponseThreadLocal; +import com.dotcms.rendering.velocity.viewtools.content.util.ContentUtils; +import com.dotmarketing.business.web.WebAPILocator; import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.contentlet.transform.DotContentletTransformer; import com.dotmarketing.portlets.contentlet.transform.DotTransformerBuilder; -import com.dotmarketing.portlets.contentlet.transform.strategy.KeyValueViewStrategy; +import com.dotmarketing.portlets.languagesmanager.model.Language; import com.dotmarketing.portlets.templates.model.Template; -import com.dotmarketing.util.UtilMethods; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.PageMode; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.ObjectWriter; import com.fasterxml.jackson.databind.SerializerProvider; import com.google.common.collect.ImmutableMap; +import com.liferay.portal.model.User; +import io.vavr.control.Try; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import java.io.CharArrayReader; import java.io.IOException; import java.util.Collections; -import java.util.List; import java.util.Map; import java.util.Objects; import java.util.TreeMap; -import static java.util.Collections.emptyMap; - /** * JsonSerializer of {@link PageView} */ @@ -71,12 +75,33 @@ protected Map getObjectMap(final PageView pageView) { protected void createObjectMapUrlContent(final Contentlet urlContent, final Map pageViewMap) { + Try.run(()->addRelationships(urlContent)) + .onFailure(e -> Logger.error(PageViewSerializer.class, e.getMessage(), e)); + final DotContentletTransformer transformer = new DotTransformerBuilder().urlContentMapTransformer().content(urlContent).build(); final Map urlContentletMap = transformer.toMaps().stream().findFirst().orElse(Collections.EMPTY_MAP); pageViewMap.put("urlContentMap", urlContentletMap); } + private static void addRelationships(final Contentlet urlContent) { + + final HttpServletRequest request = HttpServletRequestThreadLocal.INSTANCE.getRequest(); + final HttpServletResponse response = HttpServletResponseThreadLocal.INSTANCE.getResponse(); + if (null != request && null != response) { + + final User user = WebAPILocator.getUserWebAPI().getUser(request); + final PageMode mode = PageMode.get(request); + final Language language = WebAPILocator.getLanguageWebAPI().getLanguage(request); + + if (null != user && null != mode && null != language) { + + ContentUtils.addRelationships(urlContent, user, mode, language.getId()); + } + } + } + + private Map asPageMap(final PageView pageView) { final Map pageMap = this.asMap(pageView.getPage()); diff --git a/dotCMS/src/main/java/com/dotmarketing/util/WebKeys.java b/dotCMS/src/main/java/com/dotmarketing/util/WebKeys.java index 75505c67b76b..df72f1b39960 100644 --- a/dotCMS/src/main/java/com/dotmarketing/util/WebKeys.java +++ b/dotCMS/src/main/java/com/dotmarketing/util/WebKeys.java @@ -198,6 +198,7 @@ public final class WebKeys { public static final String TEMPLATE_HOSTS = "com.dotmarketing.template.hosts"; + public static final String HTMLPAGE_DEPTH = "depth"; public static final String HTMLPAGE_EDIT = "com.dotmarketing.htmlpages.edit"; public static final String HTMLPAGE_REFERER = "com.dotmarketing.htmlpages.referer"; public static final String HTMLPAGES_VIEW = "com.dotmarketing.htmlpages.view"; From 38d9038a852509041e7689cc63568b9e3cef308f Mon Sep 17 00:00:00 2001 From: Jonathan Gamba Date: Thu, 25 May 2023 15:27:12 -0600 Subject: [PATCH 28/63] Issue 24969 handle remaining contentlets without contentlet as json value (#25043) * #24969 Replacing writing into a temporal file to writing to a temporal table. * #24969 Replacing writing into a temporal file to writing to a temporal table. * #24969 Replacing writing into a temporal file to writing to a temporal table. * #24969 Replacing writing into a temporal file to writing to a temporal table. * #24969 Creating a new quartz job to migrate the remaining contentlets * #24969 Handling the whole process in batches. * #24969 Better logging * #24969 Better logging * #24969 Better logging --- .../PopulateContentletAsJSONUtilTest.java | 86 ++- .../json/PopulateContentletAsJSONUtil.java | 500 ++++++++++++------ .../job/PopulateContentletAsJSONJob.java | 84 ++- .../Task230320FixMissingContentletAsJSON.java | 12 +- 4 files changed, 496 insertions(+), 186 deletions(-) diff --git a/dotCMS/src/integration-test/java/com/dotcms/util/content/json/PopulateContentletAsJSONUtilTest.java b/dotCMS/src/integration-test/java/com/dotcms/util/content/json/PopulateContentletAsJSONUtilTest.java index 0fe03481f066..b54db7da7064 100644 --- a/dotCMS/src/integration-test/java/com/dotcms/util/content/json/PopulateContentletAsJSONUtilTest.java +++ b/dotCMS/src/integration-test/java/com/dotcms/util/content/json/PopulateContentletAsJSONUtilTest.java @@ -4,23 +4,26 @@ import com.dotcms.datagen.ContentletDataGen; import com.dotcms.datagen.SiteDataGen; import com.dotcms.datagen.TestDataUtils; +import com.dotcms.datagen.VariantDataGen; import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.variant.model.Variant; import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; import com.dotmarketing.common.db.DotConnect; import com.dotmarketing.db.DbConnectionFactory; import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.util.Logger; import org.apache.felix.framework.OSGIUtil; import org.junit.BeforeClass; import org.junit.Test; -import java.io.IOException; import java.sql.SQLException; import java.util.ArrayList; import java.util.Collection; +import static com.dotcms.util.CollectionsUtils.map; import static org.junit.Assert.*; public class PopulateContentletAsJSONUtilTest extends IntegrationTestBase { @@ -83,13 +86,13 @@ private void createContentletAsJSONColumn() throws DotDataException { /** * Method to test: {@link PopulateContentletAsJSONUtil#populateForAssetSubType(String)} *

    - * Given sceneario: We create some hosts, then we drop the contentlet_as_json column, and we add it again to simulate + * Given scenario: We create some hosts, then we drop the contentlet_as_json column, and we add it again to simulate * contlentlets without data in the contentlet_as_json, to finally run the populateForAssetSubType method. *

    * Expected result: We should have the contentlet_as_json column populated with the contentlet data in the test hosts. */ @Test - public void Test_populate_host() throws SQLException, DotDataException, IOException { + public void Test_populate_host() throws DotDataException { Collection hosts = new ArrayList<>(); @@ -152,14 +155,14 @@ public void Test_populate_host() throws SQLException, DotDataException, IOExcept /** * Method to test: {@link PopulateContentletAsJSONUtil#populateExcludingAssetSubType(String)} *

    - * Given sceneario: We create some contentlets, then we drop the contentlet_as_json column, and we add it again to + * Given scenario: We create some contentlets, then we drop the contentlet_as_json column, and we add it again to * simulate contlentlets without data in the contentlet_as_json, to finally run the populateExcludingAssetSubType method. *

    * Expected result: We should have the contentlet_as_json column populated with the contentlet data in the test * contentlets. */ @Test - public void Test_populate_All_excluding_host() throws SQLException, DotDataException, IOException { + public void Test_populate_All_excluding_host() throws DotDataException { Collection contents = new ArrayList<>(); @@ -188,7 +191,7 @@ public void Test_populate_All_excluding_host() throws SQLException, DotDataExcep "WHERE i.asset_subtype <> 'Host' AND asset_type = 'contentlet'") .loadObjectResults(); - // Make sure we have the right number of hosts + // Make sure we have the right number of contentlets assertTrue(results.size() >= 10); results.forEach(rowMap -> { @@ -207,7 +210,76 @@ public void Test_populate_All_excluding_host() throws SQLException, DotDataExcep "WHERE i.asset_subtype <> 'Host' AND asset_type = 'contentlet'") .loadObjectResults(); - // Make sure we have the right number of hosts again + // Make sure we have the right number of contentlets again + assertTrue(results.size() >= 10); + + // This time contentlet_as_json can not be null + results.forEach(rowMap -> { + assertTrue(rowMap.containsKey("contentlet_as_json")); + assertNotNull(rowMap.get("contentlet_as_json")); + }); + } finally { + // Clean up + contents.forEach(ContentletDataGen::destroy); + } + } + + /** + * Method to test: {@link PopulateContentletAsJSONUtil#populateEverything()} + *

    + * Given scenario: We create some contentlets, then we drop the contentlet_as_json column, and we add it again to + * simulate contlentlets without data in the contentlet_as_json, to finally run the populateEverything method. + *

    + * Expected result: We should have the contentlet_as_json column populated with the contentlet data in the test + * contentlets. + */ + @Test + public void Test_populate_everything() throws DotDataException, DotSecurityException { + + Collection contents = new ArrayList<>(); + + try { + + final var defaultLanguageId = APILocator.getLanguageAPI().getDefaultLanguage().getId(); + final Variant variant_1 = new VariantDataGen().nextPersisted(); + + // First we need to create some contentlets + for (int i = 0; i < 10; i++) { + + var contenlet = TestDataUtils.getGenericContentContent(true, defaultLanguageId); + contents.add(contenlet); + + // For this contentlet we need to create multiple versions + for (int j = 0; j < 3; j++) { + var newVersion = ContentletDataGen.createNewVersion(contenlet, variant_1, map()); + ContentletDataGen.publish(newVersion); + } + } + + // We drop the contentlet_as_json column + removeContentletAsJSONColumn(); + + // And we add it again + createContentletAsJSONColumn(); + + // Make sure we have the column but with not content + final DotConnect dotConnect = new DotConnect(); + var results = dotConnect.setSQL("select * from contentlet").loadObjectResults(); + + // Make sure we have the right number of contentlets + assertTrue(results.size() >= 10); + + results.forEach(rowMap -> { + assertTrue(rowMap.containsKey("contentlet_as_json")); + assertNull(rowMap.get("contentlet_as_json")); + }); + + // Now we execute the task + new PopulateContentletAsJSONUtil().populateEverything(); + + results = dotConnect.setSQL("select * from contentlet").loadObjectResults(); + + // Make sure we have the right number of contents again assertTrue(results.size() >= 10); // This time contentlet_as_json can not be null diff --git a/dotCMS/src/main/java/com/dotcms/util/content/json/PopulateContentletAsJSONUtil.java b/dotCMS/src/main/java/com/dotcms/util/content/json/PopulateContentletAsJSONUtil.java index 010dac2301ed..9d781086a10c 100644 --- a/dotCMS/src/main/java/com/dotcms/util/content/json/PopulateContentletAsJSONUtil.java +++ b/dotCMS/src/main/java/com/dotcms/util/content/json/PopulateContentletAsJSONUtil.java @@ -18,22 +18,20 @@ import com.dotmarketing.util.Logger; import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.primitives.Ints; +import io.vavr.Tuple; +import io.vavr.Tuple2; import org.apache.commons.lang3.mutable.MutableInt; import javax.annotation.Nullable; -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileWriter; import java.io.IOException; -import java.nio.file.Files; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; -import java.util.stream.Stream; /** * Utility class to populate the contentlet_as_json column in the contentlet table. @@ -48,7 +46,8 @@ public class PopulateContentletAsJSONUtil { " JOIN identifier i ON i.id = c.identifier" + " JOIN contentlet_version_info cv ON i.id = cv.identifier" + " AND (c.inode = cv.working_inode OR c.inode = cv.live_inode)" + - " WHERE i.asset_subtype = '%s' AND c.contentlet_as_json IS NULL;"; + " WHERE i.asset_subtype = '%s' AND c.contentlet_as_json IS NULL " + + " LIMIT %d;"; // Query to find all the contentlets that have a null contentlet_as_json private final String CONTENTS_WITH_NO_JSON = "select c.* " + @@ -56,7 +55,14 @@ public class PopulateContentletAsJSONUtil { " JOIN identifier i ON i.id = c.identifier" + " JOIN contentlet_version_info cv ON i.id = cv.identifier" + " AND (c.inode = cv.working_inode OR c.inode = cv.live_inode)" + - " WHERE i.asset_type = 'contentlet' AND c.contentlet_as_json IS NULL;"; + " WHERE i.asset_type = 'contentlet' AND c.contentlet_as_json IS NULL " + + " LIMIT %d;"; + + // Query to find all the contentlets that have a null contentlet_as_json for all the versions + private final String CONTENTS_WITH_NO_JSON_ALL_VERSIONS = "SELECT c.* " + + "FROM contentlet c " + + "WHERE c.contentlet_as_json IS NULL " + + "LIMIT %d;"; // Query to find all the contentlets that are NOT of a given asset_subtype and have a null contentlet_as_json private final String CONTENTS_WITH_NO_JSON_AND_EXCLUDE = "select c.* " + @@ -64,139 +70,230 @@ public class PopulateContentletAsJSONUtil { " JOIN identifier i ON i.id = c.identifier" + " JOIN contentlet_version_info cv ON i.id = cv.identifier" + " AND (c.inode = cv.working_inode OR c.inode = cv.live_inode)" + - " WHERE i.asset_subtype <> '%s' AND i.asset_type = 'contentlet' AND c.contentlet_as_json IS NULL;"; + " WHERE i.asset_subtype <> '%s' AND i.asset_type = 'contentlet' AND c.contentlet_as_json IS NULL " + + " LIMIT %d;"; // Query to update the contentlet_as_json column of the contentlet table - private final String UPDATE_CONTENTLET_AS_JSON = "UPDATE contentlet SET contentlet_as_json = ? " + - "WHERE inode = ? AND contentlet_as_json IS NULL"; + private final String UPDATE_CONTENTLET_AS_JSON = + "UPDATE contentlet SET contentlet_as_json = ? " + + "WHERE inode = ? AND contentlet_as_json IS NULL"; + + // Temporal table related queries + private final String CREATE_TEMP_TABLE = "CREATE TEMP TABLE tmp_contentlet_json (" + + " inode varchar(36) not null," + + " json text not null" + + ");"; + + private final String DROP_TEMP_TABLE = "DROP TABLE IF EXISTS tmp_contentlet_json;"; + + private final String INSERT_INTO_TEMP_TABLE = "INSERT INTO tmp_contentlet_json (inode, json) " + + "VALUES (?,?)"; // Cursor related queries private final String DECLARE_CURSOR = "DECLARE missingContentletAsJSONCursor CURSOR FOR %s"; - private final String FETCH_CURSOR_POSTGRES = "FETCH FORWARD %s FROM missingContentletAsJSONCursor"; - private final String FETCH_CURSOR_MSSQL = "FETCH NEXT FROM missingContentletAsJSONCursor"; - private final String OPEN_CURSOR_MSSQL = "OPEN missingContentletAsJSONCursor"; + private final String DECLARE_CURSOR_FOR_TEMPORAL_TABLE = + "DECLARE tmpContentletJSONCursor CURSOR " + + "FOR SELECT inode, json FROM tmp_contentlet_json;"; + private final String FETCH_CURSOR = "FETCH FORWARD %s FROM missingContentletAsJSONCursor"; + private final String FETCH_CURSOR_FOR_TEMPORAL_TABLE = "FETCH FORWARD %s FROM tmpContentletJSONCursor"; private final String CLOSE_CURSOR = "CLOSE missingContentletAsJSONCursor"; - private final String DEALLOCATE_CURSOR_MSSQL = "DEALLOCATE missingContentletAsJSONCursor"; + private final String CLOSE_CURSOR_FOR_TEMPORAL_TABLE = "CLOSE tmpContentletJSONCursor"; - private static final int MAX_UPDATE_BATCH_SIZE = Config.getIntProperty("task.230320.maxupdatebatchsize", 100); - private static final int MAX_CURSOR_FETCH_SIZE = Config.getIntProperty("task.230320.maxcursorfetchsize", 100); + private static final int MAX_BATCH_SIZE = Config.getIntProperty( + "task.populateContentletAsJSON.maxbatchsize", 200); + private static final int MAX_CURSOR_FETCH_SIZE = Config.getIntProperty( + "task.populateContentletAsJSON.maxcursorfetchsize", 200); + private static final int LIMIT_SIZE_FOR_SELECTS = Config.getIntProperty( + "task.populateContentletAsJSON.selectslimitsize", 5000); public PopulateContentletAsJSONUtil() { this.contentletJsonAPI = APILocator.getContentletJsonAPI(); } /** - * Finds all the contentlets that need to be updated with the contentlet_as_json column for a given - * assetSubtype (Content Type). + * Finds all the contentlets that need to be updated with the contentlet_as_json column. + *

    + * All versions will be processed. + */ + public void populateEverything() { + Logger.info(this, "Populate Contentlet as JSON task started for all versions"); + populate(null, null, true); + } + + /** + * Finds all the contentlets that need to be updated with the contentlet_as_json column for a + * given assetSubtype (Content Type). + *

    + * Only working and live versions of the contentlets will be processed. * - * @param assetSubtype Asset subtype (Content Type) to filter the contentlets to process, if null then all - * the contentlets will be processed. + * @param assetSubtype Asset subtype (Content Type) to filter the contentlets to process, if + * null then all the contentlets will be processed. * @throws SQLException - * @throws DotDataException * @throws IOException */ - public void populateForAssetSubType(final String assetSubtype) throws SQLException, DotDataException, IOException { - Logger.info(this, String.format("Populate Contentlet as JSON task started for asset subtype [%s]", assetSubtype)); - populate(assetSubtype, null); + public void populateForAssetSubType(final String assetSubtype) { + Logger.info(this, + String.format("Populate Contentlet as JSON task started for asset subtype [%s]", + assetSubtype)); + populate(assetSubtype, null, false); } /** * Finds all the contentlets that need to be updated with the contentlet_as_json column excluding the contentles * of a given assetSubtype (Content Type). + *

    + * Only working and live versions of the contentlets will be processed. * * @param assetSubtype Asset subtype (Content Type) use to exclude contentlets of that given type from the query. * @throws SQLException * @throws DotDataException * @throws IOException */ - public void populateExcludingAssetSubType(final String assetSubtype) throws SQLException, DotDataException, IOException { + public void populateExcludingAssetSubType(final String assetSubtype) { Logger.info(this, String.format("Populate Contentlet as JSON task started excluding asset subtype [%s]", assetSubtype)); - populate(null, assetSubtype); + populate(null, assetSubtype, false); } /** - * Finds all the contentlets that need to be updated with the contentlet_as_json column for the given - * assetSubtype and excludingAssetSubtype. + * Finds all the contentlets that need to be updated with the contentlet_as_json for the + * given assetSubtype and excludingAssetSubtype. * - * @param assetSubtype Optional asset subtype (Content Type) to filter the contentlets to process, if null then all - * the contentlets will be processed unless the excludingAssetSubtype is provided. - * @param excludingAssetSubtype Optional asset subtype (Content Type) use to exclude contentlets from the query - * @throws IOException + * @param assetSubtype Optional asset subtype (Content Type) to filter the contentlets + * to process. If null, all the contentlets will be processed + * unless the excludingAssetSubtype is provided. + * Applies only for working and live versions. + * @param excludingAssetSubtype Optional asset subtype (Content Type) used to exclude contentlets + * from the query. + * @param allVersions Boolean indicating whether to process all versions of contentlets. */ @LogTime(loggingLevel = "INFO") - private void populate(@Nullable String assetSubtype, @Nullable String excludingAssetSubtype) throws IOException { + private void populate(@Nullable String assetSubtype, + @Nullable String excludingAssetSubtype, + final Boolean allVersions) { - final File populateJSONTaskDataFile = File.createTempFile("rows-task-230320", "tmp"); + final MutableInt totalRecordsAffected = new MutableInt(0); - Logger.debug(this, "File created: " + populateJSONTaskDataFile.getAbsolutePath()); + while (true) { + + CompletableFuture future = CompletableFuture.supplyAsync(() -> + populateWrapper(assetSubtype, excludingAssetSubtype, allVersions, totalRecordsAffected)); - Runnable findAndStore = () -> { try { - // First we need to find all the contentlets to process and write them into a file - findAndStoreToDisk(assetSubtype, excludingAssetSubtype, populateJSONTaskDataFile); - } catch (SQLException | DotDataException | IOException e) { - throw new DotRuntimeException("Error finding, generating JSON representation of Contentlets " + - "and storing them in file.", e); + Boolean foundRecords = future.get(); + if (!foundRecords) { + break; // We don't need to continue processing + } + } catch (InterruptedException | ExecutionException e) { + throw new DotRuntimeException("Error populating contentlets with missing contentlet as JSON", e); } - }; + } + + // Log task completion status + Logger.info(this, "---- Records processed: " + totalRecordsAffected.intValue()); + if (allVersions) { + + Logger.info(this, "Contentlet as JSON migration task DONE for all versions"); + } else if (!Strings.isNullOrEmpty(assetSubtype)) { + + Logger.info(this, String.format("Contentlet as JSON migration task " + + "DONE for assetSubtype: [%s].", assetSubtype)); + } else if (!Strings.isNullOrEmpty(excludingAssetSubtype)) { + + Logger.info(this, String.format("Contentlet as JSON migration task " + + "DONE for excludingAssetSubtype [%s].", excludingAssetSubtype)); + } else { + + Logger.info(this, "Contentlet as JSON migration task DONE"); + } + } + + /** + * Internal method for populating the contentlet_as_json in contentlets. + * Executes the population process for the given assetSubtype and excludingAssetSubtype. + * + * @param assetSubtype Optional asset subtype (Content Type) to filter the contentlets + * to process. If null, all the contentlets will be processed + * unless the excludingAssetSubtype is provided. + * Applies only for working and live versions. + * @param excludingAssetSubtype Optional asset subtype (Content Type) used to exclude contentlets + * from the query. + * @param allVersions Boolean indicating whether to process all versions of contentlets. + * @param totalRecords A MutableInt object to keep track of the total number of affected records. + * @return True if contentlets were found and processed, false otherwise. + */ + @WrapInTransaction + private boolean populateWrapper(@Nullable String assetSubtype, + @Nullable String excludingAssetSubtype, + final Boolean allVersions, + final MutableInt totalRecords) { + + var foundRecords = false; + + try { + // First we need to find all the contentlets to process and write them into a file + foundRecords = findAndStore(assetSubtype, excludingAssetSubtype, allVersions, totalRecords); + } catch (SQLException | DotDataException e) { + throw new DotRuntimeException("Error finding, generating JSON representation of " + + "Contentlets and storing them into a temporal table.", e); + } + + if (foundRecords) { - Runnable processFile = () -> { try { // Now we need to process the file and each record on it - processFile(populateJSONTaskDataFile); - } catch (IOException e) { - throw new DotRuntimeException("Error processing file with the JSON representation of Contentlets to " + - "update.", e); + processRecords(); + } catch (SQLException | DotDataException e) { + throw new DotRuntimeException( + "Error processing records with the JSON representation " + + "of Contentlets to update.", e); } - }; - - CompletableFuture. - runAsync(findAndStore). - thenRunAsync(processFile). - thenAccept(unused -> Logger.info(this, String.format("Contentlet as JSON migration task " + - "DONE for assetSubtype: [%s] / excludingAssetSubtype [%s].", - assetSubtype, excludingAssetSubtype))). - join();// Block the current thread and wait for the CompletableFuture to complete + } + + return foundRecords; } /** - * Searches for all the contentlets of a given asset subtype (Content Type) that have a null contentlet_as_json. This - * method uses a cursor to avoid loading all the contentlets into memory. - * Each found contentlet is written into a file for a later processing. + * Searches for all the contentlets of a given asset subtype (Content Type) that have a null + * contentlet_as_json. This method uses a cursor to avoid loading all the contentlets into + * memory. Each found contentlet is written into a temporal table for a later processing. * - * @param assetSubtype The asset subtype (Content Type) to search for, if null all the contentlets will be searched. - * @param excludingAssetSubtype The asset subtype (Content Type) to exclude in the search, if null no Content Type will be excluded. - * @param populateJSONTaskDataFile The file where the contentlets will be written. - * @throws SQLException - * @throws DotDataException - * @throws IOException + * @param assetSubtype The asset subtype (Content Type) to search for, if null all the + * contentlets will be searched. + * @param excludingAssetSubtype The asset subtype (Content Type) to exclude in the search, if + * null no Content Type will be excluded. + * @param allVersions Boolean indicating whether to process all versions of contentlets. + * @param totalRecords A MutableInt object to keep track of the total number of affected records. */ @WrapInTransaction - private void findAndStoreToDisk(@Nullable final String assetSubtype, - @Nullable final String excludingAssetSubtype, - final File populateJSONTaskDataFile) throws - SQLException, DotDataException, IOException { + private boolean findAndStore(@Nullable final String assetSubtype, + @Nullable final String excludingAssetSubtype, + final Boolean allVersions, + final MutableInt totalRecords + ) throws SQLException, DotDataException { + + final Collection paramsInsert = new ArrayList<>(); + final MutableInt totalInsertAffected = new MutableInt(0); + + var foundData = false; - int recordsProcessed = 0; + Logger.info(this, "Finding records with missing Contentlet as JSON"); final Connection conn = DbConnectionFactory.getConnection(); - try (var fileWriter = new BufferedWriter(new FileWriter(populateJSONTaskDataFile)); - var stmt = conn.createStatement()) { + // Creating the temporal table to hold the contentlets and json to process + createTempTable(conn); + + try (var stmt = conn.createStatement()) { // Declaring the cursor - declareCursor(stmt, assetSubtype, excludingAssetSubtype); + declareCursor(stmt, assetSubtype, excludingAssetSubtype, allVersions); boolean hasRows; do { - if (DbConnectionFactory.isMsSql()) { - stmt.execute(FETCH_CURSOR_MSSQL); - } else { - // Fetching batches of 100 records - stmt.execute(String.format(FETCH_CURSOR_POSTGRES, MAX_CURSOR_FETCH_SIZE)); - } + // Fetching batches of 100 records + stmt.execute(String.format(FETCH_CURSOR, MAX_CURSOR_FETCH_SIZE)); try (ResultSet rs = stmt.getResultSet()) { @@ -205,11 +302,11 @@ private void findAndStoreToDisk(@Nullable final String assetSubtype, dotConnect.fromResultSet(rs); var loadedResults = dotConnect.loadObjectResults(); - recordsProcessed += loadedResults.size(); if (!loadedResults.isEmpty()) { hasRows = true; + foundData = true; var jsonDataArray = Optional.ofNullable(loadedResults) .map(results -> @@ -223,12 +320,13 @@ private void findAndStoreToDisk(@Nullable final String assetSubtype, .orElse(Collections.emptyList()); for (var jsonData : jsonDataArray) { - // Write the json representation of the contentlet into the file - fileWriter.write(jsonData); - fileWriter.newLine(); + // Insert the json representation of the contentlet into the temp table + this.processInsertRecord(jsonData._1(), jsonData._2(), paramsInsert, totalInsertAffected); } - Logger.debug(this, String.format("Added [%s] records for update to temp file", jsonDataArray.size())); + if (!paramsInsert.isEmpty()) { + this.doInsertBatch(paramsInsert, totalInsertAffected); + } } else { hasRows = false; @@ -237,45 +335,80 @@ private void findAndStoreToDisk(@Nullable final String assetSubtype, } while (hasRows); - // Flush the writer to the file - fileWriter.flush(); - // Close the cursor stmt.execute(CLOSE_CURSOR); - if (DbConnectionFactory.isMsSql()) { - stmt.execute(DEALLOCATE_CURSOR_MSSQL); + + if (foundData) { + totalRecords.add(totalInsertAffected); } } - Logger.info(this, "-- Records found to process: " + recordsProcessed); + return foundData; } /** - * This method processes a file that contains all the contentlets that need to be updated with the contentlet_as_json + * This method processes a temporal table that contains all the contentlets that need to be + * updated with the contentlet_as_json * - * @param taskDataFile - * @throws IOException + * @throws SQLException If there is an error in the SQL execution. + * @throws DotDataException If there is an error related to data handling. */ @WrapInTransaction - private void processFile(final File taskDataFile) throws IOException { - - Logger.info(this, "Updating records with missing Contentlet as JSON"); + private void processRecords() throws SQLException, DotDataException { final Collection paramsUpdate = new ArrayList<>(); final MutableInt totalUpdateAffected = new MutableInt(0); - try (final Stream streamLines = Files.lines(taskDataFile.toPath())) { + Logger.info(this, "Updating records with missing Contentlet as JSON"); - streamLines.forEachOrdered(line -> this.processLine(paramsUpdate, line, totalUpdateAffected)); + final Connection conn = DbConnectionFactory.getConnection(); - if (!paramsUpdate.isEmpty()) { - this.doUpdateBatch(paramsUpdate, totalUpdateAffected); - } - } finally { - Logger.info(this, "-- total updates: " + totalUpdateAffected.intValue()); - } + try (var stmt = conn.createStatement()) { + + // Declaring the cursor + stmt.execute(DECLARE_CURSOR_FOR_TEMPORAL_TABLE); + + boolean hasRows; - Logger.info(this, "Updated records with missing Contentlet as JSON"); + do { + + // Fetching batches of 100 records + stmt.execute(String.format(FETCH_CURSOR_FOR_TEMPORAL_TABLE, MAX_CURSOR_FETCH_SIZE)); + + try (ResultSet rs = stmt.getResultSet()) { + + // Now we want to write the found Contentlets into a file for a later processing + var dotConnect = new DotConnect(); + dotConnect.fromResultSet(rs); + + var loadedResults = dotConnect.loadObjectResults(); + + if (!loadedResults.isEmpty()) { + + hasRows = true; + + loadedResults.forEach( + record -> this.processUpdateRecord( + (String) record.get("inode"), + (String) record.get("json"), + paramsUpdate, + totalUpdateAffected) + ); + + if (!paramsUpdate.isEmpty()) { + this.doUpdateBatch(paramsUpdate, totalUpdateAffected); + } + + } else { + hasRows = false; + } + } + + } while (hasRows); + + // Close the cursor + stmt.execute(CLOSE_CURSOR_FOR_TEMPORAL_TABLE); + } } /** @@ -286,76 +419,119 @@ private void processFile(final File taskDataFile) throws IOException { * @param excludingAssetSubtype The asset subtype (Content Type) to exclude in the search, if null no Content Type will be excluded. * @throws SQLException */ - private void declareCursor(final Statement stmt, @Nullable final String assetSubtype, - @Nullable final String excludingAssetSubtype) throws SQLException { + private void declareCursor(final Statement stmt, + @Nullable final String assetSubtype, + @Nullable final String excludingAssetSubtype, + final Boolean allVersions + ) throws SQLException { // Declaring the cursor - if (Strings.isNullOrEmpty(assetSubtype)) { - if (Strings.isNullOrEmpty(excludingAssetSubtype)) { - stmt.execute(String.format(DECLARE_CURSOR, CONTENTS_WITH_NO_JSON)); - } else { - var selectQuery = String.format(CONTENTS_WITH_NO_JSON_AND_EXCLUDE, excludingAssetSubtype); - stmt.execute(String.format(DECLARE_CURSOR, selectQuery)); - } + if (allVersions) { + var selectQuery = String.format(CONTENTS_WITH_NO_JSON_ALL_VERSIONS, LIMIT_SIZE_FOR_SELECTS); + stmt.execute(String.format(DECLARE_CURSOR, selectQuery)); + } else if (!Strings.isNullOrEmpty(assetSubtype)) { + var selectQuery = String.format(SUBTYPE_WITH_NO_JSON, assetSubtype, LIMIT_SIZE_FOR_SELECTS); + stmt.execute(String.format(DECLARE_CURSOR, selectQuery)); + } else if (!Strings.isNullOrEmpty(excludingAssetSubtype)) { + var selectQuery = String.format(CONTENTS_WITH_NO_JSON_AND_EXCLUDE, excludingAssetSubtype, LIMIT_SIZE_FOR_SELECTS); + stmt.execute(String.format(DECLARE_CURSOR, selectQuery)); } else { - var selectQuery = String.format(SUBTYPE_WITH_NO_JSON, assetSubtype); + var selectQuery = String.format(CONTENTS_WITH_NO_JSON, LIMIT_SIZE_FOR_SELECTS); stmt.execute(String.format(DECLARE_CURSOR, selectQuery)); } + } - if (DbConnectionFactory.isMsSql()) { - stmt.execute(OPEN_CURSOR_MSSQL); + /** + * Processes a record by preparing the parameters for a batch insert into the temporal tables. + * + * @param inode The inode of the contentlet. + * @param json The JSON representation of the contentlet. + * @param paramsInsert A collection of Params objects used for batch inserts. The Params + * object contains the contentlet inode and JSON. + * @param totalInsertAffected A MutableInt object to keep track of the total number of affected + * rows in batch inserts. + */ + private void processInsertRecord( + final String inode, + final String json, + final Collection paramsInsert, + final MutableInt totalInsertAffected + ) { + + paramsInsert.add(new Params(inode, json)); + + // Execute the batch for the inserts if we have reached the max batch size + if (paramsInsert.size() >= MAX_BATCH_SIZE) { + this.doInsertBatch(paramsInsert, totalInsertAffected); } } /** - * Processes the given line preparing the params for the batch update. + * Processes a record by preparing the parameters for a batch update. * - * @param line Line with the json representation of the contentlet. - * @throws JsonProcessingException + * @param inode The inode of the contentlet. + * @param json The JSON representation of the contentlet. + * @param paramsUpdate A collection of Params objects used for batch updates. The Params + * object contains the contentlet JSON and inode. + * @param totalUpdateAffected A MutableInt object to keep track of the total number of affected + * rows in batch updates. + * @throws JsonProcessingException If there is an error while processing the JSON. */ - private void processLine(final Collection paramsUpdate, final String line, - final MutableInt totalInsertAffected) { - - try { - var contentlet = ContentletJsonHelper.INSTANCE.get().immutableFromJson(line); - - final Object contentletAsJSON; - if (DbConnectionFactory.isPostgres()) { - contentletAsJSON = new DotPGobject.Builder() - .jsonValue(line) - .build(); - } else { - contentletAsJSON = line; - } + private void processUpdateRecord( + final String inode, + final String json, + final Collection paramsUpdate, + final MutableInt totalUpdateAffected + ) { + + final Object contentletAsJSON; + if (DbConnectionFactory.isPostgres()) { + contentletAsJSON = new DotPGobject.Builder() + .jsonValue(json) + .build(); + } else { + contentletAsJSON = json; + } - paramsUpdate.add(new Params(contentletAsJSON, contentlet.inode())); + paramsUpdate.add(new Params(contentletAsJSON, inode)); - // Execute the batch for the updates if we have reached the max batch size - if (paramsUpdate.size() >= MAX_UPDATE_BATCH_SIZE) { - this.doUpdateBatch(paramsUpdate, totalInsertAffected); - } - } catch (JsonProcessingException e) { - throw new DotRuntimeException("Error processing line", e); + // Execute the batch for the updates if we have reached the max batch size + if (paramsUpdate.size() >= MAX_BATCH_SIZE) { + this.doUpdateBatch(paramsUpdate, totalUpdateAffected); } } /** - * Converts the contentlet to an immutable contentlet and then builds a json representation of it. + * Creates a temporary table in the database. + * + * @throws DotDataException If there is an error related to data handling. + * @throws SQLException If there is an error in the SQL execution. + */ + private void createTempTable(final Connection conn) throws DotDataException, SQLException { + + new DotConnect().setSQL(DROP_TEMP_TABLE).loadResult(conn); + new DotConnect().setSQL(CREATE_TEMP_TABLE).loadResult(conn); + } + + /** + * Converts the given {@link Contentlet} to an immutable contentlet and then builds a json + * representation of it. * - * @param contentlet - * @return The Contentlet with the json representation attached to it. + * @param contentlet The contentlet to convert. + * @return A tuple containing the inode of the contentlet and its JSON representation. */ - private String toJSON(Contentlet contentlet) { + private Tuple2 toJSON(Contentlet contentlet) { try { // Converts the given contentlet to an immutable contentlet and then builds a json representation of it. - var contentletAsJSON = ContentletJsonHelper.INSTANCE.get().writeAsString(this.contentletJsonAPI.toImmutable(contentlet)); + var contentletAsJSON = ContentletJsonHelper.INSTANCE.get() + .writeAsString(this.contentletJsonAPI.toImmutable(contentlet)); - // I need to have the JSON in a single line, so I can write it into a file - return contentletAsJSON.replaceAll("[\\t\\n\\r]", ""); + return Tuple.of(contentlet.getInode(), contentletAsJSON); } catch (JsonProcessingException e) { - throw new DotRuntimeException(String.format("Error creating the JSON representation of Contentlet - " + - "inode [%s]", contentlet.getInode()), e); + throw new DotRuntimeException( + String.format("Error creating the JSON representation of Contentlet - " + + "inode [%s]", contentlet.getInode()), e); } } @@ -371,7 +547,7 @@ private void doUpdateBatch(final Collection paramsUpdate, final MutableI paramsUpdate)); final int rowsAffected = batchResult.stream().reduce(0, Integer::sum); - Logger.info(this, "Batch rows to populate contentlet_as_json column, updated: " + rowsAffected + " rows"); + Logger.info(this, "-- Batch rows to populate contentlet_as_json column, updated: " + rowsAffected + " rows"); totalUpdateAffected.add(rowsAffected); } catch (DotDataException e) { Logger.error(this, "Couldn't update these rows: " + paramsUpdate); @@ -381,6 +557,28 @@ private void doUpdateBatch(final Collection paramsUpdate, final MutableI } } + /** + * Executes the batch of inserts to populate the temporal tmp_contentlet_json table. + */ + private void doInsertBatch(final Collection paramsInsert, final MutableInt totalInsertAffected) { + + try { + final List batchResult = + Ints.asList(new DotConnect().executeBatch( + INSERT_INTO_TEMP_TABLE, + paramsInsert)); + + final int rowsAffected = batchResult.stream().reduce(0, Integer::sum); + Logger.info(this, "-- Batch rows to populate temporal table, inserted: " + rowsAffected + " rows"); + totalInsertAffected.add(rowsAffected); + } catch (DotDataException e) { + Logger.error(this, "Couldn't insert these rows: " + paramsInsert); + Logger.error(this, e.getMessage(), e); + } finally { + paramsInsert.clear(); + } + } + /** * This basically tells Weather or not we support saving content as json and if we have not turned it off. */ diff --git a/dotCMS/src/main/java/com/dotmarketing/quartz/job/PopulateContentletAsJSONJob.java b/dotCMS/src/main/java/com/dotmarketing/quartz/job/PopulateContentletAsJSONJob.java index 1fb1b2380e60..e3a35bf20cd9 100644 --- a/dotCMS/src/main/java/com/dotmarketing/quartz/job/PopulateContentletAsJSONJob.java +++ b/dotCMS/src/main/java/com/dotmarketing/quartz/job/PopulateContentletAsJSONJob.java @@ -1,7 +1,6 @@ package com.dotmarketing.quartz.job; import com.dotcms.util.content.json.PopulateContentletAsJSONUtil; -import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.quartz.DotStatefulJob; import com.dotmarketing.quartz.QuartzUtils; @@ -12,40 +11,54 @@ import io.vavr.control.Try; import org.quartz.*; -import java.io.IOException; -import java.sql.SQLException; - /** * Job created to populate in the Contentlet table missing contentlet_as_json columns. */ public class PopulateContentletAsJSONJob extends DotStatefulJob { - private static final String EXCLUDING_ASSET_SUB_TYPE = "excludingAssetSubType"; + private static final String JOB_DATA_EXCLUDING_ASSET_SUB_TYPE = "excludingAssetSubType"; + private static final String JOB_DATA_ALL_CONTENTS_ALL_VERSIONS = "allContentsAllVersions"; private static final String CONFIG_PROPERTY_HOURS_INTERVAL = "populateContentletAsJSONJob.hours.interval"; private static final Lazy HOURS_INTERVAL = Lazy.of(() -> Config.getIntProperty( CONFIG_PROPERTY_HOURS_INTERVAL, 4)); + /** + * Executes the job logic, which first involves populating the working and live versions of contentlets as JSON. + * After the first population process finishes, it registers another job to migrate all the remaining contentlets. + * + * @param jobContext The JobExecutionContext object containing the job execution context. + * @throws JobExecutionException if there is an error executing the job. + */ @Override public void run(JobExecutionContext jobContext) throws JobExecutionException { final var jobDataMap = jobContext.getJobDetail().getJobDataMap(); - final String excludingAssetSubType; - if (jobDataMap.containsKey(EXCLUDING_ASSET_SUB_TYPE)) { - excludingAssetSubType = (String) jobDataMap.get(EXCLUDING_ASSET_SUB_TYPE); - } else { - excludingAssetSubType = null; + String excludingAssetSubType = null; + Boolean forAllContentsAllVersions = null; + + if (jobDataMap.containsKey(JOB_DATA_ALL_CONTENTS_ALL_VERSIONS)) { + forAllContentsAllVersions = (Boolean) jobDataMap.get(JOB_DATA_ALL_CONTENTS_ALL_VERSIONS); + } else if (jobDataMap.containsKey(JOB_DATA_EXCLUDING_ASSET_SUB_TYPE)) { + excludingAssetSubType = (String) jobDataMap.get(JOB_DATA_EXCLUDING_ASSET_SUB_TYPE); } try { // Executing the populate contentlet as JSON logic - new PopulateContentletAsJSONUtil().populateExcludingAssetSubType(excludingAssetSubType); + if (forAllContentsAllVersions != null && forAllContentsAllVersions) { + new PopulateContentletAsJSONUtil().populateEverything(); + } else { + new PopulateContentletAsJSONUtil().populateExcludingAssetSubType(excludingAssetSubType); + } // Removing the job if everything went well removeJob(); - } catch (SQLException | DotDataException | IOException e) { - Logger.error(this, "Error executing Contentlet as JSON population job", e); - throw new DotRuntimeException(e); + + // If the populate for working and live versions is done we fire the job to populate the missing contentlets + if (forAllContentsAllVersions == null) { + fireJobAllContentsAllVersions(); + } + } catch (SchedulerException e) { Logger.error(this, String.format("Unable to remove [%s] job", PopulateContentletAsJSONJob.class.getName()), e); @@ -54,10 +67,36 @@ public void run(JobExecutionContext jobContext) throws JobExecutionException { } /** - * Fires the job to populate the missing contentlet_as_json columns. + * Fires the job to populate the missing contentlet_as_json columns for all contents and all versions. + */ + private static void fireJobAllContentsAllVersions() { + + final var jobDataMap = new JobDataMap(); + jobDataMap.put(JOB_DATA_ALL_CONTENTS_ALL_VERSIONS, true); + + fireJob(jobDataMap); + } + + /** + * Fires the job to populate the missing contentlet_as_json columns excluding a specific asset sub-type. + * + * @param excludingAssetSubType The asset sub-type to exclude. */ public static void fireJob(final String excludingAssetSubType) { + final var jobDataMap = new JobDataMap(); + jobDataMap.put(JOB_DATA_EXCLUDING_ASSET_SUB_TYPE, excludingAssetSubType); + + fireJob(jobDataMap); + } + + /** + * Fires the job to populate the missing contentlet_as_json columns. + * + * @param jobDataMap The JobDataMap containing the job data. + */ + private static void fireJob(final JobDataMap jobDataMap) { + final var jobName = getJobName(); final var groupName = getJobGroupName(); @@ -77,9 +116,6 @@ public static void fireJob(final String excludingAssetSubType) { } // Creating the job - final var jobDataMap = new JobDataMap(); - jobDataMap.put(EXCLUDING_ASSET_SUB_TYPE, excludingAssetSubType); - jobDetail = new JobDetail( jobName, groupName, PopulateContentletAsJSONJob.class ); @@ -107,17 +143,29 @@ public static void fireJob(final String excludingAssetSubType) { /** * Removes the PopulateContentletAsJSONJob from the scheduler + * + * @throws SchedulerException if there is an error removing the job. */ @VisibleForTesting static void removeJob() throws SchedulerException { QuartzUtils.removeJob(getJobName(), getJobGroupName()); } + /** + * Gets the job name. + * + * @return The job name. + */ @VisibleForTesting static String getJobName() { return PopulateContentletAsJSONJob.class.getSimpleName(); } + /** + * Gets the job group name. + * + * @return The job group name. + */ @VisibleForTesting static String getJobGroupName() { return getJobName() + "_Group"; diff --git a/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task230320FixMissingContentletAsJSON.java b/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task230320FixMissingContentletAsJSON.java index 659796236bd1..4df8273f3d60 100644 --- a/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task230320FixMissingContentletAsJSON.java +++ b/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task230320FixMissingContentletAsJSON.java @@ -7,9 +7,6 @@ import com.dotmarketing.startup.StartupTask; import com.dotmarketing.util.Logger; -import java.io.IOException; -import java.sql.SQLException; - public class Task230320FixMissingContentletAsJSON implements StartupTask { @Override @@ -26,13 +23,8 @@ public void executeUpgrade() throws DotDataException, DotRuntimeException { // will execute a background stateful quartz job PopulateContentletAsJSONJob.fireJob("Host"); - try { - // Now we populate the contentlet as JSON for Hosts, this will execute in the same thread - new PopulateContentletAsJSONUtil().populateForAssetSubType("Host"); - } catch (SQLException | IOException e) { - Logger.error(this, "Error populating Contentlet as JSON population column for Hosts", e); - throw new DotDataException(e.getMessage(), e); - } + // Now we populate the contentlet as JSON for Hosts, this will execute in the same thread + new PopulateContentletAsJSONUtil().populateForAssetSubType("Host"); } } From d1633c13c93e74d29706b9de066fbd7fe2f08b2a Mon Sep 17 00:00:00 2001 From: Jose Castro Date: Thu, 25 May 2023 18:37:38 -0600 Subject: [PATCH 29/63] #24908 : Avoid showing the `System Host` in the result list in the `Sites` portlet. (#25045) * #24908 : Avoid showing the `System Host` in the result list in the `Sites` portlet. * Adding Integration Test to check results when the System Host is required/not required to be in the results. --- .../contentlet/business/HostAPITest.java | 67 +++++++++++++------ .../dotmarketing/business/ajax/HostAjax.java | 17 +++-- .../portlets/contentlet/business/HostAPI.java | 22 +++++- .../contentlet/business/HostAPIImpl.java | 50 +++++++++++--- 4 files changed, 119 insertions(+), 37 deletions(-) diff --git a/dotCMS/src/integration-test/java/com/dotmarketing/portlets/contentlet/business/HostAPITest.java b/dotCMS/src/integration-test/java/com/dotmarketing/portlets/contentlet/business/HostAPITest.java index 6c99c17c50b5..3db3fc98dfec 100644 --- a/dotCMS/src/integration-test/java/com/dotmarketing/portlets/contentlet/business/HostAPITest.java +++ b/dotCMS/src/integration-test/java/com/dotmarketing/portlets/contentlet/business/HostAPITest.java @@ -46,20 +46,20 @@ import com.dotmarketing.util.PaginatedArrayList; import com.dotmarketing.util.UUIDGenerator; import com.liferay.portal.model.User; -import java.util.HashMap; -import java.util.Map; import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Test; import org.mockito.Mockito; import org.quartz.JobDetail; import org.quartz.JobExecutionContext; +import org.quartz.Trigger; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; -import org.quartz.Trigger; import static com.dotmarketing.portlets.templates.model.Template.ANONYMOUS_PREFIX; import static org.junit.Assert.assertEquals; @@ -505,7 +505,7 @@ private void unpublishHost(final Host host, final User user) throws DotHibernate try { HibernateUtil.startTransaction(); - host.setIndexPolicy(IndexPolicy.FORCE); + host.setIndexPolicy(IndexPolicy.WAIT_FOR); APILocator.getHostAPI().unpublish(host, user, false); HibernateUtil.closeAndCommitTransaction(); } catch (Exception e) { @@ -520,10 +520,12 @@ private void unpublishHost(final Host host, final User user) throws DotHibernate * Archives a given host */ private void archiveHost(final Host host, final User user) throws DotHibernateException { - + if (null == host || null == user) { + return; + } try { HibernateUtil.startTransaction(); - host.setIndexPolicy(IndexPolicy.FORCE); + host.setIndexPolicy(IndexPolicy.WAIT_FOR); APILocator.getHostAPI().archive(host, user, false); HibernateUtil.closeAndCommitTransaction(); } catch (Exception e) { @@ -539,11 +541,13 @@ private void archiveHost(final Host host, final User user) throws DotHibernateEx */ private void deleteHost(final Host host, final User user) throws DotHibernateException, InterruptedException, ExecutionException { - + if (null == host || null == user) { + return; + } Optional> hostDeleteResult = Optional.empty(); try { HibernateUtil.startTransaction(); - host.setIndexPolicy(IndexPolicy.FORCE); + host.setIndexPolicy(IndexPolicy.WAIT_FOR); hostDeleteResult = APILocator.getHostAPI().delete(host, user, false, true); HibernateUtil.closeAndCommitTransaction(); } catch (Exception e) { @@ -1219,14 +1223,15 @@ public void retrieveHostsPerTagStorage() throws DotHibernateException, Execution } /** - * Method to test: {@link HostAPI#searchByStopped(String, boolean, boolean, int, int, User, boolean)} - * - * Given Scenario: Create a test Site and stop it. Then, create another Site, then stop it and archive it. Finally, - * compare the total count of stopped Sites. - * - * Expected Result: When compared to the initial stopped Sites count, after stopping the new Site, the count must - * increase by one. After stopping and archivnig the second Site, the count must be increased by two because - * archived Sites are also considered "stopped Sites". + *

      + *
    • Method to test: + * {@link HostAPI#searchByStopped(String, boolean, boolean, int, int, User, boolean)}
    • + *
    • Given Scenario: Create a test Site and stop it. Then, create another Site, then stop it and + * archive it. Finally, compare the total count of stopped Sites.
    • + *
    • Expected Result: When compared to the initial stopped Sites count, after stopping the new Site, + * the count must be increased by one. After stopping AND archiving the second Site, the total count difference + * must be 2 because archived Sites are also considered "stopped Sites" as well.
    • + *
    */ @Test public void searchByStopped() throws DotHibernateException, ExecutionException, InterruptedException { @@ -1242,7 +1247,7 @@ public void searchByStopped() throws DotHibernateException, ExecutionException, final PaginatedArrayList stoppedSites = hostAPI.searchByStopped(null, true, false, 0, 0, systemUser, false); testSite = siteDataGen.nextPersisted(); - unpublishHost(testSite, systemUser); + this.unpublishHost(testSite, systemUser); final PaginatedArrayList updatedStoppedSites = hostAPI.searchByStopped(null, true, false, 0, 0, systemUser, false); @@ -1253,8 +1258,8 @@ public void searchByStopped() throws DotHibernateException, ExecutionException, // Test data generation #2 siteDataGen = new SiteDataGen(); testSiteTwo = siteDataGen.nextPersisted(); - unpublishHost(testSiteTwo, systemUser); - archiveHost(testSiteTwo, systemUser); + this.unpublishHost(testSiteTwo, systemUser); + this.archiveHost(testSiteTwo, systemUser); final PaginatedArrayList updatedStoppedAndArchivedSites = hostAPI.searchByStopped(null, true, false, 0, 0, systemUser, false); @@ -1366,4 +1371,28 @@ public void count() throws DotHibernateException, ExecutionException, Interrupte } } + /** + *
      + *
    • Method to test: {@link HostAPI#findAllFromDB(User, boolean, boolean)}
    • + *
    • Given Scenario: Create a test Site and call the {@findAllFromDB} method that allows you to include + * or exclude the System Host.
    • + *
    • Expected Result: When calling the method with the {@code includeSystemHost} parameter as + * {@code true}, the System Host must be included. Otherwise, it must be left out.
    • + *
    + */ + @Test + public void findAllFromDB() throws DotDataException, DotSecurityException { + // Initialization + final HostAPI hostAPI = APILocator.getHostAPI(); + final User systemUser = APILocator.systemUser(); + + // Test data generation + final List siteList = hostAPI.findAllFromDB(systemUser, false, false); + final List siteListWithSystemHost = hostAPI.findAllFromDB(systemUser, true, false); + + // Assertions + assertEquals("The size difference between both Site lists MUST be 1", 1, + siteListWithSystemHost.size() - siteList.size()); + } + } diff --git a/dotCMS/src/main/java/com/dotmarketing/business/ajax/HostAjax.java b/dotCMS/src/main/java/com/dotmarketing/business/ajax/HostAjax.java index 2dd2a30c83f8..e6c5a5bdc1c9 100644 --- a/dotCMS/src/main/java/com/dotmarketing/business/ajax/HostAjax.java +++ b/dotCMS/src/main/java/com/dotmarketing/business/ajax/HostAjax.java @@ -48,9 +48,9 @@ */ public class HostAjax { - private HostAPI hostAPI = APILocator.getHostAPI(); - private UserWebAPI userWebAPI = WebAPILocator.getUserWebAPI(); - private PermissionAPI permissionAPI = APILocator.getPermissionAPI(); + private final HostAPI hostAPI = APILocator.getHostAPI(); + private final UserWebAPI userWebAPI = WebAPILocator.getUserWebAPI(); + private final PermissionAPI permissionAPI = APILocator.getPermissionAPI(); public Map findHostsForDataStore(String filter, boolean showArchived, int offset, int count) throws PortalException, SystemException, DotDataException, DotSecurityException { return findHostsForDataStore(filter, showArchived, offset, count, Boolean.FALSE); @@ -103,8 +103,8 @@ public Map findHostsForDataStore(String filter, boolean showArch /** * Returns the complete list of Sites that exist in a dotCMS instance based on specific case-insensitive filtering - * criteria. When filtering results, only listed text-type fields can be searched, which are basically the two - * columns displayed in the UI: {@code Site Key}, and {@code Aliases}. + * criteria, and excluding the System Host. When filtering results, only listed text-type fields can be + * searched, which are basically the two columns displayed in the UI: {@code Site Key}, and {@code Aliases}. * * @param filter Search term used to filter results. * @param showArchived If archived Sites must be returned, set to {@code true}. Otherwise, set to {@code false}. @@ -121,16 +121,15 @@ public Map findHostsForDataStore(String filter, boolean showArch * @throws SystemException An application error has occurred. */ public Map findHostsPaginated(final String filter, final boolean showArchived, int offset, int count) throws DotDataException, DotSecurityException, PortalException, SystemException { - final User user = this.getLoggedInUser(); final boolean respectFrontend = !this.userWebAPI.isLoggedToBackend(this.getHttpRequest()); - final List sitesFromDb = this.hostAPI.findAllFromDB(user, respectFrontend); + final List sitesFromDb = this.hostAPI.findAllFromDB(user, false, respectFrontend); final List fields = FieldsCache.getFieldsByStructureVariableName(Host.HOST_VELOCITY_VAR_NAME); final List searchableFields = fields.stream().filter(field -> field.isListed() && field .getFieldType().startsWith("text")).collect(Collectors.toList()); List> siteList = new ArrayList<>(sitesFromDb.size()); - Collections.sort(sitesFromDb, new HostNameComparator()); + sitesFromDb.sort(new HostNameComparator()); for (final Host site : sitesFromDb) { boolean addToResultList = false; if (showArchived || !site.isArchived()) { @@ -174,7 +173,7 @@ public Map findHostsPaginated(final String filter, final boolean } } - final List> fieldMapList = fields.stream().map(field -> field.getMap()).collect(Collectors.toList()); + final List> fieldMapList = fields.stream().map(Field::getMap).collect(Collectors.toList()); final Structure siteContentType = CacheLocator.getContentTypeCache().getStructureByVelocityVarName(Host.HOST_VELOCITY_VAR_NAME); return CollectionsUtils.map( "total", totalResults, diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/HostAPI.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/HostAPI.java index 527d5da2d6da..73a7bd5a8835 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/HostAPI.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/HostAPI.java @@ -200,7 +200,8 @@ Host find(Contentlet contentlet, List findAll(User user, int limit, int offset, String sortBy, boolean respectFrontendRoles) throws DotDataException, DotSecurityException; /** - * Returns the complete list of Sites in your dotCMS repository retrieved directly from the data source. + * Returns the complete list of Sites in your dotCMS repository retrieved directly from the data source, + * including the System Host. * * @param user The {@link User} that is calling this method. * @param respectFrontendRoles If the User's front-end roles need to be taken into account in order to perform this @@ -214,6 +215,23 @@ Host find(Contentlet contentlet, */ List findAllFromDB(final User user, final boolean respectFrontendRoles) throws DotDataException, DotSecurityException; + /** + * Returns the complete list of Sites in your dotCMS repository retrieved directly from the data source. This + * method allows you to EXCLUDE the System Host from the result list. + * + * @param user The {@link User} that is calling this method. + * @param includeSystemHost If the System Host must be included in the results, set to {@code true}. + * @param respectFrontendRoles If the User's front-end roles need to be taken into account in order to perform this + * operation, set to {@code true}. Otherwise, set to {@code false}. + * + * @return The list of {@link Host} objects. + * + * @throws DotDataException An error occurred when accessing the data source. + * @throws DotSecurityException The specified User does not have the required permissions to perform this + * operation. + */ + List findAllFromDB(final User user, final boolean includeSystemHost, final boolean respectFrontendRoles) throws DotDataException, DotSecurityException; + /** * Returns the complete list of Sites in your dotCMS repository retrieved from the cache. If no data is currently * available, it will be retrieved from the data source, and put into the respective cache region. @@ -461,6 +479,8 @@ PaginatedArrayList search(final String filter, final boolean showArchived, *
  • {@code showStopped}: Determines if stopped Sites are returned in the result set.
  • *
  • {@code showSystemHost}: Determines whether the System Host must be returned or not.
  • * + * It's very important to note that, if the {@code showStopped} parameter is set to {@code true}, then all archived + * Sites will also be returned because they're considered stopped Sites. * * @param filter The initial part or full name of the Site you need to look up. * @param showStopped If stopped Sites must be returned, set to {@code true}. Otherwise, se to {@code diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/HostAPIImpl.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/HostAPIImpl.java index 02b6f6c5a8b9..5fefa102da7e 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/HostAPIImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/HostAPIImpl.java @@ -60,7 +60,7 @@ */ public class HostAPIImpl implements HostAPI, Flushable { - private HostCache hostCache = CacheLocator.getHostCache(); + private final HostCache hostCache = CacheLocator.getHostCache(); private Host systemHost; private final SystemEventsAPI systemEventsAPI; private HostFactory hostFactory; @@ -320,7 +320,13 @@ public List findAll(final User user, final int limit, final int offset, fi @Override public List findAllFromDB(final User user, final boolean respectFrontendRoles) throws DotDataException, DotSecurityException { - return this.findPaginatedSitesFromDB(user, 0, 0, null, respectFrontendRoles); + return this.findAllFromDB(user, true, respectFrontendRoles); + } + + @Override + public List findAllFromDB(final User user, final boolean includeSystemHost, + final boolean respectFrontendRoles) throws DotDataException, DotSecurityException { + return this.findPaginatedSitesFromDB(user, 0, 0, null, includeSystemHost, respectFrontendRoles); } /** @@ -345,15 +351,43 @@ public List findAllFromDB(final User user, final boolean respectFrontendRo @CloseDBIfOpened private List findPaginatedSitesFromDB(final User user, final int limit, final int offset, final String sortBy, final boolean respectFrontendRoles) throws DotDataException, DotSecurityException { - final List siteList = this.getHostFactory().findAll(limit, offset, sortBy); + return this.findPaginatedSitesFromDB(user, limit, offset, sortBy, true, respectFrontendRoles); + } + + /** + * Returns an optionally paginated list of all Sites in your dotCMS content repository. This method allows you to + * EXCLUDE the System Host from the result list. + * + * @param user The {@link User} performing this action. + * @param limit Limit of results returned in the response, for pagination purposes. If set equal or + * lower than zero, this parameter will be ignored. + * @param offset Expected offset of results in the response, for pagination purposes. If set equal or + * lower than zero, this parameter will be ignored. + * @param sortBy Optional sorting criterion, as specified by the available columns in: {@link + * com.dotmarketing.common.util.SQLUtil#ORDERBY_WHITELIST} + * @param includeSystemHost If the System Host should be included in the result list, set to {@code true}. + * @param respectFrontendRoles If the User's front-end roles need to be taken into account in order to perform this + * operation, set to {@code true}. Otherwise, set to {@code false}. + * + * @return The list of {@link Host} objects. + * + * @throws DotDataException An error occurred when accessing the data source. + * @throws DotSecurityException The specified User does not have the required permissions to perform this + * operation. + */ + private List findPaginatedSitesFromDB(final User user, final int limit, final int offset, + final String sortBy, final boolean includeSystemHost, + final boolean respectFrontendRoles) throws DotDataException, DotSecurityException { + final List siteList = this.getHostFactory().findAll(limit, offset, sortBy, includeSystemHost); if (null != siteList && !siteList.isEmpty()) { return siteList.stream().filter(site -> { try { - checkSitePermission(user, respectFrontendRoles, site); + this.checkSitePermission(user, respectFrontendRoles, site); return true; } catch (final DotDataException | DotSecurityException e) { - Logger.warn(this, String.format("An error occurred when checking permissions from User '%s' on " + - "Site '%s': %s", user.getUserId(), site.getInode(), e.getMessage())); + Logger.warn(this, + String.format("An error occurred when checking permissions from User '%s' on " + "Site " + + "'%s': %s", user.getUserId(), site.getInode(), e.getMessage())); } return false; }).collect(Collectors.toList()); @@ -826,9 +860,9 @@ public PaginatedArrayList search(final String filter, final boolean showAr } } if (showStopped && !showArchived) { - // Return stopped Sites + // Return stopped Sites, which include archived Sites as well siteListOpt = this.getHostFactory() - .findStoppedSites(filter, false, limit, offset, showSystemHost, user, respectFrontendRoles); + .findStoppedSites(filter, true, limit, offset, showSystemHost, user, respectFrontendRoles); if (siteListOpt.isPresent()) { return convertToSitePaginatedList(siteListOpt.get()); } From 77b382aec6c0383a4dbb462fb621c084b89f9c15 Mon Sep 17 00:00:00 2001 From: Nollymar Longa Date: Fri, 26 May 2023 09:52:26 -0500 Subject: [PATCH 30/63] A new empty starter was generated (#25048) Co-authored-by: nollymar --- dotCMS/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotCMS/build.gradle b/dotCMS/build.gradle index 9ec2c680149b..0a2454cd668c 100644 --- a/dotCMS/build.gradle +++ b/dotCMS/build.gradle @@ -292,7 +292,7 @@ dependencies { //// starter config - starter group: 'com.dotcms', name: 'starter', version: 'empty_20230509', ext: 'zip' + starter group: 'com.dotcms', name: 'starter', version: 'empty_20230525', ext: 'zip' //Uncomment this line if you want to download the starter that comes with data // starter group: 'com.dotcms', name: 'starter', version: '20230518', ext: 'zip' testsStarter group: 'com.dotcms', name: 'starter', version: 'empty_20220718', ext: 'zip' From fda5936b21810c7808ec854363af5c0045507c7c Mon Sep 17 00:00:00 2001 From: jdotcms Date: Fri, 26 May 2023 14:35:12 -0600 Subject: [PATCH 31/63] resolving conflicts with master and release 23.06 --- .../Content Resource.postman_collection.json | 1773 +++++++++++++++-- .../DotFavoritePage.postman_collection.json | 367 ++++ dotCMS/src/curl-test/PagesResourceTests.json | 5 +- .../UserResource.postman_collection.json | 6 +- .../workflows/business/WorkflowAPITest.java | 5 +- .../auth/providers/saml/v1/SAMLHelper.java | 64 +- .../business/ESContentletAPIImpl.java | 41 +- .../business/ContentTypeInitializer.java | 20 +- .../role/LayoutMapResponseEntityView.java | 17 + .../rest/api/v1/system/role/RoleForm.java | 130 ++ .../api/v1/system/role/RoleLayoutForm.java | 4 + .../rest/api/v1/system/role/RoleResource.java | 186 +- .../system/role/RoleResponseEntityView.java | 16 + .../rest/api/v1/user/CreateUserForm.java | 215 ++ .../dotcms/rest/api/v1/user/UserHelper.java | 114 ++ .../dotcms/rest/api/v1/user/UserResource.java | 123 ++ .../dotmarketing/business/PermissionAPI.java | 31 + .../business/PermissionBitAPIImpl.java | 50 +- .../WEB-INF/messages/Language.properties | 1 + ...rmissions_accordion_contentType_entry.html | 14 + .../edit_permissions_accordion_entry.html | 3 +- .../common/edit_permissions_tab_js_inc.jsp | 36 +- 22 files changed, 2939 insertions(+), 282 deletions(-) create mode 100644 dotCMS/src/curl-test/DotFavoritePage.postman_collection.json create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/LayoutMapResponseEntityView.java create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleForm.java create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleResponseEntityView.java create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/user/CreateUserForm.java create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserHelper.java create mode 100644 dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_contentType_entry.html diff --git a/dotCMS/src/curl-test/Content Resource.postman_collection.json b/dotCMS/src/curl-test/Content Resource.postman_collection.json index c0c5107b8ad9..d3ea9278d50e 100644 --- a/dotCMS/src/curl-test/Content Resource.postman_collection.json +++ b/dotCMS/src/curl-test/Content Resource.postman_collection.json @@ -1,15 +1,311 @@ { "info": { - "_postman_id": "debc118d-ab19-430a-8488-b33662e964ce", + "_postman_id": "30e7c950-c42b-4489-a163-8fa82bdbbb54", "name": "Content Resource", "description": "Content Resource test", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "4500400" + "_exporter_id": "781456" }, "item": [ { "name": "Test Content Search", "item": [ + { + "name": "Story Block Field Tests", + "item": [ + { + "name": "Create Content Type with WYSIWYG field", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Test Content Type creation must be successful\", function() {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "var contentType = jsonData.entity[0];", + "", + "pm.collectionVariables.set(\"contentTypeId\", contentType.id);", + "pm.collectionVariables.set(\"contentTypeName\", contentType.name);", + "pm.collectionVariables.set(\"fieldId\", contentType.fields[0].id);" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "password", + "value": "admin", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"defaultType\":false,\n \"fixed\":false,\n \"system\":false,\n \"clazz\":\"com.dotcms.contenttype.model.type.ImmutableSimpleContentType\",\n \"description\":\"\",\n \"host\":\"8a7d5e23-da1e-420a-b4f0-471e7da8ea2d\",\n \"folder\":\"SYSTEM_FOLDER\",\n \"name\":\"TestRichCT\",\n \"fields\": [{\n \"clazz\": \"com.dotcms.contenttype.model.field.ImmutableWysiwygField\",\n\t\t\"dataType\": \"LONG_TEXT\",\n\t\t\"fieldVariables\": [],\n\t\t\"fixed\": false,\n\t\t\"iDate\": 1662153603216,\n\t\t\"indexed\": true,\n\t\t\"listed\": false,\n\t\t\"modDate\": 1662153603216,\n\t\t\"name\": \"Description\",\n\t\t\"readOnly\": false,\n\t\t\"required\": false,\n\t\t\"searchable\": true,\n\t\t\"sortOrder\": 1,\n\t\t\"unique\": false,\n\t\t\"variable\": \"description\"\n }\n ]\n}" + }, + "url": { + "raw": "{{serverURL}}/api/v1/contenttype", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "contenttype" + ] + }, + "description": "Creates a test Content Type with a single WYSIWYG field, which will be transformed into a Story Block field later on." + }, + "response": [] + }, + { + "name": "Create Test Content", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Test Content creation must be successful\", function() {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Test contentlet was created successfully\", function () {", + " var jsonData = pm.response.json().entity;", + "", + " pm.expect(jsonData.summary.failCount).to.eql(0, \"An error occurred when creating the test Content Type\");", + " pm.expect(jsonData.summary.successCount).to.eql(1, \"One piece of content should have been created\");", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "saveHelperData", + "type": "any" + }, + { + "key": "showPassword", + "value": false, + "type": "boolean" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"contentlets\": [\n {\n \"contentType\": \"{{contentTypeName}}\",\n \"description\": \"

    My Title

    This is my test content.

    \",\n \"contentHost\": \"default\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH?indexPolicy=WAIT_FOR", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "workflow", + "actions", + "default", + "fire", + "PUBLISH" + ], + "query": [ + { + "key": "indexPolicy", + "value": "WAIT_FOR" + } + ] + }, + "description": "Creates a test Contentlet of the previously generated Content Type." + }, + "response": [] + }, + { + "name": "Transform WYSIWYG field into Story Block", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"WYSIWYG transformation must be successful\", function() {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Checking transformation of WYSIWYG field into Story Block\", function () {", + " var jsonData = pm.response.json().entity;", + "", + " pm.expect(jsonData[0].columns[0].fields[0].clazz).to.eql(\"com.dotcms.contenttype.model.field.ImmutableStoryBlockField\", \"Field type is not the expected ImmutableStoryBlockField\");", + " pm.expect(jsonData[0].columns[0].fields[0].variable).to.eql(\"description\", \"Story Block Field does not correspond to expected 'description' field\");", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"field\": {\n \"clazz\": \"com.dotcms.contenttype.model.field.ImmutableStoryBlockField\",\n \"contentTypeId\": \"{{contentTypeId}}\",\n \"dataType\": \"LONG_TEXT\",\n \"fieldType\": \"Story-Block\",\n \"fieldTypeLabel\": \"Story Block\",\n \"fieldVariables\": [],\n \"fixed\": false,\n \"iDate\": 1662153603000,\n \"id\": \"{{fieldId}}\",\n \"indexed\": true,\n \"listed\": false,\n \"modDate\": 1662153723000,\n \"name\": \"Description\",\n \"readOnly\": false,\n \"required\": false,\n \"searchable\": true,\n \"sortOrder\": 1,\n \"unique\": false,\n \"variable\": \"description\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v3/contenttype/{{contentTypeId}}/fields/{{fieldId}}", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v3", + "contenttype", + "{{contentTypeId}}", + "fields", + "{{fieldId}}" + ] + }, + "description": "Transforms the WYSIWYG into the Story Block field. This is NOT a new REST Endpoint, it is the existing Endpoint used to update data for any field." + }, + "response": [] + }, + { + "name": "Delete Test Content Type", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Test Content Type deletion must be successful\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "saveHelperData", + "type": "any" + }, + { + "key": "showPassword", + "value": false, + "type": "boolean" + } + ] + }, + "method": "DELETE", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/contenttype/id/{{contentTypeId}}?indexPolicy=WAIT_FOR", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "contenttype", + "id", + "{{contentTypeId}}" + ], + "query": [ + { + "key": "indexPolicy", + "value": "WAIT_FOR" + } + ] + }, + "description": "Deletes the test Content Type with the now-transformed Story Block field." + }, + "response": [] + } + ], + "description": "This folder contains Postman tests related to the interaction with Story Block fields in dotCMS." + }, { "name": "Create ContentType with 360 Icon", "event": [ @@ -134,11 +430,1063 @@ } ] }, - "method": "POST", + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n\n \"contentlets\":[\n {\n \"contentType\":\"Three60IconTest\",\n \"title\":\"360IconTest\",\n \"contentHost\":\"default\" \n } \n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH?indexPolicy=WAIT_FOR", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "workflow", + "actions", + "default", + "fire", + "PUBLISH" + ], + "query": [ + { + "key": "indexPolicy", + "value": "WAIT_FOR" + } + ] + } + }, + "response": [] + }, + { + "name": "Request a Contet-Type Expect ContetType Icon", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "", + "", + "pm.test(\"Id check\", function () {", + " pm.expect(jsonData.contentlets[0].__icon__).to.eql('contentIcon');", + " pm.expect(jsonData.contentlets[0].contentTypeIcon).to.eql('360');", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/content/render/false/query/+contentType:Three60IconTest", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "content", + "render", + "false", + "query", + "+contentType:Three60IconTest" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Test Related Items Pagination", + "item": [ + { + "name": "Import Content ManyToMany Parent Children", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Bundle uploaded sucessfully\", function () {", + " pm.response.to.have.status(200);", + "", + " var jsonData = pm.response.json();", + " console.log(jsonData);", + "", + " pm.expect(jsonData[\"bundleName\"]).to.eql(\"issue-22236-RelatedPagination-01GA6T1VX69MHWX2NYME3EG238.tar.gz\");", + " pm.expect(jsonData[\"status\"]).to.eql(\"SUCCESS\");", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "password", + "value": "admin", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/octet-stream" + }, + { + "key": "Content-Disposition", + "type": "text", + "value": "attachment" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "src": "resources/issue-22236-RelatedPagination-01GA6T1VX69MHWX2NYME3EG238.tar.gz" + } + ] + }, + "url": { + "raw": "{{serverURL}}/api/bundle?sync=true", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "bundle" + ], + "query": [ + { + "key": "sync", + "value": "true" + }, + { + "key": "AUTH_TOKEN", + "value": "", + "disabled": true + } + ] + }, + "description": "Imports a Bundle that includes:\n\n* pp-test page with all the dependencies. pp-test page was created on a demo.dotcms.com site" + }, + "response": [] + }, + { + "name": "Request a Contet With Offset 0 Expect 3 Items", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "", + "pm.test(\"check items\", function () {", + " pm.expect(jsonData.contentlets.length).eql(3); ", + " pm.expect(jsonData.contentlets[0].title).eql('Parent-1');", + " pm.expect(jsonData.contentlets[1].title).eql('Parent-2');", + " pm.expect(jsonData.contentlets[2].title).eql('Parent-3');", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/content/render/false/query/+contentType:Issue22236Parent%20+languageId:1%20+deleted:false%20+working:true/orderby/title,modDate%20desc/related/Issue22236Parent.children:a696acd4d82bce47eac68da48e26ca96/limit/10/offset/0", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "content", + "render", + "false", + "query", + "+contentType:Issue22236Parent%20+languageId:1%20+deleted:false%20+working:true", + "orderby", + "title,modDate%20desc", + "related", + "Issue22236Parent.children:a696acd4d82bce47eac68da48e26ca96", + "limit", + "10", + "offset", + "0" + ] + } + }, + "response": [] + }, + { + "name": "Request a Contet With Offset 1 Expect 2 items", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "", + "pm.test(\"check items\", function () {", + " pm.expect(jsonData.contentlets.length).eql(2); ", + " pm.expect(jsonData.contentlets[0].title).eql('Parent-2');", + " pm.expect(jsonData.contentlets[1].title).eql('Parent-3'); ", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/content/render/false/query/+contentType:Issue22236Parent%20+languageId:1%20+deleted:false%20+working:true/orderby/title,modDate%20desc/related/Issue22236Parent.children:a696acd4d82bce47eac68da48e26ca96/limit/10/offset/1", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "content", + "render", + "false", + "query", + "+contentType:Issue22236Parent%20+languageId:1%20+deleted:false%20+working:true", + "orderby", + "title,modDate%20desc", + "related", + "Issue22236Parent.children:a696acd4d82bce47eac68da48e26ca96", + "limit", + "10", + "offset", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Request a Contet With Offset 2 Expect 1 items", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "", + "pm.test(\"check items\", function () {", + " pm.expect(jsonData.contentlets.length).eql(1); ", + " pm.expect(jsonData.contentlets[0].title).eql('Parent-3'); ", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/content/render/false/query/+contentType:Issue22236Parent%20+languageId:1%20+deleted:false%20+working:true/orderby/title,modDate%20desc/related/Issue22236Parent.children:a696acd4d82bce47eac68da48e26ca96/limit/10/offset/2", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "content", + "render", + "false", + "query", + "+contentType:Issue22236Parent%20+languageId:1%20+deleted:false%20+working:true", + "orderby", + "title,modDate%20desc", + "related", + "Issue22236Parent.children:a696acd4d82bce47eac68da48e26ca96", + "limit", + "10", + "offset", + "2" + ] + } + }, + "response": [] + }, + { + "name": "Request a Contet With Offset 0 Limit 1 Expect 1 items", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "", + "pm.test(\"check items\", function () {", + " pm.expect(jsonData.contentlets.length).eql(1); ", + " pm.expect(jsonData.contentlets[0].title).eql('Parent-1'); ", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/content/render/false/query/+contentType:Issue22236Parent%20+languageId:1%20+deleted:false%20+working:true/orderby/title,modDate%20desc/related/Issue22236Parent.children:a696acd4d82bce47eac68da48e26ca96/limit/1/offset/0", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "content", + "render", + "false", + "query", + "+contentType:Issue22236Parent%20+languageId:1%20+deleted:false%20+working:true", + "orderby", + "title,modDate%20desc", + "related", + "Issue22236Parent.children:a696acd4d82bce47eac68da48e26ca96", + "limit", + "1", + "offset", + "0" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Categories", + "item": [ + { + "name": "create-content-type-with-categories", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200); ", + " let jsonData = pm.response.json();", + " pm.expect(jsonData[\"bundleName\"]).to.eql(\"issue-22756-categories-01GCAK78NPY1JH8TRGX8SWCVN3.tar.gz\");", + " pm.expect(jsonData[\"status\"]).to.eql(\"SUCCESS\");", + "});", + "", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "saveHelperData", + "type": "any" + }, + { + "key": "showPassword", + "value": false, + "type": "boolean" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "src": "resources/issue-22756-categories-01GCAK78NPY1JH8TRGX8SWCVN3.tar.gz" + } + ] + }, + "url": { + "raw": "{{serverURL}}/api/bundle/sync", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "bundle", + "sync" + ] + }, + "description": "This endpoint imports a bundle that contains an instance of a Content Type that contains 2 category fields." + }, + "response": [] + }, + { + "name": "get-contentlet-identifier-by-contnet-type", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200); ", + " const jsonData = pm.response.json();", + " const entity = (jsonData.entity);", + " const identifier = entity.jsonObjectView.contentlets[0].identifier; ", + " pm.collectionVariables.set(\"identifier22756\", identifier);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "saveHelperData", + "type": "any" + }, + { + "key": "showPassword", + "value": false, + "type": "boolean" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\"query\":\"+contentType:BikeNameAndCategory +languageId:1 +deleted:false +working:true\",\"sort\":\"modDate desc\",\"offset\":0}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/content/_search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "content", + "_search" + ] + }, + "description": "Once the Content type that contains the category fields has been imported this request finds it and gets the identifier from it." + }, + "response": [] + }, + { + "name": "update-contentlet-add-category-1", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200); ", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "saveHelperData", + "type": "any" + }, + { + "key": "showPassword", + "value": false, + "type": "boolean" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{ \n \"title\": \"Update Norco Revolver\",\n \"contentHost\": \"demo.dotcms.com\",\n \"identifier\": \"{{identifier22756}}\", \n \"contentType\":\"BikeNameAndCategory\",\n \"bikeType\":[\"MTB\",\"Road\"],\n \"make\":[\"Norco\"] \n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/content/publish/1", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "content", + "publish", + "1" + ] + }, + "description": "This is basically the happy path test. We validate that the content-type can be updated passing arrays of valid categories. Then verify the response to check the categories are the expected." + }, + "response": [] + }, + { + "name": "validate-contentlet-categories-post-update-1", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200); ", + " const jsonData = pm.response.json();", + " const entity = (jsonData.entity);", + " const contentlet = entity.jsonObjectView.contentlets[0];", + " pm.expect(contentlet.bikeType).to.eql([{\"MTB\":\"MTB\"},{\"Road\":\"Road\"}]); ", + " pm.expect(contentlet.make).to.eql([{\"Norco\":\"Norco\"}]); ", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "saveHelperData", + "type": "any" + }, + { + "key": "showPassword", + "value": false, + "type": "boolean" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\"query\":\"+contentType:BikeNameAndCategory +languageId:1 +deleted:false +working:true\",\"sort\":\"modDate desc\",\"offset\":0}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/content/_search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "content", + "_search" + ] + }, + "description": "Once the Content type that contains the category fields has been imported this request finds it and gets the identifier from it." + }, + "response": [] + }, + { + "name": "update-contentlet-test-null-category", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200); ", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "saveHelperData", + "type": "any" + }, + { + "key": "showPassword", + "value": false, + "type": "boolean" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{ \n \"title\": \"Update Norco Revolver\",\n \"contentHost\": \"demo.dotcms.com\",\n \"identifier\": \"{{identifier22756}}\", \n \"contentType\":\"BikeNameAndCategory\",\n \"bikeType\":null,\n \"make\":null \n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/content/publish/1", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "content", + "publish", + "1" + ] + }, + "description": "Update categories passing null values on eachoena of them" + }, + "response": [] + }, + { + "name": "validate-contentlet-categories-post-update-with-null", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200); ", + " const jsonData = pm.response.json();", + " const entity = (jsonData.entity);", + " const contentlet = entity.jsonObjectView.contentlets[0];", + " pm.expect(contentlet.bikeType).to.eql([{\"MTB\":\"MTB\"},{\"Road\":\"Road\"}]); ", + " pm.expect(contentlet.make).to.eql([{\"Norco\":\"Norco\"}]); ", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "saveHelperData", + "type": "any" + }, + { + "key": "showPassword", + "value": false, + "type": "boolean" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\"query\":\"+contentType:BikeNameAndCategory +languageId:1 +deleted:false +working:true\",\"sort\":\"modDate desc\",\"offset\":0}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/content/_search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "content", + "_search" + ] + }, + "description": "validate that passing null did not affected the content. specifically the category fields." + }, + "response": [] + }, + { + "name": "update-contentlet-test-ignored-category-fields", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200); ", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "saveHelperData", + "type": "any" + }, + { + "key": "showPassword", + "value": false, + "type": "boolean" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{ \n \"title\": \"Update Norco Revolver\",\n \"contentHost\": \"demo.dotcms.com\",\n \"identifier\": \"{{identifier22756}}\", \n \"contentType\":\"BikeNameAndCategory\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/content/publish/1", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "content", + "publish", + "1" + ] + }, + "description": "Test ignoring the fields." + }, + "response": [] + }, + { + "name": "validate-contentlet-categories-post-update-ignored-fields", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200); ", + " const jsonData = pm.response.json();", + " const entity = (jsonData.entity);", + " const contentlet = entity.jsonObjectView.contentlets[0];", + " pm.expect(contentlet.bikeType).to.eql([{\"MTB\":\"MTB\"},{\"Road\":\"Road\"}]); ", + " pm.expect(contentlet.make).to.eql([{\"Norco\":\"Norco\"}]); ", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "saveHelperData", + "type": "any" + }, + { + "key": "showPassword", + "value": false, + "type": "boolean" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\"query\":\"+contentType:BikeNameAndCategory +languageId:1 +deleted:false +working:true\",\"sort\":\"modDate desc\",\"offset\":0}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/content/_search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "content", + "_search" + ] + }, + "description": "Validate the results after having ignored the fields." + }, + "response": [] + }, + { + "name": "update-contentlet-test-ignored-category-fields Copy", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200); ", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "saveHelperData", + "type": "any" + }, + { + "key": "showPassword", + "value": false, + "type": "boolean" + } + ] + }, + "method": "PUT", "header": [], "body": { "mode": "raw", - "raw": "{\n\n \"contentlets\":[\n {\n \"contentType\":\"Three60IconTest\",\n \"title\":\"360IconTest\",\n \"contentHost\":\"default\" \n } \n ]\n}", + "raw": "{ \n \"title\": \"Update Norco Revolver\",\n \"contentHost\": \"demo.dotcms.com\",\n \"identifier\": \"{{identifier22756}}\", \n \"contentType\":\"BikeNameAndCategory\",\n \"bikeType\":[] \n}", "options": { "raw": { "language": "json" @@ -146,42 +1494,36 @@ } }, "url": { - "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH", + "raw": "{{serverURL}}/api/content/publish/1", "host": [ "{{serverURL}}" ], "path": [ "api", - "v1", - "workflow", - "actions", - "default", - "fire", - "PUBLISH" + "content", + "publish", + "1" ] - } + }, + "description": "Test removing the categories on one fields and ignoring the other" }, "response": [] }, { - "name": "Request a Contet-Type Expect ContetType Icon", + "name": "validate-contentlet-categories-post-update-ignored-fields Copy", "event": [ { "listen": "test", "script": { "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var jsonData = pm.response.json();", - "", - "", - "pm.test(\"Id check\", function () {", - " pm.expect(jsonData.contentlets[0].__icon__).to.eql('contentIcon');", - " pm.expect(jsonData.contentlets[0].contentTypeIcon).to.eql('360');", - "});", - "" + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200); ", + " const jsonData = pm.response.json();", + " const entity = (jsonData.entity);", + " const contentlet = entity.jsonObjectView.contentlets[0];", + " pm.expect(contentlet.bikeType).to.eql(undefined); ", + " pm.expect(contentlet.make).to.eql([{\"Norco\":\"Norco\"}]); ", + "});" ], "type": "text/javascript" } @@ -200,50 +1542,68 @@ "key": "username", "value": "admin@dotcms.com", "type": "string" + }, + { + "key": "saveHelperData", + "type": "any" + }, + { + "key": "showPassword", + "value": false, + "type": "boolean" } ] }, - "method": "GET", + "method": "POST", "header": [], + "body": { + "mode": "raw", + "raw": "{\"query\":\"+contentType:BikeNameAndCategory +languageId:1 +deleted:false +working:true\",\"sort\":\"modDate desc\",\"offset\":0}", + "options": { + "raw": { + "language": "json" + } + } + }, "url": { - "raw": "{{serverURL}}/api/content/render/false/query/+contentType:Three60IconTest", + "raw": "{{serverURL}}/api/content/_search", "host": [ "{{serverURL}}" ], "path": [ "api", "content", - "render", - "false", - "query", - "+contentType:Three60IconTest" + "_search" ] - } + }, + "description": "Validate that the fiel that got the empty array has lost the categories while the other one remains intact" }, "response": [] } ] }, { - "name": "testSearch_givenContentOnDefaultAndVariant_shouldExcludeVariant", + "name": "File Asset Metadata", "item": [ { - "name": "Create page in DEFAULT variant", + "name": "Create Test File", "event": [ { "listen": "test", "script": { "exec": [ - "var jsonData = pm.response.json();", - "", - "", - "pm.test(\"Status code should be ok 200\", function () {", + "pm.test(\"Test File created successfully\", function () {", " pm.response.to.have.status(200);", "});", "", - "pm.collectionVariables.set(\"page_id\", jsonData.entity.identifier);", + "pm.test(\"No errors must be present\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors.length).to.eql(0);", "", - "" + " pm.collectionVariables.set(\"fileId\", jsonData.entity.identifier);", + " pm.collectionVariables.set(\"fileInode\", jsonData.entity.inode);", + " pm.collectionVariables.set(\"fileName\", jsonData.entity.fileName);", + "});" ], "type": "text/javascript" } @@ -266,18 +1626,36 @@ ] }, "method": "PUT", - "header": [], + "header": [ + { + "key": "Origin", + "value": "{{serverURL}}", + "type": "text" + } + ], "body": { - "mode": "raw", - "raw": "{ \n \"contentlet\" : {\n \"title\" : \"test_variants_page\",\n \"languageId\" : 1,\n \"stInode\": \"c541abb1-69b3-4bc5-8430-5e09e5239cc8\",\n \"url\": \"test_variants_page\",\n \"hostFolder\": \"8a7d5e23-da1e-420a-b4f0-471e7da8ea2d\",\n \"template\": \"SYSTEM_TEMPLATE\",\n \"cachettl\": 0,\n \"friendlyName\": \"friendlyName\"\n }\n}", - "options": { - "raw": { - "language": "json" + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "src": "resources/testpdf.pdf" + }, + { + "key": "json", + "value": "{\"contentlet\": {\"contentType\":\"FileAsset\",\"title\":\"testpdf.pdf\",\"hostFolder\":\"default\"}}", + "type": "text" + }, + { + "key": "file", + "type": "file", + "src": [], + "disabled": true } - } + ] }, "url": { - "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH", + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH?indexPolicy=WAIT_FOR", "host": [ "{{serverURL}}" ], @@ -289,29 +1667,43 @@ "default", "fire", "PUBLISH" + ], + "query": [ + { + "key": "indexPolicy", + "value": "WAIT_FOR" + } ] - } + }, + "description": "Create a test File Asset that will be used to read Metadata from." }, "response": [] }, { - "name": "createDraftExperiment_shoudSucceed", + "name": "Check Metadata Section", "event": [ { "listen": "test", "script": { "exec": [ - "var jsonData = pm.response.json();", - "", - "", - "pm.test(\"Status code should be ok 200\", function () {", + "pm.test(\"HTTP Status must be successfully\", function () {", " pm.response.to.have.status(200);", "});", "", - "pm.collectionVariables.set(\"experimentId\", jsonData.entity.id);", - "pm.collectionVariables.set(\"experimentShortId\", jsonData.entity.id.substring(0, 11).replace('-', ''));", + "var jsonData = pm.response.json();", "", - "" + "pm.test(\"Metadata section must be present and not empty\", function () {", + " pm.expect(jsonData.contentlets[0].metaData.length).to.greaterThan(0);", + "});", + "", + "pm.test(\"Expected Metadata are present\", function () {", + " var expectedFileName = pm.collectionVariables.get(\"fileName\");", + " var expectedFileInode = pm.collectionVariables.get(\"fileInode\");", + " pm.expect(jsonData.contentlets[0].metaData.isImage).to.eq(false);", + " pm.expect(jsonData.contentlets[0].metaData.name).to.eq(expectedFileName);", + " pm.expect(jsonData.contentlets[0].metaData.contentType).to.eq(\"application/pdf\");", + " pm.expect(jsonData.contentlets[0].metaData.path).to.include(\"/\" + expectedFileInode + \"/fileAsset/\" + expectedFileName);", + "});" ], "type": "text/javascript" } @@ -322,63 +1714,50 @@ "type": "basic", "basic": [ { - "key": "password", - "value": "admin", + "key": "username", + "value": "admin@dotcms.com", "type": "string" }, { - "key": "username", - "value": "admin@dotcms.com", + "key": "password", + "value": "admin", "type": "string" } ] }, - "method": "POST", + "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"pageId\": \"{{page_id}}\",\n \"name\": \"Add/Remove content Experiment\",\n \"description\": \"Expriment ro Add/Remove contentlet from a specific variant page\" \n}", - "options": { - "raw": { - "language": "json" - } - } - }, "url": { - "raw": "{{serverURL}}/api/v1/experiments/", + "raw": "{{serverURL}}/api/content/id/{{fileId}}", "host": [ "{{serverURL}}" ], "path": [ "api", - "v1", - "experiments", - "" + "content", + "id", + "{{fileId}}" ] }, - "description": "Create a new Experiment named \"Add/Remove content Experiment\" with the page created in the previous request." + "description": "Checks a few Metadata properties, such as:\n\n- **isImage:** should be \"false\" for a PDF.\n- **name:** File name.\n- **contentType**: The HTTP content type, not the dotCMS content Type.\n- **path**: The Inode-based path to the file." }, "response": [] }, { - "name": "pre_addVariantToExperiment", + "name": "Unpublish Test File", "event": [ { "listen": "test", "script": { "exec": [ - "", - "pm.test(\"Variants with correct weight\", function () {", + "pm.test(\"Test File unpublished successfully\", function () {", " pm.response.to.have.status(200);", "});", "", - "var jsonData = pm.response.json();", - "pm.collectionVariables.set(\"variantId\", jsonData.entity.trafficProportion.variants[1].id);", - "", - "", - "", - "", - "" + "pm.test(\"No errors must be present\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors.length).to.eql(0);", + "});" ], "type": "text/javascript" } @@ -400,51 +1779,61 @@ } ] }, - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"description\": \"Variant to Add/Remove contentlet test\"\n}", - "options": { - "raw": { - "language": "json" - } + "method": "PUT", + "header": [ + { + "key": "Origin", + "value": "{{serverURL}}", + "type": "text" } - }, + ], "url": { - "raw": "{{serverURL}}/api/v1/experiments/{{experimentId}}/variants", + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/UNPUBLISH?inode={{fileInode}}&identifier={{fileId}}&indexPolicy=WAIT_FOR", "host": [ "{{serverURL}}" ], "path": [ "api", "v1", - "experiments", - "{{experimentId}}", - "variants" + "workflow", + "actions", + "default", + "fire", + "UNPUBLISH" + ], + "query": [ + { + "key": "inode", + "value": "{{fileInode}}" + }, + { + "key": "identifier", + "value": "{{fileId}}" + }, + { + "key": "indexPolicy", + "value": "WAIT_FOR" + } ] - }, - "description": "Create a new variant into the previous created experiment" + } }, "response": [] }, { - "name": "Create page in Variant", + "name": "Archive Test File", "event": [ { "listen": "test", "script": { "exec": [ - "var jsonData = pm.response.json();", - "", - "", - "pm.test(\"Status code should be ok 200\", function () {", + "pm.test(\"Test File archived successfully\", function () {", " pm.response.to.have.status(200);", "});", "", - "pm.collectionVariables.set(\"page_id\", jsonData.entity.identifier);", - "", - "" + "pm.test(\"No errors must be present\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors.length).to.eql(0);", + "});" ], "type": "text/javascript" } @@ -467,18 +1856,15 @@ ] }, "method": "PUT", - "header": [], - "body": { - "mode": "raw", - "raw": "{ \n \"contentlet\" : {\n \"identifier\": \"{{page_id}}\",\n \"title\" : \"test_variants_page on variant\",\n \"languageId\" : 1,\n \"stInode\": \"c541abb1-69b3-4bc5-8430-5e09e5239cc8\",\n \"url\": \"test_variants_page\",\n \"hostFolder\": \"8a7d5e23-da1e-420a-b4f0-471e7da8ea2d\",\n \"template\": \"SYSTEM_TEMPLATE\",\n \"cachettl\": 0,\n \"friendlyName\": \"friendlyName\", \n \"variantId\": \"{{variantId}}\",\n \"indexPolicy\" : \"FORCE\"\n }\n}", - "options": { - "raw": { - "language": "json" - } + "header": [ + { + "key": "Origin", + "value": "{{serverURL}}", + "type": "text" } - }, + ], "url": { - "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH", + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/ARCHIVE?inode={{fileInode}}&identifier={{fileId}}&indexPolicy=WAIT_FOR", "host": [ "{{serverURL}}" ], @@ -489,31 +1875,41 @@ "actions", "default", "fire", - "PUBLISH" + "ARCHIVE" + ], + "query": [ + { + "key": "inode", + "value": "{{fileInode}}" + }, + { + "key": "identifier", + "value": "{{fileId}}" + }, + { + "key": "indexPolicy", + "value": "WAIT_FOR" + } ] } }, "response": [] }, { - "name": "testSearch_givenContentOnDefaultAndVariant_shouldExcludeVariant", + "name": "Delete Test File", "event": [ { "listen": "test", "script": { "exec": [ - "pm.test(\"Status code should be 200\", function () {", + "pm.test(\"Test File deleted successfully\", function () {", " pm.response.to.have.status(200);", "});", "", - "var jsonData = pm.response.json();", - "", - "", - "pm.test(\"Verify no variant is present\", function () {", - " pm.expect(jsonData.entity.jsonObjectView.contentlets.length).eq(1);", - " pm.expect(jsonData.entity.jsonObjectView.contentlets[0].variantId).eq(\"DEFAULT\");", - "});", - "" + "pm.test(\"No errors must be present\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors.length).to.eql(0);", + "});" ], "type": "text/javascript" } @@ -535,33 +1931,80 @@ } ] }, - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"query\": \"+working:true +(urlmap:* OR basetype:5) +deleted:false +(title:test_variants_page* OR path:*test_variants_page* OR urlmap:*test_variants_page*) \",\n \"sort\": \"modDate DESC\",\n \"limit\": 40,\n \"offset\": \"0\"\n}", - "options": { - "raw": { - "language": "json" - } + "method": "PUT", + "header": [ + { + "key": "Origin", + "value": "{{serverURL}}", + "type": "text" } - }, + ], "url": { - "raw": "{{serverURL}}/api/content/_search", + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/DELETE?inode={{fileInode}}&identifier={{fileId}}&indexPolicy=WAIT_FOR", "host": [ "{{serverURL}}" ], "path": [ "api", - "content", - "_search" + "v1", + "workflow", + "actions", + "default", + "fire", + "DELETE" + ], + "query": [ + { + "key": "inode", + "value": "{{fileInode}}" + }, + { + "key": "identifier", + "value": "{{fileId}}" + }, + { + "key": "indexPolicy", + "value": "WAIT_FOR" + } ] } }, "response": [] } ], - "description": "This test do the follow:\n\n* add/remove contentlet type called 'add/remove contentlet type' with just a title field\n* Create 4 contentlet with title:\n * content_1\n * content_2\n * content_3\n * content_4\n* Create a Page called 'page_to_add_remove_contentlet'\n* Add the first 3 contentlet into the Page.\n* Create a experiment called 'Add/Remove content Experiment' using this page as Original\n* Create a variant into this experiment called \"Variant to Add/Remove contentlet test\".\n* Render the page in the DEFAULT variant and the specific variant created before, the 3 contentlets should be render.\n* Remove 'content-2' and 'content-3' also add 'content-4' into the page for the variant \"Variant to Add/Remove contentlet test\".\n* Render the page for the variant \"Variant to Add/Remove contentlet test\" should have just 'content-1' and 'content-4'.\n* Render the page for the DEFAULT variant should render 'content-1', 'content-2' and 'content-3'" + "description": "Verifies that the Metadata section in the JSON response in a File Asset is added." + }, + { + "name": "invalidateSession", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/logout", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "logout" + ] + } + }, + "response": [] }, { "name": "Save Multiple Generic Contentlets", @@ -643,7 +2086,7 @@ "response": [] }, { - "name": "invalidateSession", + "name": "invalidateSessionAgain", "event": [ { "listen": "test", @@ -1128,12 +2571,10 @@ "method": "GET", "header": [], "url": { - "raw": "http://localhost:8080/api/content/render/false/query/+contentType:host +title:default", - "protocol": "http", + "raw": "{{serverURL}}/api/content/render/false/query/+contentType:host +title:default", "host": [ - "localhost" + "{{serverURL}}" ], - "port": "8080", "path": [ "api", "content", @@ -1147,33 +2588,53 @@ "response": [] } ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ], "variable": [ { "key": "contentTypeId", "value": "" }, { - "key": "content1_id", + "key": "fieldId", "value": "" }, { - "key": "content2_id", + "key": "contentTypeName", "value": "" }, { - "key": "page_id", + "key": "identifier22756", "value": "" }, { - "key": "experimentId", + "key": "fileId", "value": "" }, { - "key": "experimentShortId", + "key": "fileInode", "value": "" }, { - "key": "variantId", + "key": "fileName", "value": "" } ] diff --git a/dotCMS/src/curl-test/DotFavoritePage.postman_collection.json b/dotCMS/src/curl-test/DotFavoritePage.postman_collection.json new file mode 100644 index 000000000000..ae0f3adde4a9 --- /dev/null +++ b/dotCMS/src/curl-test/DotFavoritePage.postman_collection.json @@ -0,0 +1,367 @@ +{ + "info": { + "_postman_id": "256da9a7-a2a0-40ed-9ce9-c21947f9d67c", + "name": "DotFavoritePage", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "781456" + }, + "item": [ + { + "name": "CreateNewLimitedRole", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"roleName\":\"limitedRole\",\n \"roleKey\":\"limitedRole\",\n \"canEditUsers\":true,\n \"canEditPermissions\":true,\n \"canEditLayouts\":true,\n \"description\":\"Limited Role for Limited Users\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/roles", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "roles" + ] + } + }, + "response": [] + }, + { + "name": "GetLayouts", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200 \", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response includes name\", function () {", + " pm.expect(pm.response.json().entity.length).to.gte(0)", + "});", + "", + "var jsonData = pm.response.json();", + "pm.collectionVariables.set(\"firstLayoutId\", jsonData.entity[0].id);" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/roles/layouts", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "roles", + "layouts" + ] + } + }, + "response": [] + }, + { + "name": "SetLayoutToBackedUser", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200 \", function () {", + " pm.response.to.have.status(200);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"roleId\":\"DOTCMS_BACK_END_USER\",\n \"layoutIds\":[\"{{firstLayoutId}}\"]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/roles/layouts", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "roles", + "layouts" + ] + } + }, + "response": [] + }, + { + "name": "CreateLimitedUser", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200 \", function () {", + " pm.response.to.have.status(200);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"firstName\":\"LimitedTestName\",\n \"lastName\":\"LimitedTestLastName\",\n \"email\":\"mylimiteduser@dotcms.com\",\n \"password\":\"dotcms123456\",\n \"active\":true,\n \"roles\":[\"DOTCMS_BACK_END_USER\"]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/users", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "users" + ] + } + }, + "response": [] + }, + { + "name": "invalidateSession", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/logout", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "logout" + ] + } + }, + "response": [] + }, + { + "name": "FireDotFavoriteWithPermissionsWithLimitedUser", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200 \", function () {", + " pm.response.to.have.status(200);", + "});", + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "dotcms123456", + "type": "string" + }, + { + "key": "username", + "value": "mylimiteduser@dotcms.com", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"contentlet\":{\n \"contentType\":\"dotFavoritePage\",\n \"title\":\"Test3-11\",\n \"screenshot\":\"/dA/8ba493215e/fileAsset/veni-vidi-vici.png\",\n \"url\":\"Test body\",\n \"order\":1\n },\n \"individualPermissions\": {\n \"READ\":[\"CMS Owner\"],\n \"WRITE\":[\"CMS Owner\"],\n \"PUBLISH\":[\"CMS Owner\"],\n \"EDIT_PERMISSIONS\":[\"CMS Owner\"]\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "workflow", + "actions", + "default", + "fire", + "PUBLISH" + ] + } + }, + "response": [] + }, + { + "name": "invalidateSessionAgain", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/logout", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "logout" + ] + } + }, + "response": [] + }, + { + "name": "ReloginAsAnAdminAtTheEnd", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"userId\":\"admin@dotcms.com\",\n \"password\":\"admin\",\n \"backEndLogin\":true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/authentication", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "authentication" + ] + } + }, + "response": [] + } + ], + "variable": [ + { + "key": "firstLayoutId", + "value": "" + } + ] +} \ No newline at end of file diff --git a/dotCMS/src/curl-test/PagesResourceTests.json b/dotCMS/src/curl-test/PagesResourceTests.json index ebac1b2f8967..5067b618f991 100644 --- a/dotCMS/src/curl-test/PagesResourceTests.json +++ b/dotCMS/src/curl-test/PagesResourceTests.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "98af464f-bfb7-495e-89a3-d26545fecc9c", + "_postman_id": "efa22acb-6704-493e-bb14-fec8cb186601", "name": "Page API - [api/v1/page]", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", "_exporter_id": "781456" @@ -5212,14 +5212,13 @@ "method": "GET", "header": [], "url": { - "raw": "{{serverURL}}/api/v1/users/logout", + "raw": "{{serverURL}}/api/v1/logout", "host": [ "{{serverURL}}" ], "path": [ "api", "v1", - "users", "logout" ] } diff --git a/dotCMS/src/curl-test/UserResource.postman_collection.json b/dotCMS/src/curl-test/UserResource.postman_collection.json index de17637da0e8..462dc4b9ec5a 100644 --- a/dotCMS/src/curl-test/UserResource.postman_collection.json +++ b/dotCMS/src/curl-test/UserResource.postman_collection.json @@ -1,10 +1,10 @@ { "info": { - "_postman_id": "a9eac856-e8ee-47da-96a9-f2e68e81fd72", + "_postman_id": "958f7287-3a20-4591-94ae-525a6c9a7c38", "name": "UserResource", "description": "Verifies that commonly-used routines for interacting with User data are working as expected. Most of these are related to filtering operations and for back-end use only.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "5403727" + "_exporter_id": "781456" }, "item": [ { @@ -34,7 +34,7 @@ " var paginationData = pm.response.json().pagination;", " pm.expect(paginationData.currentPage).to.equal(1);", " pm.expect(paginationData.perPage).to.equal(100);", - " pm.expect(paginationData.totalEntries).to.equal(2);", + " pm.expect(paginationData.totalEntries).to.gte(2);", "});", "" ], diff --git a/dotCMS/src/integration-test/java/com/dotmarketing/portlets/workflows/business/WorkflowAPITest.java b/dotCMS/src/integration-test/java/com/dotmarketing/portlets/workflows/business/WorkflowAPITest.java index 4516d4e97615..172edfb9189a 100644 --- a/dotCMS/src/integration-test/java/com/dotmarketing/portlets/workflows/business/WorkflowAPITest.java +++ b/dotCMS/src/integration-test/java/com/dotmarketing/portlets/workflows/business/WorkflowAPITest.java @@ -602,10 +602,11 @@ public void send_permission_with_limited_user_Test() final Role cmsOwnerRole = APILocator.getRoleAPI().loadCMSOwnerRole(); final User wflimitedUser = new UserDataGen().active(true).emailAddress("wflimiteduser" + time + "@dotcms.com").roles(backendRole).nextPersisted(); // create a content type with cms owner full permissions - final ContentType testContentType = new ContentTypeDataGen().velocityVarName("testcontenttype" + time).name("testcontenttype" + time).nextPersisted(); + final ContentType testContentType = new ContentTypeDataGen().velocityVarName("testcontenttype" + time) + .host(APILocator.systemHost()).name("testcontenttype" + time).nextPersisted(); final int permissionType = PermissionAPI.PERMISSION_USE | PermissionAPI.PERMISSION_EDIT | PermissionAPI.PERMISSION_PUBLISH | PermissionAPI.PERMISSION_EDIT_PERMISSIONS; - final Permission permission = new Permission(testContentType.getPermissionId(), backendRole.getId(), permissionType); + final Permission permission = new Permission(testContentType.getPermissionId(), cmsOwnerRole.getId(), permissionType); APILocator.getPermissionAPI().save(permission, testContentType, APILocator.systemUser(), false); // create a contentlet of the type given permissions to the someone else final Contentlet contentlet = new ContentletDataGen(testContentType).user(wflimitedUser).next(); diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/saml/v1/SAMLHelper.java b/dotCMS/src/main/java/com/dotcms/auth/providers/saml/v1/SAMLHelper.java index 3def4d22b589..3d064c529d74 100644 --- a/dotCMS/src/main/java/com/dotcms/auth/providers/saml/v1/SAMLHelper.java +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/saml/v1/SAMLHelper.java @@ -3,6 +3,7 @@ import com.dotcms.cms.login.LoginServiceAPI; import com.dotcms.company.CompanyAPI; import com.dotcms.filters.interceptor.saml.SamlWebInterceptor; +import com.dotcms.rest.api.v1.user.UserHelper; import com.dotcms.saml.Attributes; import com.dotcms.saml.DotSamlConstants; import com.dotcms.saml.DotSamlException; @@ -439,7 +440,7 @@ private void handleRoles(final User user, final Attributes attributesBean, this.addRolesFromIDP(user, attributesBean, identityProviderConfiguration, buildRolesStrategy); // Add SAML User role - this.addRole(user, DotSamlConstants.DOTCMS_SAML_USER_ROLE, true, true); + UserHelper.getInstance().addRole(user, DotSamlConstants.DOTCMS_SAML_USER_ROLE, true, true); Logger.debug(this, ()->"Default SAML User role has been assigned"); // the only strategy that does not include the saml user role is the "idp" @@ -453,7 +454,7 @@ private void handleRoles(final User user, final Attributes attributesBean, for (final String roleExtra : rolesExtra){ - this.addRole(user, roleExtra, false, false); + UserHelper.getInstance().addRole(user, roleExtra, false, false); Logger.debug(this, () -> "Optional user role: " + this.getSamlConfigurationService().getConfigAsString(identityProviderConfiguration, SamlName.DOTCMS_SAML_OPTIONAL_USER_ROLE) + " has been assigned"); @@ -585,66 +586,9 @@ private void addRole(final User user, final String removeRolePrefix, final Strin roleObject.replaceFirst(removeRolePrefix, StringUtils.EMPTY): roleObject; - addRole(user, roleKey, false, false); + UserHelper.getInstance().addRole(user, roleKey, false, false); } - private void addRole(final User user, final String roleKey, final boolean createRole, final boolean isSystem) - throws DotDataException { - - Role role = this.roleAPI.loadRoleByKey(roleKey); - - // create the role, in case it does not exist - if (role == null && createRole) { - Logger.info(this, "Role with key '" + roleKey + "' was not found. Creating it..."); - role = createNewRole(roleKey, isSystem); - } - - if (null != role) { - if (!this.roleAPI.doesUserHaveRole(user, role)) { - this.roleAPI.addRoleToUser(role, user); - Logger.debug(this, "Role named '" + role.getName() + "' has been added to user: " + user.getEmailAddress()); - } else { - Logger.debug(this, - "User '" + user.getEmailAddress() + "' already has the role '" + role + "'. Skipping assignment..."); - } - } else { - Logger.debug(this, "Role named '" + roleKey + "' does NOT exists in dotCMS. Ignoring it..."); - } - } - - private Role createNewRole(String roleKey, boolean isSystem) throws DotDataException { - Role role = new Role(); - role.setName(roleKey); - role.setRoleKey(roleKey); - role.setEditUsers(true); - role.setEditPermissions(false); - role.setEditLayouts(false); - role.setDescription(""); - role.setId(UUIDGenerator.generateUuid()); - - // Setting SYSTEM role as a parent - role.setSystem(isSystem); - Role parentRole = roleAPI.loadRoleByKey(Role.SYSTEM); - role.setParent(parentRole.getId()); - - String date = DateUtil.getCurrentDate(); - - ActivityLogger.logInfo(ActivityLogger.class, getClass() + " - Adding Role", - "Date: " + date + "; " + "Role:" + roleKey); - AdminLogger.log(AdminLogger.class, getClass() + " - Adding Role", "Date: " + date + "; " + "Role:" + roleKey); - - try { - role = roleAPI.save(role, role.getId()); - } catch (DotDataException | DotStateException e) { - ActivityLogger.logInfo(ActivityLogger.class, getClass() + " - Error adding Role", - "Date: " + date + "; " + "Role:" + roleKey); - AdminLogger.log(AdminLogger.class, getClass() + " - Error adding Role", - "Date: " + date + "; " + "Role:" + roleKey); - throw e; - } - - return role; - } private String toString(final String... rolePatterns) { return null == rolePatterns ? DotSamlConstants.NULL : Arrays.asList(rolePatterns).toString(); diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java index 505aff14e817..96cbe2461e5c 100644 --- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java @@ -221,12 +221,6 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -import static com.dotcms.exception.ExceptionUtil.bubbleUpException; -import static com.dotcms.exception.ExceptionUtil.getLocalizedMessageOrDefault; -import static com.dotmarketing.business.PermissionAPI.PERMISSION_CAN_ADD_CHILDREN; -import static com.dotmarketing.portlets.contentlet.model.Contentlet.URL_MAP_FOR_CONTENT_KEY; -import static com.dotmarketing.portlets.personas.business.PersonaAPI.DEFAULT_PERSONA_NAME_KEY; - /** * Implementation class for the {@link ContentletAPI} interface. * @@ -242,6 +236,8 @@ public class ESContentletAPIImpl implements ContentletAPI { private static final String NEVER_EXPIRE = "NeverExpire"; private static final String CHECKIN_IN_PROGRESS = "__checkin_in_progress__"; + private static final String IS_NEW_CONTENT = "__IS_NEW_CONTENT__"; + private final ContentletIndexAPIImpl indexAPI; private final ESContentFactoryImpl contentFactory; private final PermissionAPI permissionAPI; @@ -978,8 +974,14 @@ private void internalPublish(final Contentlet contentlet, final User user, ? contentlet.getInode() : "Unknown")); //If the contentlet has CMS Owner Publish permission on it, the user creating the new contentlet is allowed to publish - final List roles = permissionAPI.getRoles(contentlet.getPermissionId(), - PermissionAPI.PERMISSION_PUBLISH, "CMS Owner", 0, -1); + List roles = permissionAPI.getRoles(contentlet.getPermissionId(), + PermissionAPI.PERMISSION_PUBLISH, Role.CMS_OWNER_ROLE, 0, -1); + if (roles.isEmpty() && + isNewContentlet(contentlet)) { + + roles = permissionAPI.getRoles(contentlet.getContentType().getPermissionId(), + PermissionAPI.PERMISSION_PUBLISH, Role.CMS_OWNER_ROLE, 0, -1); + } final Role cmsOwner = APILocator.getRoleAPI().loadCMSOwnerRole(); if (roles.size() > 0) { @@ -1052,6 +1054,20 @@ private void internalPublish(final Contentlet contentlet, final User user, //contentletSystemEventUtil.pushPublishEvent(contentlet); } + private boolean isNewContentlet(final Contentlet contentlet) throws DotDataException { + return contentlet.isNew() || null == contentlet.getIdentifier() + || ConversionUtils.toBooleanFromDb(contentlet.getMap().getOrDefault(IS_NEW_CONTENT, false)) + || hasOnlyOneVersion(contentlet); + } + + private boolean hasOnlyOneVersion(final Contentlet contentlet) throws DotDataException { + + final int versionCount = new DotConnect().setSQL("SELECT count(*) as count FROM (SELECT 1 FROM contentlet WHERE identifier =? LIMIT 2) AS t") + .addParam(contentlet.getIdentifier()) + .loadInt("count"); + return versionCount <= 1; + } + @Override public void publishAssociated(Contentlet contentlet, boolean isNew) throws DotSecurityException, DotDataException, @@ -5634,7 +5650,10 @@ private Contentlet internalCheckin(Contentlet contentlet, } new ContentletLoader().invalidate(contentlet); - + if (isNewContent) { + // we mark as new. b/c next actions on the pipe may need to know if it is new or not. + contentlet.setProperty(IS_NEW_CONTENT, true); + } } catch (Exception e) { if (createNewVersion && workingContentlet != null && UtilMethods.isSet( workingContentlet.getInode())) { @@ -9751,7 +9770,9 @@ public boolean canLock(final Contentlet contentlet, final User user, return true; } else if (!APILocator.getPermissionAPI().doesUserHavePermission( - contentlet, PermissionAPI.PERMISSION_EDIT, user, respectFrontendRoles)) { + contentlet, PermissionAPI.PERMISSION_EDIT, user, respectFrontendRoles) + && !APILocator.getPermissionAPI().doesUserHavePermission( + contentlet.getContentType(), PermissionAPI.PERMISSION_EDIT, user, respectFrontendRoles)) { throw new DotLockException("User: " + (user != null ? user.getUserId() : "Unknown") + " does not have Edit Permissions to lock content: " + (contentlet != null diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeInitializer.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeInitializer.java index 953f3f705d2a..1a02c8d62d12 100644 --- a/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeInitializer.java +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeInitializer.java @@ -15,14 +15,18 @@ import com.dotmarketing.business.Role; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.folders.model.Folder; +import com.dotmarketing.portlets.structure.model.Structure; import com.dotmarketing.portlets.workflows.business.WorkflowAPI; +import com.dotmarketing.quartz.job.ResetPermissionsJob; import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; import io.vavr.Lazy; import io.vavr.control.Try; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -121,15 +125,23 @@ private void checkDefaultPermissions(final ContentType savedContentType) { try { + // Add CMS Owner Permissions final int permissionType = PermissionAPI.PERMISSION_USE | PermissionAPI.PERMISSION_EDIT | PermissionAPI.PERMISSION_PUBLISH | PermissionAPI.PERMISSION_EDIT_PERMISSIONS; final Role backendRole = APILocator.getRoleAPI().loadCMSOwnerRole(); - if (!APILocator.getPermissionAPI().doesRoleHavePermission( - savedContentType, permissionType, backendRole)) { + if (!APILocator.getPermissionAPI().doesRoleHavePermission(savedContentType, permissionType, backendRole)) { + + // remove all current permissions + APILocator.getPermissionAPI().removePermissions(savedContentType); Logger.info(this, "Adding default permissions to the Favorite Page Content Type..."); - final Permission permission = new Permission(savedContentType.getPermissionId(), backendRole.getId(),permissionType); - APILocator.getPermissionAPI().save(permission, savedContentType, APILocator.systemUser(), false); + final List newSetOfPermissions = new ArrayList<>(); + // this is the individual permission + newSetOfPermissions.add(new Permission(savedContentType.getPermissionId(), backendRole.getId(), permissionType, true)); + // this is the inheritance permission + newSetOfPermissions.add(new Permission(Contentlet.class.getCanonicalName(), savedContentType.getPermissionId(), backendRole.getId(), permissionType, true)); + APILocator.getPermissionAPI().assignPermissions(newSetOfPermissions, savedContentType, APILocator.systemUser(), false); + ResetPermissionsJob.triggerJobImmediately(savedContentType); } } catch (DotDataException | DotSecurityException e) { diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/LayoutMapResponseEntityView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/LayoutMapResponseEntityView.java new file mode 100644 index 000000000000..85d1da12d5bc --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/LayoutMapResponseEntityView.java @@ -0,0 +1,17 @@ +package com.dotcms.rest.api.v1.system.role; + +import com.dotcms.rest.ResponseEntityView; + +import java.util.List; +import java.util.Map; + +/** + * LayoutMapResponseEntityView + * @author jsanca + */ +public class LayoutMapResponseEntityView extends ResponseEntityView>> { + + public LayoutMapResponseEntityView(final List> entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleForm.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleForm.java new file mode 100644 index 000000000000..929d95519784 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleForm.java @@ -0,0 +1,130 @@ +package com.dotcms.rest.api.v1.system.role; + +import com.dotcms.repackage.javax.validation.constraints.NotNull; +import com.dotcms.rest.api.Validated; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +/** + * Form to save a new role + * @author jsanca + */ +@JsonDeserialize(builder = RoleForm.Builder.class) +public class RoleForm extends Validated { + + @NotNull + private final String roleName; + + private final String roleKey; + + private final String parentRoleId; + private final boolean canEditUsers; + + private final boolean canEditPermissions; + private final boolean canEditLayouts; + + private final String description; + + private RoleForm(final RoleForm.Builder builder) { + super(); + roleName = builder.roleName; + roleKey = builder.roleKey; + parentRoleId = builder.parentRoleId; + canEditUsers = builder.canEditUsers; + canEditPermissions = builder.canEditPermissions; + canEditLayouts = builder.canEditLayouts; + description = builder.description; + checkValid(); + } + + public String getRoleName() { + return roleName; + } + + public String getRoleKey() { + return roleKey; + } + + public String getParentRoleId() { + return parentRoleId; + } + + public boolean isCanEditUsers() { + return canEditUsers; + } + + public boolean isCanEditPermissions() { + return canEditPermissions; + } + + public boolean isCanEditLayouts() { + return canEditLayouts; + } + + public String getDescription() { + return description; + } + + public static final class Builder { + + @JsonProperty(required = true) + private String roleName; + + @JsonProperty() + private String roleKey; + + @JsonProperty() + private String parentRoleId; + + @JsonProperty() + private boolean canEditUsers; + + @JsonProperty() + private boolean canEditPermissions; + + @JsonProperty() + private boolean canEditLayouts; + + @JsonProperty() + private String description; + + public RoleForm.Builder roleName(final String roleName) { + this.roleName = roleName; + return this; + } + + public RoleForm.Builder roleKey(final String roleKey) { + this.roleKey = roleKey; + return this; + } + + public RoleForm.Builder parentRoleId(final String parentRoleId) { + this.parentRoleId = parentRoleId; + return this; + } + + public RoleForm.Builder canEditUsers(final boolean canEditUsers) { + this.canEditUsers = canEditUsers; + return this; + } + + public RoleForm.Builder canEditPermissions(final boolean canEditPermissions) { + this.canEditPermissions = canEditPermissions; + return this; + } + + public RoleForm.Builder canEditLayouts(final boolean canEditLayouts) { + this.canEditLayouts = canEditLayouts; + return this; + } + + public RoleForm.Builder description(final String description) { + this.description = description; + return this; + } + public RoleForm build() { + return new RoleForm(this); + } + } +} + diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleLayoutForm.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleLayoutForm.java index 469e18ec398c..84d1ae19bcd4 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleLayoutForm.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleLayoutForm.java @@ -6,6 +6,10 @@ import java.util.Set; +/** + * Form to save a layout on a role + * @author jsanca + */ @JsonDeserialize(builder = RoleLayoutForm.Builder.class) public class RoleLayoutForm extends Validated { diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleResource.java index 68a44349237e..19893c6bc0fe 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleResource.java @@ -1,6 +1,8 @@ package com.dotcms.rest.api.v1.system.role; import com.dotcms.repackage.com.google.common.annotations.VisibleForTesting; +import com.dotcms.repackage.org.directwebremoting.WebContext; +import com.dotcms.repackage.org.directwebremoting.WebContextFactory; import com.dotcms.rest.InitDataObject; import com.dotcms.rest.ResponseEntityView; import com.dotcms.rest.WebResource; @@ -8,19 +10,30 @@ import com.dotcms.rest.exception.mapper.ExceptionMapperUtil; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.ApiProvider; +import com.dotmarketing.business.DotStateException; +import com.dotmarketing.business.Layout; import com.dotmarketing.business.LayoutAPI; import com.dotmarketing.business.Role; import com.dotmarketing.business.RoleAPI; import com.dotmarketing.business.UserAPI; +import com.dotmarketing.business.web.UserWebAPI; +import com.dotmarketing.business.web.WebAPILocator; import com.dotmarketing.exception.DoesNotExistException; import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.exception.RoleNameException; import com.dotmarketing.portlets.user.ajax.UserAjax; +import com.dotmarketing.util.ActivityLogger; +import com.dotmarketing.util.AdminLogger; +import com.dotmarketing.util.DateUtil; import com.dotmarketing.util.Logger; import com.dotmarketing.util.SecurityLogger; import com.dotmarketing.util.StringUtils; import com.dotmarketing.util.UtilMethods; +import com.liferay.portal.PortalException; +import com.liferay.portal.SystemException; import com.liferay.portal.language.LanguageException; import com.liferay.portal.language.LanguageUtil; import com.liferay.portal.model.User; @@ -30,6 +43,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.vavr.control.Try; import org.apache.commons.beanutils.BeanUtils; import java.io.IOException; @@ -40,6 +54,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -162,7 +177,7 @@ public Response deleteRoleLayouts( final String roleId = roleLayoutForm.getRoleId(); final Set layoutIds = roleLayoutForm.getLayoutIds(); - final Role role = roleAPI.loadRoleById(roleId); + final Role role = roleAPI.loadRoleById(roleId); final LayoutAPI layoutAPI = APILocator.getLayoutAPI(); Logger.debug(this, ()-> "Deleting the layouts : " + layoutIds + " to the role: " + roleId); @@ -179,6 +194,73 @@ public Response deleteRoleLayouts( } } + /** + * Add a new role + * Only admins can add roles. + */ + @POST + @Produces("application/json") + public Response addNewRole( + final @Context HttpServletRequest request, + final @Context HttpServletResponse response, + final RoleForm roleForm) throws DotDataException, DotSecurityException { + + final InitDataObject initDataObject = new WebResource.InitBuilder(this.webResource) + .requiredFrontendUser(false).rejectWhenNoUser(true) + .requiredBackendUser(true).requiredPortlet("roles") + .requestAndResponse(request, response).init(); + + if (this.roleAPI.doesUserHaveRole(initDataObject.getUser(), this.roleAPI.loadCMSAdminRole())) { + + final User user = initDataObject.getUser(); + Role role = new Role(); + role.setName(roleForm.getRoleName()); + role.setRoleKey(roleForm.getRoleKey()); + role.setEditUsers(roleForm.isCanEditUsers()); + role.setEditPermissions(roleForm.isCanEditPermissions()); + role.setEditLayouts(roleForm.isCanEditLayouts()); + role.setDescription(roleForm.getDescription()); + + if(Objects.nonNull(roleForm.getParentRoleId())) { + + final Role parentRole = roleAPI.loadRoleById(roleForm.getParentRoleId()); + role.setParent(parentRole.getId()); + } + + final String date = DateUtil.getCurrentDate(); + + ActivityLogger.logInfo(getClass(), "Adding Role", "Date: " + date + "; "+ "User:" + user.getUserId()); + AdminLogger.log(getClass(), "Adding Role", "Date: " + date + "; "+ "User:" + user.getUserId()); + + try { + + role = roleAPI.save(role); + } catch(RoleNameException e) { + + ActivityLogger.logInfo(getClass(), "Error Adding Role. Invalid Name", "Date: " + date + "; "+ "User:" + user.getUserId()); + AdminLogger.log(getClass(), "Error Adding Role. Invalid Name", "Date: " + date + "; "+ "User:" + user.getUserId()); + throw new DotDataException( + Try.of(()->LanguageUtil.get(initDataObject.getUser(),"Role-Save-Name-Failed")).getOrElse("Role Name not valid"), + "Role-Save-Name-Failed", e); + + } catch(DotDataException | DotStateException e) { + ActivityLogger.logInfo(getClass(), "Error Adding Role", "Date: " + date + "; "+ "User:" + user.getUserId()); + AdminLogger.log(getClass(), "Error Adding Role", "Date: " + date + "; "+ "User:" + user.getUserId()); + throw e; + } + + ActivityLogger.logInfo(getClass(), "Role Created", "Date: " + date + "; "+ "User:" + user.getUserId() + "; RoleID: " + role.getId() ); + AdminLogger.log(getClass(), "Role Created", "Date: " + date + "; "+ "User:" + user.getUserId() + "; RoleID: " + role.getId() ); + + return Response.ok(new RoleResponseEntityView(role.toMap())).build(); + } + + final String remoteIp = request.getRemoteHost(); + SecurityLogger.logInfo(UserAjax.class, "unauthorized attempt to call create a role by user "+ + initDataObject.getUser().getUserId() + " from " + remoteIp); + throw new DotSecurityException("User: '" + initDataObject.getUser().getUserId() + "' not authorized"); + } + /** * Saves set of layout into a role * The user must have to be a BE and has to have access to roles portlet @@ -200,7 +282,7 @@ public Response saveRoleLayouts( final String roleId = roleLayoutForm.getRoleId(); final Set layoutIds = roleLayoutForm.getLayoutIds(); - final Role role = roleAPI.loadRoleById(roleId); + final Role role = roleAPI.loadRoleById(roleId); final LayoutAPI layoutAPI = APILocator.getLayoutAPI(); Logger.debug(this, ()-> "Saving the layouts : " + layoutIds + " to the role: " + roleId); @@ -208,13 +290,12 @@ public Response saveRoleLayouts( return Response.ok(new ResponseEntityView(map("savedLayouts", this.roleHelper.saveRoleLayouts(role, layoutIds, layoutAPI, this.roleAPI, APILocator.getSystemEventsAPI())))).build(); - } else { - - final String remoteIp = request.getRemoteHost(); - SecurityLogger.logInfo(UserAjax.class, "unauthorized attempt to call save role layouts by user "+ - initDataObject.getUser().getUserId() + " from " + remoteIp); - throw new DotSecurityException("User: '" + initDataObject.getUser().getUserId() + "' not authorized"); } + + final String remoteIp = request.getRemoteHost(); + SecurityLogger.logInfo(UserAjax.class, "unauthorized attempt to call save role layouts by user "+ + initDataObject.getUser().getUserId() + " from " + remoteIp); + throw new DotSecurityException("User: '" + initDataObject.getUser().getUserId() + "' not authorized"); } /** @@ -259,10 +340,10 @@ public Response findRoleLayouts( @Produces("application/json") @SuppressWarnings("unchecked") public Response loadUsersAndRolesByRoleId(@Context final HttpServletRequest request, - @Context final HttpServletResponse response, - @PathParam ("roleid") final String roleId, - @DefaultValue("false") @QueryParam("roleHierarchyForAssign") final boolean roleHierarchyForAssign, - @QueryParam ("name") final String roleNameToFilter) throws DotDataException, DotSecurityException { + @Context final HttpServletResponse response, + @PathParam ("roleid") final String roleId, + @DefaultValue("false") @QueryParam("roleHierarchyForAssign") final boolean roleHierarchyForAssign, + @QueryParam ("name") final String roleNameToFilter) throws DotDataException, DotSecurityException { new WebResource.InitBuilder(this.webResource).requiredBackendUser(true) .requiredFrontendUser(false).requestAndResponse(request, response) @@ -326,9 +407,9 @@ private final List filterRoleList(final String roleNameToFilter, final Lis @Path("/{roleid}") @Produces("application/json") public Response loadRoleByRoleId(@Context final HttpServletRequest request, - @Context final HttpServletResponse response, - @PathParam ("roleid") final String roleId, - @DefaultValue("true") @QueryParam("loadChildrenRoles") final boolean loadChildrenRoles) + @Context final HttpServletResponse response, + @PathParam ("roleid") final String roleId, + @DefaultValue("true") @QueryParam("loadChildrenRoles") final boolean loadChildrenRoles) throws DotDataException, DotSecurityException { new WebResource.InitBuilder(this.webResource).requiredBackendUser(true) @@ -366,8 +447,8 @@ public Response loadRoleByRoleId(@Context final HttpServletRequest request, @GET @Produces("application/json") public Response loadRootRoles(@Context final HttpServletRequest request, - @Context final HttpServletResponse response, - @DefaultValue("true") @QueryParam("loadChildrenRoles") final boolean loadChildrenRoles) + @Context final HttpServletResponse response, + @DefaultValue("true") @QueryParam("loadChildrenRoles") final boolean loadChildrenRoles) throws DotDataException, DotSecurityException { new WebResource.InitBuilder(this.webResource).requiredBackendUser(true) @@ -433,21 +514,21 @@ public Response loadRootRoles(@Context final HttpServletRequest request, content = @Content(mediaType = "application/json", schema = @Schema(implementation = ResponseEntitySmallRoleView.class)))}) public Response searchRoles(@Context final HttpServletRequest request, - @Context final HttpServletResponse response, - @Parameter(name = "searchName", description = "Value to filter by role name") - @DefaultValue("") @QueryParam("searchName") final String searchName, + @Context final HttpServletResponse response, + @Parameter(name = "searchName", description = "Value to filter by role name") + @DefaultValue("") @QueryParam("searchName") final String searchName, @Parameter(name = "searchKey", description = "Value to filter by role key") - @DefaultValue("") @QueryParam("searchKey") final String searchKey, + @DefaultValue("") @QueryParam("searchKey") final String searchKey, @Parameter(name = "roleId", description = "Value for specific role id") - @DefaultValue("") @QueryParam("roleId") final String roleId, + @DefaultValue("") @QueryParam("roleId") final String roleId, @Parameter(name = "start", description = "Offset on pagination") - @DefaultValue("0") @QueryParam("start") final int startParam, + @DefaultValue("0") @QueryParam("start") final int startParam, @Parameter(name = "count", description = "Size on pagination") - @DefaultValue("20") @QueryParam("count") final int count, + @DefaultValue("20") @QueryParam("count") final int count, @Parameter(name = "includeUserRoles", description = "Set false if do not want to include user rules") - @DefaultValue("true") @QueryParam("includeUserRoles") final boolean includeUserRoles, + @DefaultValue("true") @QueryParam("includeUserRoles") final boolean includeUserRoles, @Parameter(name = "includeWorkflowRoles", description = "Set to true if want to include the workflow roles") - @DefaultValue("false") @QueryParam("includeWorkflowRoles") final boolean includeWorkflowRoles) + @DefaultValue("false") @QueryParam("includeWorkflowRoles") final boolean includeWorkflowRoles) throws DotDataException, DotSecurityException, LanguageException, IOException, InvocationTargetException, IllegalAccessException { final InitDataObject initDataObject = new WebResource.InitBuilder(this.webResource).requiredBackendUser(true) @@ -455,7 +536,7 @@ public Response searchRoles(@Context final HttpServletRequest request, .rejectWhenNoUser(true).init(); Logger.debug(this, ()-> "Searching role, searchName: " + searchName + ", searchKey: " + searchKey + ", roleId: " + roleId - + ", start: " + startParam + ", count: " + count + ", includeUserRoles: " + includeUserRoles + ", includeWorkflowRoles: " + includeWorkflowRoles); + + ", start: " + startParam + ", count: " + count + ", includeUserRoles: " + includeUserRoles + ", includeWorkflowRoles: " + includeWorkflowRoles); int start = startParam; final Role cmsAnonOrig = this.roleAPI.loadCMSAnonymousRole(); @@ -487,6 +568,53 @@ public Response searchRoles(@Context final HttpServletRequest request, return Response.ok(new ResponseEntitySmallRoleView(rolesToView(roleList))).build(); } + + /** + * Get all layouts + * + * @return {@link LayoutMapResponseEntityView} List of Layouts + * @throws DotDataException + * @throws DotSecurityException + */ + @GET + @Path("/layouts") + @Produces("application/json") + public Response getAllLayouts(@Context final HttpServletRequest request, + @Context final HttpServletResponse response) + throws DotDataException, LanguageException, DotRuntimeException, PortalException, SystemException { + + final List> layoutsMap = new ArrayList<>(); + final List layouts = APILocator.getLayoutAPI().findAllLayouts(); + + for(final Layout layout: layouts) { + + final Map layoutMap = layout.toMap(); + layoutMap.put("portletTitles", getPorletTitlesFromLayout(layout, request)); + layoutsMap.add(layoutMap); + } + + return Response.ok(new LayoutMapResponseEntityView(layoutsMap)).build(); + } + + private List getPorletTitlesFromLayout (final Layout layout, + final HttpServletRequest request) + throws LanguageException, DotRuntimeException, PortalException, SystemException { + + final List portletIds = layout.getPortletIds(); + final List portletTitles = new ArrayList<>(); + if(portletIds != null) { + for(final String portletId: portletIds) { + + final String portletTitle = LanguageUtil.get( + WebAPILocator.getUserWebAPI().getLoggedInUser(request), + "com.dotcms.repackage.javax.portlet.title." + portletId); + portletTitles.add(portletTitle); + } + } + + return portletTitles; + } + private boolean fillRoles(final String searchName, final int count, final int startParam, final Role cmsAnon, final String cmsAnonName, final List roleList, final boolean includeUserRoles, final String searchKey) throws DotDataException { @@ -497,8 +625,8 @@ private boolean fillRoles(final String searchName, final int count, final int st while (roleList.size() < count) { final List roles = StringUtils.isSet(searchKey)? - this.roleAPI.findRolesByKeyFilterLeftWildcard(searchKey, start, count): - this.roleAPI.findRolesByFilterLeftWildcard(searchName, start, count); + this.roleAPI.findRolesByKeyFilterLeftWildcard(searchKey, start, count): + this.roleAPI.findRolesByFilterLeftWildcard(searchName, start, count); if (roles.isEmpty()) { break; diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleResponseEntityView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleResponseEntityView.java new file mode 100644 index 000000000000..ebcb2e81fd29 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/role/RoleResponseEntityView.java @@ -0,0 +1,16 @@ +package com.dotcms.rest.api.v1.system.role; + +import com.dotcms.rest.ResponseEntityView; + +import java.util.Map; + +/** + * View for returning a role + * @author jsanca + */ +public class RoleResponseEntityView extends ResponseEntityView> { + + public RoleResponseEntityView(final Map entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/CreateUserForm.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/CreateUserForm.java new file mode 100644 index 000000000000..3072385ff182 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/CreateUserForm.java @@ -0,0 +1,215 @@ +package com.dotcms.rest.api.v1.user; + +import com.dotcms.repackage.javax.validation.constraints.NotNull; +import com.dotcms.repackage.org.hibernate.validator.constraints.NotBlank; +import com.dotcms.rest.api.Validated; +import com.dotmarketing.util.UtilMethods; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Encapsulates the information to create an User + */ +@JsonDeserialize(builder = CreateUserForm.Builder.class) +public final class CreateUserForm extends Validated { + + private String userId; + private final boolean active; + @NotNull + @NotBlank + private final String firstName; + private final String middleName; + @NotNull + @NotBlank + private final String lastName; + private final String nickName; + @NotNull + @NotBlank + private final String email; + private final boolean male; + private final String birthday; + private final long languageId; + private final String timeZoneId; + private final char[] password; + + private final Map additionalInfo; + + private final List roles; + + private CreateUserForm(CreateUserForm.Builder builder) { + + this.active = builder.active; + this.firstName = builder.firstName; + this.middleName = builder.middleName; + this.lastName = builder.lastName; + this.nickName = builder.nickName; + this.email = builder.email; + this.male = builder.male; + this.birthday = builder.birthday; + this.languageId = builder.languageId; + this.timeZoneId = builder.timeZoneId; + this.password = builder.password; + this.additionalInfo = builder.additionalInfo; + this.roles = UtilMethods.isSet(builder.roles)?builder.roles: Collections.emptyList(); + this.userId = builder.userId; + + checkValid(); + if (!UtilMethods.isSet(this.password)) { + throw new IllegalArgumentException("Password can not be null"); + } + } + + public String getUserId() { + return userId; + } + + public boolean isActive() { + return active; + } + + public String getFirstName() { + return firstName; + } + + public String getMiddleName() { + return middleName; + } + + public String getLastName() { + return lastName; + } + + public String getNickName() { + return nickName; + } + + public String getEmail() { + return email; + } + + public boolean isMale() { + return male; + } + + public String getBirthday() { + return birthday; + } + + public long getLanguageId() { + return languageId; + } + + public String getTimeZoneId() { + return timeZoneId; + } + + public char[] getPassword() { + return password; + } + + public Map getAdditionalInfo() { + return additionalInfo; + } + + public List getRoles() { + return roles; + } + + public static final class Builder { + @JsonProperty private String userId; + @JsonProperty private boolean active; + @JsonProperty private String firstName; + @JsonProperty private String middleName; + @JsonProperty private String lastName; + @JsonProperty private String nickName; + @JsonProperty private String email; + @JsonProperty private boolean male; + @JsonProperty private String birthday; + @JsonProperty private long languageId = -1l; + @JsonProperty private String timeZoneId; + @JsonProperty private char[] password; + @JsonProperty private Map additionalInfo; + + @JsonProperty private List roles; + public Builder() { + } + + public Builder userId(String userId) { + this.userId = userId; + return this; + } + public Builder roles(List roles) { + this.roles = roles; + return this; + } + + public Builder active(boolean active) { + this.active = active; + return this; + } + + public Builder firstName(String firstName) { + this.firstName = firstName; + return this; + } + + public Builder middleName(String middleName) { + this.middleName = middleName; + return this; + } + + public Builder lastName(String lastName) { + this.lastName = lastName; + return this; + } + + public Builder nickName(String nickName) { + this.nickName = nickName; + return this; + } + + public Builder email(String email) { + this.email = email; + return this; + } + + public Builder male(boolean male) { + this.male = male; + return this; + } + + public Builder birthday(String birthday) { + this.birthday = birthday; + return this; + } + + public Builder languageId(long languageId) { + this.languageId = languageId; + return this; + } + + public Builder timeZoneId(String timeZoneId) { + this.timeZoneId = timeZoneId; + return this; + } + + public Builder password(char[] password) { + this.password = password; + return this; + } + + public Builder additionalInfo(Map additionalInfo) { + this.additionalInfo = additionalInfo; + return this; + } + + public CreateUserForm build() { + return new CreateUserForm(this); + } + } +} + diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserHelper.java new file mode 100644 index 000000000000..fbdfaf15b2a4 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserHelper.java @@ -0,0 +1,114 @@ +package com.dotcms.rest.api.v1.user; + +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.DotStateException; +import com.dotmarketing.business.Role; +import com.dotmarketing.business.RoleAPI; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.util.ActivityLogger; +import com.dotmarketing.util.AdminLogger; +import com.dotmarketing.util.DateUtil; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UUIDGenerator; +import com.google.common.annotations.VisibleForTesting; +import com.liferay.portal.model.User; + +/** + * Helper to group SAML and User Resource common methods. + */ +public class UserHelper { + + private static class SingletonHolder { + private static final UserHelper INSTANCE = new UserHelper(); + } + + /** + * Get the instance. + * @return UserHelper + */ + public static UserHelper getInstance() { + + return UserHelper.SingletonHolder.INSTANCE; + } // getInstance. + + private final RoleAPI roleAPI; + + @VisibleForTesting + public UserHelper() { + this(APILocator.getRoleAPI()); + } + + @VisibleForTesting + public UserHelper(final RoleAPI roleAPI) { + this.roleAPI = roleAPI; + } + + /** + * Adds a new role to the user. + * @param user {@link User} user to add the role. + * @param roleKey {@link String} role key to add. + * @param createRole {@link Boolean} create the role if it does not exist. + * @param isSystem {@link Boolean} if it is system role + * @throws DotDataException + */ + public void addRole(final User user, final String roleKey, final boolean createRole, final boolean isSystem) + throws DotDataException { + + Role role = this.roleAPI.loadRoleByKey(roleKey); + + // create the role, in case it does not exist + if (role == null && createRole) { + Logger.info(this, "Role with key '" + roleKey + "' was not found. Creating it..."); + role = createNewRole(roleKey, isSystem); + } + + if (null != role) { + if (!this.roleAPI.doesUserHaveRole(user, role)) { + this.roleAPI.addRoleToUser(role, user); + Logger.debug(this, "Role named '" + role.getName() + "' has been added to user: " + user.getEmailAddress()); + } else { + Logger.debug(this, + "User '" + user.getEmailAddress() + "' already has the role '" + role + "'. Skipping assignment..."); + } + } else { + Logger.debug(this, "Role named '" + roleKey + "' does NOT exists in dotCMS. Ignoring it..."); + } + } + + /** + * Creates a new role. + * @param roleKey {@link String} role key + * @param isSystem {@link Boolean} if it is system role + * @return + * @throws DotDataException + */ + public Role createNewRole(final String roleKey, final boolean isSystem) throws DotDataException { + + Role role = new Role(); + role.setName(roleKey); + role.setRoleKey(roleKey); + role.setEditUsers(true); + role.setEditPermissions(true); + role.setEditLayouts(true); + role.setDescription(""); + role.setId(UUIDGenerator.generateUuid()); + + final String date = DateUtil.getCurrentDate(); + + ActivityLogger.logInfo(ActivityLogger.class, getClass() + " - Adding Role", + "Date: " + date + "; " + "Role:" + roleKey); + AdminLogger.log(AdminLogger.class, getClass() + " - Adding Role", "Date: " + date + "; " + "Role:" + roleKey); + + try { + role = roleAPI.save(role, role.getId()); + } catch (DotDataException | DotStateException e) { + ActivityLogger.logInfo(ActivityLogger.class, getClass() + " - Error adding Role", + "Date: " + date + "; " + "Role:" + roleKey); + AdminLogger.log(AdminLogger.class, getClass() + " - Error adding Role", + "Date: " + date + "; " + "Role:" + roleKey); + throw e; + } + + return role; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserResource.java index 56bfe5ec714c..19ce29ad5de9 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserResource.java @@ -1,7 +1,9 @@ package com.dotcms.rest.api.v1.user; +import com.dotcms.auth.providers.saml.v1.SAMLHelper; import com.dotcms.exception.ExceptionUtil; import com.dotcms.repackage.com.google.common.annotations.VisibleForTesting; +import com.dotcms.rest.AnonymousAccess; import com.dotcms.rest.ErrorEntity; import com.dotcms.rest.ErrorResponseHelper; import com.dotcms.rest.InitDataObject; @@ -20,10 +22,12 @@ import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.ApiProvider; +import com.dotmarketing.business.DotStateException; import com.dotmarketing.business.NoSuchUserException; import com.dotmarketing.business.Role; import com.dotmarketing.business.RoleAPI; import com.dotmarketing.business.UserAPI; +import com.dotmarketing.business.web.WebAPILocator; import com.dotmarketing.common.util.SQLUtil; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotRuntimeException; @@ -31,9 +35,14 @@ import com.dotmarketing.exception.UserFirstNameException; import com.dotmarketing.exception.UserLastNameException; import com.dotmarketing.portlets.contentlet.business.HostAPI; +import com.dotmarketing.util.ActivityLogger; +import com.dotmarketing.util.AdminLogger; import com.dotmarketing.util.DateUtil; import com.dotmarketing.util.Logger; +import com.dotmarketing.util.PortletID; import com.dotmarketing.util.SecurityLogger; +import com.dotmarketing.util.UUIDGenerator; +import com.dotmarketing.util.UUIDUtil; import com.dotmarketing.util.UtilMethods; import com.liferay.portal.auth.PrincipalThreadLocal; import com.liferay.portal.language.LanguageUtil; @@ -58,10 +67,13 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import java.io.Serializable; +import java.text.ParseException; import java.util.Collections; +import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import static com.dotcms.util.CollectionsUtils.list; import static com.dotcms.util.CollectionsUtils.map; @@ -639,4 +651,115 @@ private void checkUserLoginAsRole(final User user) throws DotDataException, DotS } } + /** + * Creates an user. + * If userId is sent will be use, if not will be created "userId-" + UUIDUtil.uuid(). + * By default, users will be inactive unless the active = true is sent and user has permissions( is Admin or access + * to Users and Roles portlets). + * FirstName, LastName, Email and Password are required. + * + * + * Scenarios: + * 1. No Auth or User doing the request do not have access to Users and Roles Portlets + * - Always will be inactive + * - Only the Role DOTCMS_FRONT_END_USER will be added + * 2. Auth, User is Admin or have access to Users and Roles Portlets + * - Can be active if JSON includes ("active": true) + * - The list of RoleKey will be use to assign the roles, if the roleKey doesn't exist will be + * created under the ROOT ROLE. + * + * @param httpServletRequest + * @param createUserForm + * @return User Created + * @throws Exception + */ + @POST + @JSONP + @NoCache + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + public final Response create(@Context final HttpServletRequest httpServletRequest, + @Context final HttpServletResponse httpServletResponse, + final CreateUserForm createUserForm) throws Exception { + + final User modUser = new WebResource.InitBuilder(webResource) + .requestAndResponse(httpServletRequest, httpServletResponse) + .rejectWhenNoUser(true) + .init().getUser(); + + final boolean isRoleAdministrator = modUser.isAdmin() || + ( + APILocator.getLayoutAPI().doesUserHaveAccessToPortlet(PortletID.ROLES.toString(), modUser) && + APILocator.getLayoutAPI().doesUserHaveAccessToPortlet(PortletID.USERS.toString(), modUser) + ); + + if (isRoleAdministrator) { + final User userToUpdated = this.createNewUser( + modUser, createUserForm); + + return Response.ok(new ResponseEntityView(map("userID", userToUpdated.getUserId(), + "user", userToUpdated.toMap()))).build(); // 200 + } + + throw new ForbiddenException("User " + modUser.getUserId() + " does not have permissions to create users"); + } // create. + + protected User createNewUser(final User modUser, + final CreateUserForm createUserForm) + throws DotDataException, DotSecurityException, ParseException { + + final String userId = UtilMethods.isSet(createUserForm.getUserId())? + createUserForm.getUserId(): "userId-" + UUIDUtil.uuid(); + final User user = this.userAPI.createUser(userId, createUserForm.getEmail()); + + user.setFirstName(createUserForm.getFirstName()); + + if (UtilMethods.isSet(createUserForm.getLastName())) { + user.setLastName(createUserForm.getLastName()); + } + + if (UtilMethods.isSet(createUserForm.getBirthday())) { + user.setBirthday(DateUtil.parseISO(createUserForm.getBirthday())); + } + + if (UtilMethods.isSet(createUserForm.getMiddleName())) { + user.setMiddleName(createUserForm.getMiddleName()); + } + + if (createUserForm.getLanguageId() <= 0) { + user.setLanguageId(String.valueOf(createUserForm.getLanguageId() <= 0? + APILocator.getLanguageAPI().getDefaultLanguage().getId(): createUserForm.getLanguageId())); + } + + if (UtilMethods.isSet(createUserForm.getNickName())) { + user.setNickName(createUserForm.getNickName()); + } + + if (UtilMethods.isSet(createUserForm.getTimeZoneId())) { + user.setTimeZoneId(createUserForm.getTimeZoneId()); + } + + user.setPassword(new String(createUserForm.getPassword())); + user.setMale(createUserForm.isMale()); + user.setCreateDate(new Date()); + + if (UtilMethods.isSet(createUserForm.getAdditionalInfo())) { + user.setAdditionalInfo(createUserForm.getAdditionalInfo()); + } + + final List roleKeys = UtilMethods.isSet(createUserForm.getRoles())? + createUserForm.getRoles():list(Role.DOTCMS_FRONT_END_USER); + + this.userAPI.save(user, APILocator.systemUser(), false); + Logger.debug(this, ()-> "User with userId '" + userId + "' and email '" + + createUserForm.getEmail() + "' has been created."); + + for (final String roleKey : roleKeys) { + + UserHelper.getInstance().addRole(user, roleKey, false , false); + } + + return user; + } + + } diff --git a/dotCMS/src/main/java/com/dotmarketing/business/PermissionAPI.java b/dotCMS/src/main/java/com/dotmarketing/business/PermissionAPI.java index 4f259cd72cde..d078d66cff62 100644 --- a/dotCMS/src/main/java/com/dotmarketing/business/PermissionAPI.java +++ b/dotCMS/src/main/java/com/dotmarketing/business/PermissionAPI.java @@ -1,5 +1,6 @@ package com.dotmarketing.business; +import com.dotcms.contenttype.model.type.ContentType; import com.dotmarketing.beans.Inode; import com.dotmarketing.beans.Permission; import com.dotmarketing.exception.DotDataException; @@ -183,6 +184,36 @@ boolean doesUserHavePermission(Permissionable permissionable, */ boolean doesUserHavePermission(Permissionable permissionable, int permissionType, User user) throws DotDataException; + /** + * Return true if the user have over the content type specified + * permission. This method is meant to be used by frontend call because + * assumes that frontend roles should be respected. + * + * @param permissionable permissionable + * @param permissionType + * @param user + * @return boolean + * @version 1.8 + * @throws DotDataException + * @since 1.0 + */ + boolean doesUserHavePermission(ContentType permissionable, int permissionType, User user) throws DotDataException; + + /** + * Return true if the user have over the content type specified + * permission. + * + * @param permissionable permissionable + * @param permissionType + * @param user + * @param respectFrontendRoles + * @return boolean + * @version 1.8 + * @throws DotDataException + * @since 1.0 + */ + boolean doesUserHavePermission(ContentType permissionable, int permissionType, User user, boolean respectFrontendRoles) throws DotDataException; + /** * Return true if the user have over the permissionable the specified * permission diff --git a/dotCMS/src/main/java/com/dotmarketing/business/PermissionBitAPIImpl.java b/dotCMS/src/main/java/com/dotmarketing/business/PermissionBitAPIImpl.java index 34dba1f8711d..3637c4c13bd5 100644 --- a/dotCMS/src/main/java/com/dotmarketing/business/PermissionBitAPIImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/business/PermissionBitAPIImpl.java @@ -6,6 +6,8 @@ import com.dotcms.api.system.event.Visibility; import com.dotcms.business.CloseDBIfOpened; import com.dotcms.business.WrapInTransaction; +import com.dotcms.contenttype.model.field.HostFolderField; +import com.dotcms.contenttype.model.type.ContentType; import com.dotcms.repackage.com.google.common.annotations.VisibleForTesting; import com.dotmarketing.beans.Host; import com.dotmarketing.beans.Inode; @@ -691,6 +693,40 @@ private boolean checkIfContentletTypeHasEditPermissions(final Permissionable per doesUserHavePermission(Contentlet.class.cast(permissionable).getContentType(), PermissionAPI.PERMISSION_EDIT_PERMISSIONS, user):false; } + + @Override + public boolean doesUserHavePermission(final ContentType permissionable, final int permissionType, final User user) throws DotDataException { + + return doesUserHavePermission(permissionable, permissionType, user, true); + } + + @Override + public boolean doesUserHavePermission(final ContentType type, final int permissionType, + final User user, final boolean respectFrontendRoles) throws DotDataException { + + // try the legacy way + final boolean hasPermission = this.doesUserHavePermission((Permissionable) type, permissionType, user, respectFrontendRoles); + + // if the user does not have permission, check if the type allows CMS owner + if (!hasPermission) { + + final Role cmsOwnerRole = Try.of(() -> APILocator.getRoleAPI().loadCMSOwnerRole()) + .getOrElseThrow(e -> new DotRuntimeException(e.getMessage(), e)); + + final List contentTypePermissions = getPermissions(type, true); + for(final Permission contentTypePermission : contentTypePermissions) { + if (user.isBackendUser() && contentTypePermission.getRoleId().equals(cmsOwnerRole.getId())) { + + if (type.fields(HostFolderField.class).isEmpty() && Host.SYSTEM_HOST.equals(type.host())) { + return true; + } + } + } + } + + return hasPermission; + } // doesUserHavePermission + /* (non-Javadoc) * @see com.dotmarketing.business.PermissionFactory#assignPermissions * @deprecated Use save(permission) instead. @@ -1687,6 +1723,11 @@ private List getNewPermissions(Permissionable parent, Permissionable Host.class.getCanonicalName() ); + final Set ContentTypeInheritableClasses = Sets.newHashSet( + Contentlet.class.getCanonicalName() + ); + + final Set classesToIgnoreHost = Sets .newHashSet(Category.class.getCanonicalName()); @@ -1710,11 +1751,14 @@ private List getNewPermissions(Permissionable parent, Permissionable for (final Permission permission : permissions) { - if (finalPermissionableType.equals(Folder.class.getCanonicalName()) - && classesToIgnoreFolder.contains(permission.getType())) { + if (finalPermissionableType.equals(Structure.class.getCanonicalName()) + && !ContentTypeInheritableClasses.contains(permission.getType())) { continue; } - + if (finalPermissionableType.equals(Folder.class.getCanonicalName()) + && classesToIgnoreFolder.contains(permission.getType())) { + continue; + } if (finalPermissionableType.equals(Host.class.getCanonicalName()) && classesToIgnoreHost.contains(permission.getType())) { continue; diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 0c8282c30b0f..95b74460765f 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3026,6 +3026,7 @@ permissions-not-editable-for-role=Permissions are not editable for this Role Permissions-on-Children=Select what permissions this Role will have on the following children: Permissions-on-Children1=Select what permissions Permissions-on-Children2=will have on the following children: +permissions-on-contentType-children=These permissions will be inherited IF the content lives on the system host or there are no other inheritable permisions on the content's site or folder. permissions-saved=Permissions Saved permissions=Permissions Permissions=Permissions diff --git a/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_contentType_entry.html b/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_contentType_entry.html new file mode 100644 index 000000000000..c2fbd6d21642 --- /dev/null +++ b/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_contentType_entry.html @@ -0,0 +1,14 @@ + + + + + + + + + + + + + +
    ${contentWillInherit} (Children)NA
    ${permissionsOnContentTypeChildren}
    \ No newline at end of file diff --git a/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_entry.html b/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_entry.html index eddd7442e64a..4c3f851ca187 100644 --- a/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_entry.html +++ b/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_entry.html @@ -53,7 +53,8 @@ ${structureWillInherit} - NA + + diff --git a/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_tab_js_inc.jsp b/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_tab_js_inc.jsp index 48536677473c..5b25663c045b 100644 --- a/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_tab_js_inc.jsp +++ b/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_tab_js_inc.jsp @@ -33,11 +33,13 @@ Structure hostStrucuture = CacheLocator.getContentTypeCache().getStructureByVelocityVarName("Host"); Contentlet contentletAux = ((Contentlet)request.getAttribute(com.dotmarketing.util.WebKeys.CONTENTLET_EDIT)); %> + var languageId = '<%= ((UtilMethods.isSet(contentletAux) && UtilMethods.isSet(contentletAux.getLanguageId())) ? contentletAux.getLanguageId() : "") %>'; var assetId = '<%= asset.getPermissionId() %>'; var assetType = '<%= ((asset instanceof Contentlet) && ((Contentlet)asset).getStructureInode().equals(hostStrucuture.getInode()))?Host.class.getName():asset.getClass().getName() %>'; var isParentPermissionable = <%= (asset.isParentPermissionable()) %>; var isFolder = <%= (asset instanceof Folder) %>; + var isContentType = <%= (asset instanceof Structure ) %>; var isHost = <%= (asset instanceof Host) || ((asset instanceof Contentlet) && ((Contentlet)asset).getStructureInode().equals(hostStrucuture.getInode())) %>; var doesUserHavePermissionsToEdit = <%= permAPI.doesUserHavePermission(asset, PermissionAPI.PERMISSION_EDIT_PERMISSIONS, user) %>; var isNewAsset = assetId == 0 || assetId == '' || !assetId; @@ -68,6 +70,8 @@ var contentWillInheritMsg = '<%= UtilMethods.escapeSingleQuotes(LanguageUtil.get(pageContext, "Content-Files")) %>'; var permissionsOnChildrenMsg1 = '<%= UtilMethods.escapeSingleQuotes(LanguageUtil.get(pageContext, "Permissions-on-Children1")) %>'; var permissionsOnChildrenMsg2 = '<%= UtilMethods.escapeSingleQuotes(LanguageUtil.get(pageContext, "Permissions-on-Children2")) %>'; + var permissionsOnContentTypeChildren = '<%= UtilMethods.escapeSingleQuotes(LanguageUtil.get(pageContext, "permissions-on-contentType-children")) %>'; + var structureWillInheritMsg = '<%= UtilMethods.escapeSingleQuotes(LanguageUtil.get(pageContext, "Structure")) %>'; var noPermissionsSavedMsg = '<%= UtilMethods.escapeSingleQuotes(LanguageUtil.get(pageContext, "no-permissions-saved")) %>'; var categoriesWillInheritMsg = '<%= UtilMethods.escapeSingleQuotes(LanguageUtil.get(pageContext, "Category")) %>'; @@ -76,8 +80,10 @@ //HTML Templates var inheritedSourcesTemplate = ' ${path}'; var titleTemplateString = dojo._getText('/html/portlet/ext/common/edit_permissions_accordion_title.html'); - - if(isFolder){ + if(isContentType){ + var contentTemplateString = dojo._getText('/html/portlet/ext/common/edit_permissions_accordion_contentType_entry.html'); + } + else if(isFolder){ var contentTemplateString = dojo._getText('/html/portlet/ext/common/edit_permissions_accordion_folder_entry.html'); } else if(isHost){ @@ -193,7 +199,7 @@ var totalCollapsedHeight = 0; dojo.forEach(this.getChildren(), function(child){ totalCollapsedHeight += child._buttonWidget.getTitleHeight(); - if((!isFolder && !isHost) || (inheritingPermissions)) { + if((!isFolder && !isHost && !isContentType) || (inheritingPermissions)) { dojo.style(child.containerNode, { padding: '0' }); } }); @@ -202,6 +208,8 @@ this._verticalSpace = 280; }else if (isFolder && !inheritingPermissions) { this._verticalSpace = 200; + }else if (isContentType && !inheritingPermissions) { + this._verticalSpace = 100; }else { this._verticalSpace = 0; } @@ -254,7 +262,7 @@ } adjustAccordionHeigth(); - if(!inheritingPermissions && (isHost || isFolder)){ + if(!inheritingPermissions && (isHost || isFolder || isContentType)){ dojo.query(".accordionEntry").forEach(function(node, index, arr){ node.className = "permissionTable"; }); @@ -392,7 +400,7 @@ var role = currentPermissions[i]; var rolePermission = { roleId: role.id } rolePermission.individualPermission = retrievePermissionChecks(role.id); - if(isFolder || isHost) { + if(isFolder || isHost ) { rolePermission.foldersPermission = retrievePermissionChecks(role.id, 'folders'); rolePermission.containersPermission = retrievePermissionChecks(role.id, 'containers'); rolePermission.templatesPermission = retrievePermissionChecks(role.id, 'templates'); @@ -404,6 +412,9 @@ rolePermission.categoriesPermissions = retrievePermissionChecks(role.id, 'categories'); rolePermission.rulesPermissions = retrievePermissionChecks(role.id, 'rules'); } + if(isContentType){ + rolePermission.contentPermission = retrievePermissionChecks(role.id, 'content'); + } dojo.forEach(rolePermission, function(value){ console.log("rolePermission: " + value); @@ -720,8 +731,7 @@ } function permissionsIndividually () { - if(assetType == 'com.dotmarketing.portlets.folders.model.Folder' || - assetType == 'com.dotmarketing.beans.Host') { + if(isFolder || isHost || isContentType) { dijit.byId('savingPermissionsDialog').show(); changesMadeToPermissions=false; permissionsLoaded = false; @@ -771,14 +781,19 @@ var totalCollapsedHeight = 0; dojo.forEach(this.getChildren(), function(child){ totalCollapsedHeight += child._buttonWidget.getTitleHeight(); - if((!isFolder && !isHost)) { + if((!isFolder && !isHost && !isContentType)) { dojo.style(child.containerNode, { padding: '0' }); } }); var mySize = this._contentBox; if(isFolder || isHost) { this._verticalSpace = 200; - } else { + } + else if (isContentType){ + this._verticalSpace = 120; + } + + else { this._verticalSpace = 0; } @@ -895,8 +910,6 @@ role["publish-permission-style"] = 'display:none'; } else if(assetType == 'com.dotmarketing.beans.Host') { role["publish-permission-style"] = 'display:none'; - } else if(assetType == 'com.dotmarketing.portlets.structure.model.Structure') { - role["add-children-permission-style"] = 'display: none' } else if(assetType == 'com.dotmarketing.portlets.categories.model.Category') { role["publish-permission-style"] = 'display:none'; role["add-children-permission-style"] = 'display: none' @@ -926,6 +939,7 @@ role.contentWillInherit = contentWillInheritMsg; role.permissionsOnChildren1=permissionsOnChildrenMsg1; role.permissionsOnChildren2=permissionsOnChildrenMsg2; + role.permissionsOnContentTypeChildren=permissionsOnContentTypeChildren; role.structureWillInherit = structureWillInheritMsg; role.categoriesWillInherit = categoriesWillInheritMsg; role.rulesWillInherit = rulesWillInheritMsg; From b7e5927814ebac02e5e37d427cf9d3be9af799ad Mon Sep 17 00:00:00 2001 From: Fabrizzio Araya <37148755+fabrizzio-dotCMS@users.noreply.github.com> Date: Fri, 26 May 2023 09:20:05 -0600 Subject: [PATCH 32/63] Issue 25008 dateformat on content resource (#25017) * #25008 adding a strategy to normilize date, date-time, and time fields read from the contetlet as json * #25008 adding test --- .../transform/ContentletTransformerTest.java | 70 +++++++++++++++++++ .../transform/DotTransformerBuilder.java | 4 +- .../DateTimeFieldsToTimeStampStrategy.java | 56 +++++++++++++++ .../strategy/StrategyResolverImpl.java | 3 +- .../transform/strategy/TransformOptions.java | 3 +- 5 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DateTimeFieldsToTimeStampStrategy.java diff --git a/dotCMS/src/integration-test/java/com/dotmarketing/portlets/contentlet/transform/ContentletTransformerTest.java b/dotCMS/src/integration-test/java/com/dotmarketing/portlets/contentlet/transform/ContentletTransformerTest.java index 3318ca37cc60..c735643a47bb 100644 --- a/dotCMS/src/integration-test/java/com/dotmarketing/portlets/contentlet/transform/ContentletTransformerTest.java +++ b/dotCMS/src/integration-test/java/com/dotmarketing/portlets/contentlet/transform/ContentletTransformerTest.java @@ -67,6 +67,7 @@ import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; import com.dotmarketing.util.UUIDGenerator; +import com.dotmarketing.util.json.JSONObject; import com.liferay.portal.model.User; import com.liferay.util.EncryptorException; import com.liferay.util.StringPool; @@ -82,6 +83,10 @@ import java.io.ObjectOutputStream; import java.nio.file.Path; import java.security.Key; +import java.sql.Timestamp; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.time.Instant; import java.util.ArrayList; import java.util.Date; import java.util.EnumSet; @@ -864,4 +869,69 @@ private Contentlet readSerializedContentlet(final File file) throws IOException, } } + /** + * Given Scenario: This tests that the transformer used to handle serialization for the legacy content-resource is configured properly + * to handle the date formats returned from the regular database columns and also the fields loaded from the contentlet-as-json column + * Expected Result: The transformer instantiated through contentResourceOptions method should be able to convert from Date to Timestamp which the expected datatype used to feed JSONObject + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + public void Transformer_content_Resource_Date_Formats_Test() + throws Exception { + + final ContentType contentType = TestDataUtils.newContentTypeFieldTypesGalore(); + final ContentletDataGen contentletDataGen = new ContentletDataGen(contentType.inode()) + .setProperty("title", "Bicycle") + .setProperty("timeField", new Date()) + .setProperty("dateField", new Date()) + .setProperty("dateTimeField", new Date()); + final Contentlet contentlet = contentletDataGen.nextPersisted(); + + Assert.assertTrue(contentlet.getMap().get("timeField") instanceof Date); + Assert.assertTrue(contentlet.getMap().get("dateField") instanceof Date); + Assert.assertTrue(contentlet.getMap().get("dateTimeField") instanceof Date); + + final DotContentletTransformer transformer = new DotTransformerBuilder() + .contentResourceOptions(true) + .content(contentlet).build(); + + final Map map = transformer.toMaps().get(0); + + Assert.assertTrue(map.get("timeField") instanceof Timestamp); + Assert.assertTrue(map.get("dateField") instanceof Timestamp); + Assert.assertTrue(map.get("dateTimeField") instanceof Timestamp); + + final Map printableMap = ContentletUtil.getContentPrintableMap( + APILocator.systemUser(), contentlet); + + Assert.assertTrue(printableMap.get("timeField") instanceof Timestamp); + Assert.assertTrue(printableMap.get("dateField") instanceof Timestamp); + Assert.assertTrue(printableMap.get("dateTimeField") instanceof Timestamp); + + //This part simulates the JSON rendering that takes place in the ContentResource + + final JSONObject object = new JSONObject() + .put("timeField", map.get("timeField")) + .put("dateField", map.get("dateField")) + .put("dateTimeField", map.get("dateTimeField") + ); + + Assert.assertTrue(isValidStringDateISO8601(object.get("timeField").toString())); + Assert.assertTrue(isValidStringDateISO8601(object.get("dateField").toString())); + Assert.assertTrue(isValidStringDateISO8601(object.get("dateTimeField").toString())); + + } + + /** + * Utitlity method to validate a string date against the ISO8601 format + * @param dateString + * @return + */ + public static boolean isValidStringDateISO8601(final String dateString) { + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); + dateFormat.setLenient(false); // Strict date parsing + return null != Try.of(()-> dateFormat.parse(dateString)).getOrElse((Date)null); + } + } diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/DotTransformerBuilder.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/DotTransformerBuilder.java index cfad1aef9612..e5aff67f5822 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/DotTransformerBuilder.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/DotTransformerBuilder.java @@ -5,6 +5,7 @@ import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.CATEGORIES_INFO; import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.CATEGORIES_NAME; import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.CATEGORIES_VIEW; +import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.DATETIME_FIELDS_TO_TIMESTAMP; import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.FILEASSET_VIEW; import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.IDENTIFIER_VIEW; import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.COMMON_PROPS; @@ -198,7 +199,8 @@ public DotTransformerBuilder dotAssetOptions(){ */ public DotTransformerBuilder contentResourceOptions(final boolean allCategoriesInfo){ optionsHolder.clear(); - optionsHolder.addAll(EnumSet.of(COMMON_PROPS, CONSTANTS, VERSION_INFO, LOAD_META, BINARIES, CATEGORIES_NAME)); + optionsHolder.addAll(EnumSet.of(COMMON_PROPS, CONSTANTS, VERSION_INFO, LOAD_META, BINARIES, CATEGORIES_NAME, + DATETIME_FIELDS_TO_TIMESTAMP)); if(allCategoriesInfo){ optionsHolder.remove(CATEGORIES_NAME); optionsHolder.add(CATEGORIES_INFO); diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DateTimeFieldsToTimeStampStrategy.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DateTimeFieldsToTimeStampStrategy.java new file mode 100644 index 000000000000..9813fc4d39d9 --- /dev/null +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DateTimeFieldsToTimeStampStrategy.java @@ -0,0 +1,56 @@ +package com.dotmarketing.portlets.contentlet.transform.strategy; + +import com.dotcms.api.APIProvider; +import com.dotcms.contenttype.model.field.DateField; +import com.dotcms.contenttype.model.field.DateTimeField; +import com.dotcms.contenttype.model.field.Field; +import com.dotcms.contenttype.model.field.TimeField; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.liferay.portal.model.User; +import java.sql.Timestamp; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * This strategy will transform all DateTime fields into Timestamps. + * We had an issue where old ContentResource would show Datetime in an incorrect format. + * The error was originated by the fields loaded from contentlet as json which are mapped into a Date object. + * But the JSONObject formats Dates and TimeStamps differently. + */ +public class DateTimeFieldsToTimeStampStrategy extends AbstractTransformStrategy { + + DateTimeFieldsToTimeStampStrategy(APIProvider toolBox) { + super(toolBox); + } + + @Override + protected Map transform(Contentlet source, Map map, + Set options, User user) + throws DotDataException, DotSecurityException { + final ContentType contentType = source.getContentType(); + convertFieldsToTimestamp(contentType.fields(TimeField.class), map); + convertFieldsToTimestamp(contentType.fields(DateField.class), map); + convertFieldsToTimestamp(contentType.fields(DateTimeField.class), map); + return map; + } + + /** + * This method will convert all Date fields into Timestamps. + * @param fields list of fields to convert + * @param map map containing the values to convert + */ + private void convertFieldsToTimestamp(final List fields, Map map){ + fields.forEach(field -> { + final Object o = map.get(field.variable()); + if (o instanceof Date) { + map.put(field.variable(), new Timestamp(((Date) o).getTime())); + } + }); + } + +} diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/StrategyResolverImpl.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/StrategyResolverImpl.java index eea1d0d9e908..66b1528fd9d9 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/StrategyResolverImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/StrategyResolverImpl.java @@ -69,7 +69,8 @@ public StrategyResolverImpl(final APIProvider toolBox) { SITE_VIEW, ()-> new SiteViewStrategy(toolBox), STORY_BLOCK_VIEW,()-> new StoryBlockViewStrategy(toolBox), RENDER_FIELDS, ()-> new RenderFieldStrategy(toolBox), - JSON_VIEW, ()-> new JSONViewStrategy(toolBox) + JSON_VIEW, ()-> new JSONViewStrategy(toolBox), + DATETIME_FIELDS_TO_TIMESTAMP, ()-> new DateTimeFieldsToTimeStampStrategy(toolBox) ), ()-> new DefaultTransformStrategy(toolBox) ); diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/TransformOptions.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/TransformOptions.java index 8591a359384d..4a11baf89f24 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/TransformOptions.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/TransformOptions.java @@ -29,7 +29,8 @@ public enum TransformOptions { AVOID_MAP_SUFFIX_FOR_VIEWS, RENDER_FIELDS, // will velocity-render the render-able fields - JSON_VIEW; + JSON_VIEW, + DATETIME_FIELDS_TO_TIMESTAMP; private boolean defaultProperty; From 1cf10a7927ff1998813427e111f912bca76fcf3a Mon Sep 17 00:00:00 2001 From: Victor Alfaro Date: Fri, 26 May 2023 16:22:49 -0600 Subject: [PATCH 33/63] #24903: supporting credibility interval and risk for variant results (#24963) * #24903: supporting credibility interval and risk for variant results and making prior data more relevant * Applying feedback --- .../ExperimentAPIImpIntegrationTest.java | 2 + .../analytics/bayesian/BayesianAPI.java | 76 +- .../analytics/bayesian/BayesianAPIImpl.java | 824 ++++++++++++------ .../analytics/bayesian/BetaDistribution.java | 121 --- .../dotcms/analytics/bayesian/BetaModel.java | 50 -- .../beta/BetaDistributionWrapper.java | 97 +++ .../bayesian/model/AbstractBayesianInput.java | 6 +- .../model/AbstractBayesianPriors.java | 9 +- .../model/AbstractBayesianResult.java | 10 +- ....java => AbstractCredibilityInterval.java} | 14 +- .../model/AbstractDifferenceData.java | 8 +- .../bayesian/model/AbstractQuantilePair.java | 4 + .../bayesian/model/AbstractSampleData.java | 4 + .../bayesian/model/AbstractSampleGroup.java | 4 + ...java => AbstractVariantBayesianInput.java} | 4 +- .../bayesian/model/AbstractVariantResult.java | 41 + .../analytics/helper/BayesianHelper.java | 23 +- .../business/ExperimentsAPIImpl.java | 2 +- .../bayesian/BayesianAPIImplTest.java | 122 +-- ....java => BetaDistributionWrapperTest.java} | 7 +- .../analytics/helper/BayesianHelperTest.java | 4 +- 21 files changed, 827 insertions(+), 605 deletions(-) delete mode 100644 dotCMS/src/main/java/com/dotcms/analytics/bayesian/BetaDistribution.java delete mode 100644 dotCMS/src/main/java/com/dotcms/analytics/bayesian/BetaModel.java create mode 100644 dotCMS/src/main/java/com/dotcms/analytics/bayesian/beta/BetaDistributionWrapper.java rename dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/{AbstractVariantProbability.java => AbstractCredibilityInterval.java} (64%) rename dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/{AbstractVariantInputPair.java => AbstractVariantBayesianInput.java} (86%) create mode 100644 dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractVariantResult.java rename dotCMS/src/test/java/com/dotcms/analytics/bayesian/{BetaDistributionTest.java => BetaDistributionWrapperTest.java} (87%) diff --git a/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentAPIImpIntegrationTest.java b/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentAPIImpIntegrationTest.java index 8228dc0b8327..f42b29cc1b94 100644 --- a/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentAPIImpIntegrationTest.java +++ b/dotCMS/src/integration-test/java/com/dotcms/experiments/business/ExperimentAPIImpIntegrationTest.java @@ -90,6 +90,7 @@ import net.bytebuddy.utility.RandomString; import org.junit.Assert; import org.junit.BeforeClass; +import org.junit.Ignore; import org.junit.Test; /** @@ -2611,6 +2612,7 @@ public void test_calcBayesian_AOverB_ended() throws DotDataException, DotSecurit * Should: calculate the probability that B beats A is 0.99 */ @Test + @Ignore public void test_calcBayesian_ABC() throws DotDataException, DotSecurityException { final Host host = new SiteDataGen().nextPersisted(); final Template template = new TemplateDataGen().host(host).nextPersisted(); diff --git a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/BayesianAPI.java b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/BayesianAPI.java index e9eb1d0b8568..a6eb98c23d32 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/BayesianAPI.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/BayesianAPI.java @@ -1,80 +1,56 @@ package com.dotcms.analytics.bayesian; - import com.dotcms.analytics.bayesian.model.BayesianInput; import com.dotcms.analytics.bayesian.model.BayesianPriors; import com.dotcms.analytics.bayesian.model.BayesianResult; +import com.dotcms.analytics.bayesian.model.VariantResult; import com.dotcms.variant.VariantAPI; +import org.apache.commons.numbers.gamma.LogBeta; +import java.util.Comparator; import java.util.List; +import java.util.function.BiFunction; import java.util.function.Predicate; /** - * Bayesian calculation API. + * Bayesian calculation API that defines the methods to calculate the Bayesian results from a provided set of inputs. + * Basically iw defines one method that receive a BayesianInput object and returns a BayesianResult object and a set of + * constants required to be consumed by implementing classes. * * @author vico */ public interface BayesianAPI { + double DEFAULT_ALPHA = 1.0; + double DEFAULT_BETA = 1.0; + double LOWER_BOUND_PROBABILITY = 0.025; + double UPPER_BOUND_PROBABILITY = 0.975; + double BETA_DIST_DEFAULT = (double) 3 / 8; + double HALF = 0.5; + double TIE_COMPARE_DELTA = 0.01; + double[] QUANTILES = { 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.975, 0.99 }; String TIE = "TIE"; String NONE = "NONE"; - BayesianPriors NULL_PRIORS = BayesianPriors.builder().alpha(null).beta(null).build(); - Predicate DEFAULT_VARIANT_FILTER = variant -> variant.equals(VariantAPI.DEFAULT_VARIANT.name()); - Predicate OTHER_THAN_DEFAULT_VARIANT_FILTER = variant -> DEFAULT_VARIANT_FILTER.negate().test(variant); + BayesianPriors DEFAULT_PRIORS = BayesianPriors.builder() + .alpha(DEFAULT_ALPHA) + .beta(DEFAULT_BETA) + .build(); BayesianResult NOOP_RESULT = BayesianResult.builder() .value(0.0) - .probabilities(List.of()) + .results(List.of()) .suggestedWinner(NONE) .build(); + BiFunction LOG_BETA_FN = LogBeta::value; + Comparator VARIANT_RESULT_COMPARATOR = Comparator.comparingDouble(VariantResult::probability); + Predicate DEFAULT_VARIANT_FILTER = variant -> variant.equals(VariantAPI.DEFAULT_VARIANT.name()); + Predicate OTHER_THAN_DEFAULT_VARIANT_FILTER = variant -> DEFAULT_VARIANT_FILTER.negate().test(variant); /** - * Calculates probability that B (Test) beats A (Control) based on this pseudo (Julia) code: - * - *
    -     *     𝛼𝐴 is one plus the number of successes for A
    -     *     𝛽𝐴 is one plus the number of failures for A
    -     *     𝛼𝐵 is one plus the number of successes for B
    -     *     𝛽𝐵 is one plus the number of failures for B
    -     *
    -     *     function probability_B_beats_A(α_A, β_A, α_B, β_B)
    -     *         total = 0.0
    -     *         for i = 0:(α_B-1)
    -     *             total += exp(logbeta(α_A+i, β_B+β_A)
    -     *                 - log(β_B+i) - logbeta(1+i, β_B) - logbeta(α_A, β_A))
    -     *         end
    -     *         return total
    -     *     end
    -     * 
    - * + * Calculates probability that each variant beats the control including a credibility interval and calculated risk. * Instead of using the provided logBeta function from Apache Commons Math we will use our own implementation: - * {@link BetaDistribution} which provides en density function {@see DotBetaDistribution.pdf()}. * * @param input {@link BayesianInput} instance */ - BayesianResult calcProbBOverA(BayesianInput input); - - /** - * Calculates probability that C (Test) beats A (Control) and B (Test) based on this pseudo (Julia) code: - * - *
    -     *    function probability_C_beats_A_and_B(α_A, β_A, α_B, β_B, α_C, β_C)
    -     *        total = 0.0
    -     *        for i = 0:(α_A-1)
    -     *            for j = 0:(α_B-1)
    -     *                total += exp(logbeta(α_C+i+j, β_A+β_B+β_C) - log(β_A+i) - log(β_B+j)
    -     *                    - logbeta(1+i, β_A) - logbeta(1+j, β_B) - logbeta(α_C, β_C))
    -     *            end
    -     *        end
    -     *        return (1 - probability_B_beats_A(α_C, β_C, α_A, β_A)
    -     *            - probability_B_beats_A(α_C, β_C, α_B, β_B) + total)
    -     *    end
    -     * 
    - * - * Instead of using the provided logBeta function from Apache Commons Math we will use our own implementation: - * {@link com.dotcms.analytics.bayesian.BetaDistribution} which provides en density function {@see DotBetaDistribution.pdf()}. - * - * @param input {@link com.dotcms.analytics.bayesian.model.BayesianInput} instance - */ - BayesianResult calcProbABC(BayesianInput input); + BayesianResult doBayesian(BayesianInput input); } diff --git a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/BayesianAPIImpl.java b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/BayesianAPIImpl.java index fc0eb54b15d9..01b62475d153 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/BayesianAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/BayesianAPIImpl.java @@ -1,37 +1,41 @@ package com.dotcms.analytics.bayesian; - +import com.dotcms.analytics.bayesian.beta.BetaDistributionWrapper; import com.dotcms.analytics.bayesian.model.ABTestingType; import com.dotcms.analytics.bayesian.model.BayesianInput; +import com.dotcms.analytics.bayesian.model.BayesianPriors; import com.dotcms.analytics.bayesian.model.BayesianResult; +import com.dotcms.analytics.bayesian.model.CredibilityInterval; import com.dotcms.analytics.bayesian.model.DifferenceData; import com.dotcms.analytics.bayesian.model.QuantilePair; import com.dotcms.analytics.bayesian.model.SampleData; import com.dotcms.analytics.bayesian.model.SampleGroup; -import com.dotcms.analytics.bayesian.model.VariantInputPair; -import com.dotcms.analytics.bayesian.model.VariantProbability; +import com.dotcms.analytics.bayesian.model.VariantBayesianInput; +import com.dotcms.analytics.bayesian.model.VariantResult; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; import com.google.common.collect.ImmutableMap; -import org.apache.commons.numbers.gamma.LogBeta; -import org.jetbrains.annotations.NotNull; +import io.vavr.Lazy; +import org.apache.commons.math3.analysis.UnivariateFunction; +import org.apache.commons.math3.analysis.integration.SimpsonIntegrator; +import org.apache.commons.math3.analysis.integration.UnivariateIntegrator; +import org.apache.commons.math3.distribution.BetaDistribution; +import org.apache.commons.math3.stat.descriptive.SummaryStatistics; +import org.apache.commons.math3.util.FastMath; +import org.apache.commons.math3.util.Pair; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.function.BiFunction; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.Stream; - /** * Bayesian calculation API. * So far it implements on method that calculates the probability for an A/B test that B (test) beats A (control). @@ -40,371 +44,592 @@ */ public class BayesianAPIImpl implements BayesianAPI { - private static final double DEFAULT_VALUE = (double) 3 / 8; - private static final double[] QUANTILES = { 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.975, 0.99 }; - private static final int SAMPLE_SIZE = Config.getIntProperty("BETA_DISTRIBUTION_SAMPLE_SIZE", 1000); - private static final BiFunction LOG_BETA_FN = LogBeta::value; - private static final double HALF = 0.5; - private static final double TIE_COMPARE_DELTA = 0.01; - private static final Comparator VARIANT_PROBABILITY_COMPARATOR = - Comparator.comparingDouble(VariantProbability::value); + private final Lazy betaDistSamples = + Lazy.of(() -> Config.getIntProperty("BETA_DISTRIBUTION_SAMPLE_SIZE", 1000)); /** * {@inheritDoc} */ @Override - public BayesianResult calcProbBOverA(final BayesianInput input) { + public BayesianResult doBayesian(final BayesianInput input) { // validate input - final Optional noopResult = detectNoop(input); - if (noopResult.isPresent()) { - return noopResult.get(); - } - - final boolean priorPresent = isPriorPresent(input); - final BetaDistribution controlData = priorPresent ? new BetaModel(input).distribution() : null; - final BetaDistribution testData = priorPresent ? new BetaModel(input).distribution() : null; - final DifferenceData differenceData = generateDifferenceData(controlData, testData); - final VariantInputPair control = input.control(); - final VariantInputPair test = input.variantPairs().get(0); - // call calculation - final double value = calcABTesting(control, test); - - BayesianResult.Builder builder = BayesianResult.builder() - .value(value) - .probabilities(List.of( - toProbability(input.control(), 1 - value), - toProbability(input.variantPairs().get(0), value))) - .suggestedWinner(suggestABWinner(value, control.variant(), input.variantPairs().get(0).variant())); - if (priorPresent) { - builder = builder - .distributionPdfs(calcDistributionsPdfs(controlData, testData)) - .differenceData(differenceData) - .quantiles(calcQuantiles( - differenceData.differences(), - input.priors().alpha(), - input.priors().beta())); - } - - return builder.build(); + return noopFallback(input) + .orElseGet(() -> input.type() == ABTestingType.AB + ? getAbBayesianResult(input) + : getAbcBayesianResult(input)); } /** - * {@inheritDoc} + * Calculates probability that B (Test) beats A (Control) and B (Test) just as main. + * + * @param input Bayesian input + * @return Bayesian result */ - @Override - public BayesianResult calcProbABC(final BayesianInput input) { - // validate input - final Optional noopResult = detectNoop(input); - if (noopResult.isPresent()) { - return noopResult.get(); - } - - final List results = calcABCTesting( - input.control(), - input.variantPairs().get(0), - input.variantPairs().get(1)); + private BayesianResult getAbBayesianResult(final BayesianInput input) { + final VariantBayesianInput control = input.control(); + final VariantBayesianInput test = input.variantPairs().get(0); + final BayesianPriors priors = input.priors().get(0); + + final double testGain = calcProbabilityBBeatsA(priors, control, test); + final double controlGain = 1.0 - testGain; + final double controlConversionRate = calcConversionRate(control); + final double testConversionRate = calcConversionRate(test); + final BetaDistributionWrapper controlBeta = BetaDistributionWrapper.create( + priors.alpha() + control.successes(), + priors.beta() + control.failures()); + final BetaDistributionWrapper testBeta = BetaDistributionWrapper.create( + priors.alpha() + test.successes(), + priors.beta() + test.failures()); + final DifferenceData differenceData = generateDifferenceData(controlBeta, testBeta); return BayesianResult.builder() - .value(results.get(0).value()) - .probabilities(results) - .suggestedWinner(suggestABCWinner(results)) + .value(testGain) + .results(List.of( + toResult( + control, + controlGain, + calc95Credibility(priors, control), + controlConversionRate, + null, + calcRisk(controlBeta, testBeta)), + toResult( + test, + testGain, + calc95Credibility(priors, test), + testConversionRate, + calcMedianGrowth(controlConversionRate, testConversionRate), + calcRisk(testBeta, controlBeta)))) + .suggestedWinner(suggestAbWinner(testGain, control.variant(), test.variant())) + .distributionPdfs(calcDistributionsPdfs(control.variant(), controlBeta, test.variant(), testBeta)) + .differenceData(differenceData) + .quantiles(calcQuantiles(differenceData.differences(), priors.alpha(), priors.beta())) .build(); } /** - * Resolves which variant could be considered as the winner of the test. + * Calculates conversion rate for a provided variant. * - * @param value calculated probability that B (Test) beats A (Control) - * @param control control variant - * @param test test variant - * @return winner variant name + * @param input variant input + * @return conversion rate */ - @NotNull - private String suggestABWinner(final double value, final String control, final String test) { - if (Double.compare(HALF, value) == 0 || Math.abs(HALF - value) <= TIE_COMPARE_DELTA) { - return TIE; - } - - return value < 0.5 ? control : test; + private double calcConversionRate(final VariantBayesianInput input) { + return (double) input.successes() / (input.successes() + input.failures()); } /** - * Resolves which variant could be considered as the winner of the test. + * Calculates test conversion rate growth against control's. * - * @param probabilities list of probabilities - * @return winner variant name + * @param controlConversionRate control conversion rate + * @param testConversionRate test conversion rate + * @return test conversion rate growth against control's */ - @NotNull - private String suggestABCWinner(final List probabilities) { - final VariantProbability controlProbability = probabilities.get(0); - if (probabilities - .stream() - .allMatch(variantProbability -> variantProbability.value() == controlProbability.value())) { - return TIE; - } + private double calcMedianGrowth(final double controlConversionRate, final double testConversionRate) { + return (testConversionRate - controlConversionRate) / testConversionRate; + } - return probabilities - .stream() - .max(VARIANT_PROBABILITY_COMPARATOR) - .map(VariantProbability::variant) - .orElse(NONE); + /** + * Executes Apache's Common Math log beta function for provided alpha and beta values + * + * @param alpha alpha value + * @param beta beta value + * @return results from log beta function + */ + private double logBeta(final double alpha, final double beta) { + return LOG_BETA_FN.apply(alpha, beta); } /** - * Calculates probability that B (Test) beats A (Control) just as main {@link #calcProbBOverA(BayesianInput)} + * Calculates probability that B (Test) beats A (Control) based on this pseudo (Julia) code: + * + *
    +     *     𝛼𝐴 is one plus the number of successes for A
    +     *     𝛽𝐴 is one plus the number of failures for A
    +     *     𝛼𝐵 is one plus the number of successes for B
    +     *     𝛽𝐵 is one plus the number of failures for B
          *
    -     * @param alphaA control successes
    -     * @param betaA control failures
    -     * @param alphaB test successes
    -     * @param betaB test failures
    -     * @return result representing probability that B beats A
    +     *     function probability_B_beats_A(α_A, β_A, α_B, β_B)
    +     *         total = 0.0
    +     *         for i = 0:(α_B-1)
    +     *             total += exp(logbeta(α_A+i, β_B+β_A)
    +     *                 - log(β_B+i) - logbeta(1+i, β_B) - logbeta(α_A, β_A))
    +     *         end
    +     *         return total
    +     *     end
    +     * 
    + * + * Instead of using the provided logBeta function from Apache Commons Math we will use our own implementation: + * {@link BetaDistributionWrapper} which provides en density function {@see DotBetaDistribution.pdf()}. + * + * @param controlSuccesses number of successes for control + * @param controlFailures number of failures for control + * @param testSuccesses number of successes for test + * @param testFailures number of failures for test */ - private double calcABTesting(final long alphaA, final long betaA, final long alphaB, final long betaB) { + private double calcProbabilityBBeatsA(final double controlSuccesses, + final double controlFailures, + final double testSuccesses, + final double testFailures) { double result = 0.0; - for(int i = 0; i < alphaB; i++) { + for(int i = 0; i < testSuccesses; i++) { result += Math.exp( - logBeta(alphaA + i, betaB + betaA) - - Math.log(betaB + i) - - logBeta(1 + i, betaB) - - logBeta(alphaA, betaA)); + logBeta(controlSuccesses + i, testFailures + controlFailures) + - Math.log(testFailures + i) + - logBeta(1 + i, testFailures) + - logBeta(controlSuccesses, controlFailures)); } return result; } /** - * Calculates probability that B (Test) beats A (Control) just as main {@link #calcProbBOverA(BayesianInput)} + * Calculates probability that B (Test) beats A (Control) * - * @param control successes and failures - * @param test successes and failures - * @return result representing probability that B beats A + * @param priors the priors + * @param control the control + * @param test the test */ - private double calcABTesting(final VariantInputPair control, final VariantInputPair test) { - return calcABTesting( - control.successes() + 1, - control.failures() + 1, - test.successes() + 1, - test.failures() + 1); + private double calcProbabilityBBeatsA(final BayesianPriors priors, + final VariantBayesianInput control, + final VariantBayesianInput test) { + return calcProbabilityBBeatsA( + control.successes() + priors.alpha(), + control.failures() + priors.beta(), + test.successes() + priors.alpha(), + test.failures() + priors.beta()); } /** - * Calculates probability that C (Test) beats A (Control) and B (Test) just as main - * {@link #calcProbABC(BayesianInput)} - * - * @param alphaA control successes - * @param betaA control failures - * @param alphaB test B successes - * @param betaB test B failures - * @param alphaC test C successes - * @param betaC test C failures - * @return a list {@link VariantProbability} representing probability that C beats A and B + * Calculates probability control and tests B and C. + * + * @param input Bayesian input + * @return Bayesian result */ - private Double calcABCTesting(final long alphaA, final long betaA, - final long alphaB, final long betaB, - final long alphaC, final long betaC) { + private BayesianResult getAbcBayesianResult(final BayesianInput input) { + final List results = calcAbcProbabilities( + Pair.create(input.priors().get(0), input.control()), + Pair.create(input.priors().get(1), input.variantPairs().get(0)), + Pair.create(input.priors().get(2), input.variantPairs().get(1))); + + return BayesianResult.builder() + .value(results.get(0).probability()) + .results(results) + .suggestedWinner(suggestAbcWinner(results)) + .build(); + } + + /** + * Calculates probability that C (Test) beats A (Control) and B (Test) based on this pseudo (Julia) code: + * + *
    +     *    function probability_C_beats_A_and_B(α_A, β_A, α_B, β_B, α_C, β_C)
    +     *        total = 0.0
    +     *        for i = 0:(α_A-1)
    +     *            for j = 0:(α_B-1)
    +     *                total += exp(logbeta(α_C+i+j, β_A+β_B+β_C) - log(β_A+i) - log(β_B+j)
    +     *                    - logbeta(1+i, β_A) - logbeta(1+j, β_B) - logbeta(α_C, β_C))
    +     *            end
    +     *        end
    +     *        return (1 - probability_B_beats_A(α_C, β_C, α_A, β_A)
    +     *            - probability_B_beats_A(α_C, β_C, α_B, β_B) + total)
    +     *    end
    +     * 
    + * + * Instead of using the provided logBeta function from Apache Commons Math we will use our own implementation: + * {@link BetaDistributionWrapper} which provides en density function {@see DotBetaDistribution.pdf()}. + * + * @param controlSuccesses control successes + * @param controlFailures control failures + * @param testBSuccesses test B successes + * @param testBFailures test B failures + * @param testCSuccesses test C successes + * @param testCFailures test C failures + * @return probability that C (Test) beats A (Control) and B (Test) + */ + private double calcAbcProbabilities(final double controlSuccesses, + final double controlFailures, + final double testBSuccesses, + final double testBFailures, + final double testCSuccesses, + final double testCFailures) { double total = 0.0; - for(int i = 0; i < alphaA; i++) { - for(int j = 0; j < alphaB; j++) { + for(int i = 0; i < controlSuccesses; i++) { + for(int j = 0; j < testBSuccesses; j++) { total += Math.exp( - logBeta(alphaC + i + j, betaA + betaB + betaC) - - Math.log(betaA + i) - - Math.log(betaB + j) - - logBeta(1 + i, betaA) - - logBeta(1 + j, betaB) - - logBeta(alphaC, betaC)); + logBeta(testCSuccesses + i + j, controlFailures + testBFailures + testCFailures) + - Math.log(controlFailures + i) + - Math.log(testBFailures + j) + - logBeta(1 + i, controlFailures) + - logBeta(1 + j, testBFailures) + - logBeta(testCSuccesses, testCFailures)); } } - final double probAOverC = calcABTesting(alphaC, betaC, alphaA, betaA); - final double probBOverC = calcABTesting(alphaC, betaC, alphaB, betaB); + final double probAOverC = calcProbabilityBBeatsA(testCSuccesses, testCFailures, controlSuccesses, controlFailures); + final double probBOverC = calcProbabilityBBeatsA(testCSuccesses, testCFailures, testBSuccesses, testBFailures); return 1 - probAOverC - probBOverC + total; } /** * Calculates probability that C (Test) beats A (Control) and B (Test) just as main - * {@link #calcProbABC(BayesianInput)} + * {@link #doBayesian(BayesianInput)} * + * @param controlPriors control alpha and beta * @param control successes and failures + * @param testBPriors test B alpha and beta * @param testB successes and failures + * @param testCPriors test C alpha and beta * @param testC successes and failures - * @return a list {@link VariantProbability} representing probability that C beats A and B + * @return a list {@link VariantResult} representing probability that C beats A and B */ - private VariantProbability calcSingleABCTesting(final VariantInputPair control, - final VariantInputPair testB, - final VariantInputPair testC) { - return toProbability( + private VariantResult calcSingleAbcTesting(final BayesianPriors controlPriors, + final VariantBayesianInput control, + final BayesianPriors testBPriors, + final VariantBayesianInput testB, + final BayesianPriors testCPriors, + final VariantBayesianInput testC) { + return toResult( testC, - calcABCTesting( - control.successes() + 1, - control.failures() + 1, - testB.successes() + 1, - testB.failures() + 1, - testC.successes() + 1, - testC.failures() + 1)); + calcAbcProbabilities( + control.successes() + controlPriors.alpha(), + control.failures() + controlPriors.beta(), + testB.successes() + testBPriors.alpha(), + testB.failures() + testBPriors.beta(), + testC.successes() + testCPriors.alpha(), + testC.failures() + testCPriors.beta()), + calc95Credibility(testCPriors, testC), + 0.0, + 0.0, + 0.0); } /** * Calculates probability that C (Test) beats A (Control) and B (Test) just as main - * {@link #calcProbABC(BayesianInput)} + * {@link #doBayesian(BayesianInput)} * - * @param control successes and failures - * @param testB successes and failures - * @param testC successes and failures - * @return a list {@link VariantProbability} representing probability that C beats A and B + * @param controlPair control successes/failures and priors + * @param testBPair test B successes/failures and priors + * @param testCPair test C successes/failures and priors + * @return a list {@link VariantResult} representing probability that C beats A and B */ - private List calcABCTesting(final VariantInputPair control, - final VariantInputPair testB, - final VariantInputPair testC) { + private List calcAbcProbabilities(final Pair controlPair, + final Pair testBPair, + final Pair testCPair) { return Stream .of( - List.of(testC, testB, control), - List.of(control, testC, testB), - List.of(testB, control, testC)) - .map(inputs -> calcSingleABCTesting(inputs.get(0), inputs.get(1), inputs.get(2))) + List.of(testCPair, testBPair, controlPair), + List.of(controlPair, testCPair, testBPair), + List.of(testBPair, controlPair, testCPair)) + .map(inputs -> calcSingleAbcTesting( + inputs.get(0).getFirst(), inputs.get(0).getSecond(), + inputs.get(1).getFirst(), inputs.get(1).getSecond(), + inputs.get(2).getFirst(), inputs.get(2).getSecond())) .collect(Collectors.toList()); } /** - * Converts variant pair information and the calculated probability into {@link VariantProbability} instance. + * Calculates the 95% credibility interval for the given input. * - * @param pair variant pair - * @param value calculated probability - * @return {@link VariantProbability} instance + * @param priorAlpha the prior alpha + * @param priorBeta the prior beta + * @param successes the successes + * @param failures the failures + * @return the 95% credibility interval */ - private VariantProbability toProbability(final VariantInputPair pair, final double value) { - return VariantProbability.builder() - .variant(pair.variant()) - .value(value) - .build(); + private double[] calc95Credibility(final double priorAlpha, + final double priorBeta, + final long successes, + final long failures) { + final BetaDistribution betaDistA = new BetaDistribution(priorAlpha + successes, priorBeta + failures); + final double lowerBound = betaDistA.inverseCumulativeProbability(LOWER_BOUND_PROBABILITY); + final double upperBound = betaDistA.inverseCumulativeProbability(UPPER_BOUND_PROBABILITY); + return new double[] { lowerBound, upperBound }; + } + + /** + * Calculates the 95% credibility interval for the given input. + * + * @param priors the priors + * @param input the input + * @return the 95% credibility interval + */ + private double[] calc95Credibility(final BayesianPriors priors, final VariantBayesianInput input) { + return calc95Credibility(priors.alpha(), priors.beta(), input.successes(), input.failures()); + } + + /** + * Calculates the mean of the beta distribution. + * + * @param priorAlpha the prior alpha + * @param priorBeta the prior beta + * @param successes the number of successes + * @param failures the number of failures + * @return the mean + */ + private double calcMean(final double priorAlpha, + final double priorBeta, + final long successes, + final long failures) { + return (priorAlpha + successes) / (priorAlpha + successes + priorBeta + failures); + } + + /** + * Calculates variance for the beta distribution. + * + * @param priorAlpha the prior alpha + * @param priorBeta the prior beta + * @param successes the number of successes + * @param failures the number of failures + * @return the variance + */ + private double calcVariance(final double priorAlpha, + final double priorBeta, + final long successes, + final long failures) { + return (successes + priorAlpha) + * (failures + priorBeta) + / (FastMath.pow(priorAlpha + successes + priorBeta + failures, 2) + * (priorAlpha + successes + priorBeta + failures + 1)); + } + + /** + * Calculates the risk of the test variant beating the control variant. + * + * @param priorAlpha the prior alpha + * @param priorBeta the prior beta + * @param controlSuccesses the number of successes for the control variant + * @param controlFailures the number of failures for the control variant + * @param testSuccesses the number of successes for the test variant + * @param testFailures the number of failures for the test variant + * @return the risk of the test variant beating the control variant + */ + private double calcRiskCheap(final double priorAlpha, + final double priorBeta, + final long controlSuccesses, + final long controlFailures, + final long testSuccesses, + final long testFailures) { + // CLT approximation for Variants A and B + final double meanA = calcMean(priorAlpha, priorBeta, controlSuccesses, controlFailures); + final double varianceA = calcMean(priorAlpha, priorBeta, controlSuccesses, controlFailures); + final double meanB = calcMean(priorAlpha, priorBeta, testSuccesses, testFailures); + final double varianceB = calcVariance(priorAlpha, priorBeta, testSuccesses, testFailures); + final double meanDifference = meanA - meanB; + final double varianceDifference = varianceA + varianceB; + + // Defining the function to be integrated + UnivariateFunction function = x -> { + double pdf = FastMath.exp(-FastMath.pow(x - meanDifference, 2) / (2 * varianceDifference)) + / FastMath.sqrt(2 * FastMath.PI * varianceDifference); + return x * pdf; + }; + + // Gaussian quadrature integration + final UnivariateIntegrator integrator = new SimpsonIntegrator(); // or any other integrator + return integrator.integrate(1000, function, -15, 15); // adjust parameters as needed + } + + /** + * Calculates the risk of the test variant beating the control variant. + * + * @param controlBeta control variant beta distribution + * @param testBeta test variant beta distribution + * @return risk of the test variant beating the control variant + */ + private double calcRisk(final BetaDistributionWrapper controlBeta, final BetaDistributionWrapper testBeta) { + final int samples = resolveSampleSize(); + final SummaryStatistics stats = new SummaryStatistics(); + + IntStream.range(0, samples).forEach(i -> stats.addValue(controlBeta.rv() - testBeta.rv())); + + return stats.getMean(); + } + + /** + * Calculates the risk of the test variant beating the control variant. + * + * @param priors Bayesian priors + * @param controlInput control variant input + * @param testInput test variant input + * @return risk of the test variant beating the control variant + */ + private double calcRisk(final BayesianPriors priors, + final VariantBayesianInput controlInput, + final VariantBayesianInput testInput) { + return calcRiskCheap( + priors.alpha(), + priors.beta(), + controlInput.successes(), + controlInput.failures(), + testInput.successes(), + testInput.failures()); + } + + /** + * Resolves which variant could be considered as the winner of the test. + * + * @param value calculated probability that B (Test) beats A (Control) + * @param control control variant + * @param test test variant + * @return winner variant name + */ + private String suggestAbWinner(final double value, final String control, final String test) { + if (Double.compare(HALF, value) == 0 || Math.abs(HALF - value) <= TIE_COMPARE_DELTA) { + return TIE; + } + + return value < HALF ? control : test; } /** - * Given a {@link BetaDistribution} instance calculates density (pdf) elements. + * Resolves which variant could be considered as the winner of the test. + * + * @param results list of probabilities + * @return winner variant name + */ + private String suggestAbcWinner(final List results) { + final VariantResult controlResult = results.get(0); + if (results + .stream() + .allMatch(result -> result.probability() == controlResult.probability())) { + return TIE; + } + + return results + .stream() + .max(VARIANT_RESULT_COMPARATOR) + .map(VariantResult::variant) + .orElse(NONE); + } + + /** + * Given a {@link BetaDistributionWrapper} instance calculates density (pdf) elements. * * @param distribution provided beta distribution * @return list of {@link SampleData} instances */ - private List calcPdfElements(final BetaDistribution distribution) { - return Optional - .ofNullable(distribution) - .map(dist -> IntStream - .range(0, SAMPLE_SIZE) - .mapToObj(operand -> { - final double x = (double) operand / SAMPLE_SIZE; - final double temp = dist.pdf(x); - final double y = temp == Double.POSITIVE_INFINITY ? 0 : temp; - return SampleData.builder() - .x(x) - .y(y) - .build(); - }) - .collect(Collectors.toList())) - .orElse(Collections.emptyList()); + private List calcPdfElements(final BetaDistributionWrapper distribution) { + final int sampleSize = resolveSampleSize(); + return IntStream + .range(0, sampleSize) + .mapToObj(operand -> { + final double x = (double) operand / sampleSize; + final double val = distribution.pdf(x); + final double y = val == Double.POSITIVE_INFINITY ? 0 : val; + return SampleData.builder() + .x(x) + .y(y) + .build(); + }) + .collect(Collectors.toList()); } /** - * Given a couple {@link BetaDistribution} objects calculates dendity(PDF) for both control (A) and test (B). + * Given a couple {@link BetaDistributionWrapper} objects calculates density(PDF) for both control (A) and test (B). * * @param controlDistribution provided control beta distribution * @param testDistribution provided test beta distribution * @return {@link SampleGroup} containing each sample list against an identifier */ - private SampleGroup calcDistributionsPdfs(final BetaDistribution controlDistribution, - final BetaDistribution testDistribution) { + private SampleGroup calcDistributionsPdfs(final String control, + final BetaDistributionWrapper controlDistribution, + final String test, + final BetaDistributionWrapper testDistribution) { return SampleGroup.builder() .samples(ImmutableMap.of( - "A", calcPdfElements(controlDistribution), - "B", calcPdfElements(testDistribution))) + control, calcPdfElements(controlDistribution), + test, calcPdfElements(testDistribution))) .build(); } /** - * Given a couple {@link BetaDistribution} objects generates difference between test and control sample data. + * Given a couple {@link BetaDistributionWrapper} objects generates difference between test and control sample data. * * @param controlDistribution provided control beta distribution * @param testDistribution provided test beta distribution * @return {@link DifferenceData} instance with control and test data and its difference */ - private DifferenceData generateDifferenceData(final BetaDistribution controlDistribution, - final BetaDistribution testDistribution) { - final double[] controlData = Optional - .ofNullable(controlDistribution) - .map(control -> control.rvs(SAMPLE_SIZE)) - .orElse(new double[0]); - final double[] testData = Optional - .ofNullable(testDistribution) - .map(test -> test.rvs(SAMPLE_SIZE)) - .orElse(new double[0]); + private DifferenceData generateDifferenceData(final BetaDistributionWrapper controlDistribution, + final BetaDistributionWrapper testDistribution) { + final int sampleSize = resolveSampleSize(); + final double[] controlData = controlDistribution.rvs(sampleSize); + final double[] testData = testDistribution.rvs(sampleSize); + final double[] differences = IntStream.range(0, controlData.length) + .mapToDouble(i -> testData[i] - controlData[i]) + .toArray(); return DifferenceData.builder() .controlData(controlData) .testData(testData) - .differences( - IntStream.range(0, controlData.length) - .mapToDouble(i -> testData[i] - controlData[i]) - .toArray()) + .differences(differences) + .relativeDifference(Arrays.stream(differences).average().orElse(0.0)) .build(); } /** - * Gets the maximum number between a given number and the minimum between two other numbers. + * Resolves sample size for beta distribution. * - * @param arg number - * @param min number - * @param max number - * @return maximum result + * @return sample size */ - private double clip(final double arg, final double min, final double max) { - return Math.max(min, Math.min(arg, max)); + private int resolveSampleSize() { + return betaDistSamples.get(); } /** - * Calculates quantiles from a difference data array, an alpha and beta values. + * Converts {@link VariantBayesianInput} including probability, credibility interval, and risk to + * {@link VariantResult}. * - * @param differences difference data array - * @param alpha alpha value - * @param beta beta value - * @return map of calculated quantiles double values + * @param input variant input + * @param probability probability that B (Test) beats A (Control) + * @param credibilityInterval credibility interval + * @param conversionRate conversion rate + * @param medianGrowth median growth + * @param risk risk + * @return {@link VariantResult} instance */ - private Map calcQuantiles(final double[] differences, final Double alpha, final Double beta) { - if (differences.length == 0) { - return ImmutableMap.of(); - } - - final double[] sorted = Arrays.copyOf(differences, differences.length); - Arrays.sort(sorted); - - final int size = differences.length; - final double alphaFinal = Objects.requireNonNullElse(alpha, DEFAULT_VALUE); - final double betaFinal = Objects.requireNonNullElse(beta, DEFAULT_VALUE); + private VariantResult toResult(final VariantBayesianInput input, + final double probability, + final double[] credibilityInterval, + final double conversionRate, + final Double medianGrowth, + final double risk) { + return VariantResult.builder() + .variant(input.variant()) + .probability(probability) + .conversionRate(conversionRate) + .medianGrowth(medianGrowth) + .credibilityInterval( + CredibilityInterval.builder() + .lower(credibilityInterval[0]) + .upper(credibilityInterval[1]) + .build()) + .risk(risk) + .build(); + } - return Arrays.stream(QUANTILES) - .boxed() - .collect(Collectors.toMap( - Function.identity(), - quantile -> { - final double p = quantile; - final double m = alphaFinal + p * (1 - alphaFinal - betaFinal); - final double aleph = size * p + m; - final int k = (int) Math.floor(clip(aleph, 1, size - 1)); - final double gamma = clip(aleph - k, 0, 1); - final double result = (1 - gamma) * sorted[k - 1] + gamma * sorted[k]; - return QuantilePair.builder() - .quantile(result) - .formatted((double) Math.round(100 * result) / 100) - .build(); - })); + /** + * Validates passed {@link BayesianInput} instance to have the right parameters. + * + * @param input Bayesian calculation input + * @param expected expected number of variants + */ + private void validateVariantsSize(final BayesianInput input, final int expected) { + if (input.variantPairs().size() != expected) { + throw new IllegalArgumentException(String.format("AB test must have only %d variant", expected)); + } } /** - * Executes Apache's Common Math log beta function for provided alpha and beta values + * Validates passed {@link VariantBayesianInput} instance to have the right parameters. * - * @param alpha alpha value - * @param beta beta value - * @return results from log beta function + * @param variantPair variant pair */ - private double logBeta(final double alpha, final double beta) { - return LOG_BETA_FN.apply(alpha, beta); + private void validateVariantPair(final VariantBayesianInput variantPair) { + if (variantPair.successes() < 0) { + throw new IllegalArgumentException("Variant successes cannot have negative value"); + } + if (variantPair.failures() < 0) { + throw new IllegalArgumentException("Variant failures cannot have negative value"); + } } /** @@ -418,42 +643,22 @@ private void validateInput(final BayesianInput input) throws DotDataException { validateVariantPair(input.control()); input.variantPairs().forEach(this::validateVariantPair); - final List variantPairs = new ArrayList<>(input.variantPairs()); + final List variantPairs = new ArrayList<>(input.variantPairs()); variantPairs.add(input.control()); - for (final VariantInputPair variantPair: variantPairs) { + for (final VariantBayesianInput variantPair: variantPairs) { validateVariantsSize(input, input.type() == ABTestingType.ABC ? 2 : 1); if (variantPair.successes() + variantPair.failures() == 0) { throw new DotDataException("Variant successes and failures cannot be zero"); } } - if (Objects.nonNull(input.priors().alpha()) && input.priors().alpha() <= 0.0) { - throw new IllegalArgumentException("Prior alpha cannot have a zero or negative value"); - } - - if (Objects.nonNull(input.priors().beta()) && input.priors().beta() <= 0.0) { - throw new IllegalArgumentException("Prior beta cannot have a zero or negative value"); - } - } - - private void validateVariantsSize(final BayesianInput input, final int expected) { - if (input.variantPairs().size() != expected) { - throw new IllegalArgumentException(String.format("AB test must have only %d variant", expected)); - } - } - - /** - * Validates passed {@link VariantInputPair} instance to have the right parameters. - * - * @param variantPair variant pair - */ - private void validateVariantPair(final VariantInputPair variantPair) { - if (variantPair.successes() < 0) { - throw new IllegalArgumentException("Variant successes cannot have negative value"); - } - - if (variantPair.failures() < 0) { - throw new IllegalArgumentException("Variant failures cannot have negative value"); + for(final BayesianPriors priors: input.priors()) { + if (priors.alpha() <= 0.0) { + throw new IllegalArgumentException("Prior alpha cannot have a zero or negative value"); + } + if (priors.beta() <= 0.0) { + throw new IllegalArgumentException("Prior beta cannot have a zero or negative value"); + } } } @@ -463,7 +668,7 @@ private void validateVariantPair(final VariantInputPair variantPair) { * @param input Bayesian calculation input * @return Optional with NOOP result if input has zero interactions, empty otherwise */ - private Optional detectNoop(final BayesianInput input) { + private Optional noopFallback(final BayesianInput input) { try { validateInput(input); return Optional.empty(); @@ -476,12 +681,53 @@ private Optional detectNoop(final BayesianInput input) { } /** - * Evaluates is prior is present in the input. - * @param input Bayesian calculation input - * @return true if prior is present, false otherwise + * Gets the maximum number between a given number and the minimum between two other numbers. + * + * @param arg number + * @param min number + * @param max number + * @return maximum result + */ + private double clip(final double arg, final double min, final double max) { + return Math.max(min, Math.min(arg, max)); + } + + /** + * Calculates quantiles from a difference data array, an alpha and beta values. + * + * @param differences difference data array + * @param alpha alpha value + * @param beta beta value + * @return map of calculated quantiles double values */ - private boolean isPriorPresent(final BayesianInput input) { - return Objects.nonNull(input.priors().alpha()) && Objects.nonNull(input.priors().beta()); + private Map calcQuantiles(final double[] differences, final Double alpha, final Double beta) { + if (differences.length == 0) { + return ImmutableMap.of(); + } + + final double[] sorted = Arrays.copyOf(differences, differences.length); + Arrays.sort(sorted); + + final int size = differences.length; + final double alphaFinal = Objects.requireNonNullElse(alpha, BETA_DIST_DEFAULT); + final double betaFinal = Objects.requireNonNullElse(beta, BETA_DIST_DEFAULT); + + return Arrays.stream(QUANTILES) + .boxed() + .collect(Collectors.toMap( + Function.identity(), + quantile -> { + final double p = quantile; + final double m = alphaFinal + p * (1 - alphaFinal - betaFinal); + final double aleph = size * p + m; + final int k = (int) Math.floor(clip(aleph, 1, size - 1)); + final double gamma = clip(aleph - k, 0, 1); + final double result = (1 - gamma) * sorted[k - 1] + gamma * sorted[k]; + return QuantilePair.builder() + .quantile(result) + .formatted((double) Math.round(100 * result) / 100) + .build(); + })); } } diff --git a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/BetaDistribution.java b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/BetaDistribution.java deleted file mode 100644 index 6d94c040e7e4..000000000000 --- a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/BetaDistribution.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.dotcms.analytics.bayesian; - -import org.apache.commons.numbers.gamma.LogGamma; - -import java.util.function.Function; -import java.util.stream.Stream; - - -/** - * Customized Beta Distribution class wrapping utilities found in Apache's Common Math and Apache's Commons Numbers - * libraries. - * - * @author vico - */ -public class BetaDistribution { - - private final double alpha; - private final double beta; - // do we even need to set this for Commons Math ? - private final double betaInverse; - - /** - * Instantiates class with alpha and beta parameters. - * - * @param alpha alpha value - * @param beta beta value - */ - private BetaDistribution(final double alpha, final double beta) { - this.alpha = alpha; - this.beta = beta; - // do we need to set this for Commons Math ? - betaInverse = LogGamma.value(this.alpha + this.beta) - LogGamma.value(this.alpha) - LogGamma.value(this.beta); - } - - /** - * Creates new instance of {@link BetaDistribution}. - * - * @param alpha alpha value - * @param beta beta value - * @return a new {@link BetaDistribution} instance - */ - public static BetaDistribution create(final double alpha, final double beta) { - return new BetaDistribution(alpha, beta); - } - - /** - * Creates a function that returns the log density for a provided number. - * - * @return {@link Function} instance - */ - public Function lp() { - return x -> distribution().logDensity(x); - } - - /** - * Returns log density actual result. - * - * @param x number to calculate density from - * @return log density result - */ - public double lp(final double x) { - return lp().apply(x); - } - - /** - * Creates a function that returns the density for a provided number. - * - * @return {@link Function} instance - */ - public Function pdf() { - return x -> distribution().density(x); - } - - /** - * Returns density actual result. - * - * @param x number to calculate density from - * @return density result - */ - public double pdf(final double x) { - return pdf().apply(x); - } - - /** - * Creates a distribution sample. - * - * @return double sample - */ - public double rv() { - return distribution().sample(); - } - - /** - * Creates a limited (by provided size) generation of samples. - * - * @param size size - * @return list of samples - */ - public double[] rvs(final long size) { - return Stream.generate(this::rv).limit(size).mapToDouble(Double::doubleValue).toArray(); - } - - /** - * Creates a {@link org.apache.commons.math3.distribution.BetaDistribution} instance based on current instance's alpha, beta and inverse beta values. - * - * @return Apache's Commons Math beta distribution instance. - */ - private org.apache.commons.math3.distribution.BetaDistribution distribution() { - return new org.apache.commons.math3.distribution.BetaDistribution(alpha, beta, betaInverse); - } - - @Override - public String toString() { - return "DotBetaDistribution{" + - "alpha=" + alpha + - ", beta=" + beta + - ", betaInverse=" + betaInverse + - '}'; - } - -} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/BetaModel.java b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/BetaModel.java deleted file mode 100644 index 2ddc10bcab50..000000000000 --- a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/BetaModel.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.dotcms.analytics.bayesian; - -import com.dotcms.analytics.bayesian.model.BayesianInput; - - -/** - * Beta Distribution wrapping class. - * - * @author vico - */ -public class BetaModel { - - private Double alpha; - private Double beta; - - public BetaModel(final BayesianInput input) { - setValues(input.priors().alpha(), input.priors().beta()); - } - - /** - * Adds provided successes and failures values to current alpha and beta attributes. - * - * @param successes number os successes - * @param failures number of failures - */ - public void update(final int successes, final int failures) { - setValues(alpha + successes, beta + failures); - } - - /** - * Creates a new instance of {@link BetaDistribution} with already provided alpha and beta values. - * - * @return a beta distribution object. - */ - public BetaDistribution distribution() { - return BetaDistribution.create(alpha, beta); - } - - /** - * Set alpha and beta values. - * - * @param alpha alpha value - * @param beta beta value - */ - private void setValues(final Double alpha, final Double beta) { - this.alpha = alpha; - this.beta = beta; - } - -} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/beta/BetaDistributionWrapper.java b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/beta/BetaDistributionWrapper.java new file mode 100644 index 000000000000..e15f8e17fe31 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/beta/BetaDistributionWrapper.java @@ -0,0 +1,97 @@ +package com.dotcms.analytics.bayesian.beta; + +import org.apache.commons.math3.distribution.BetaDistribution; + +import java.util.stream.Stream; + +/** + * Customized Beta Distribution class wrapping utilities found in Apache's Common Math and Apache's Commons Numbers + * libraries. + * + * @author vico + */ +public class BetaDistributionWrapper { + + private final BetaDistribution betaDistribution; + + /** + * Instantiates class with alpha and beta parameters. + * + * @param alpha alpha value + * @param beta beta value + * @param betaInverse beta inverse value + */ + private BetaDistributionWrapper(final double alpha, final double beta, final Double betaInverse) { + betaDistribution = betaInverse != Double.NEGATIVE_INFINITY + ? new BetaDistribution(alpha, beta, betaInverse) + : new BetaDistribution(alpha, beta); + } + + /** + * Creates new instance of {@link BetaDistributionWrapper}. + * + * @param alpha alpha value + * @param beta beta value + * @return a new {@link BetaDistributionWrapper} instance + */ + public static BetaDistributionWrapper create(final double alpha, final double beta, final double betaInverse) { + return new BetaDistributionWrapper(alpha, beta, betaInverse); + } + + /** + * Creates new instance of {@link BetaDistributionWrapper}. + * + * @param alpha alpha value + * @param beta beta value + * @return a new {@link BetaDistributionWrapper} instance + */ + public static BetaDistributionWrapper create(final double alpha, final double beta) { + return new BetaDistributionWrapper(alpha, beta, Double.NEGATIVE_INFINITY); + } + + /** + * Returns log density actual result. + * + * @param x number to calculate density from + * @return log density result + */ + public double lp(final double x) { + return betaDistribution.logDensity(x); + } + + /** + * Returns density actual result. + * + * @param x number to calculate density from + * @return density result + */ + public double pdf(final double x) { + return betaDistribution.density(x); + } + + /** + * Creates a distribution sample. + *d + * @return double sample + */ + public double rv() { + return betaDistribution.sample(); + } + + /** + * Creates a limited (by provided size) generation of samples. + * + * @param size size + * @return list of samples + */ + public double[] rvs(final long size) { + return Stream.generate(this::rv).limit(size).mapToDouble(Double::doubleValue).toArray(); + } + + @Override + public String toString() { + return "BetaDistributionWrapper{" + + "betaDistribution=" + betaDistribution + + '}'; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractBayesianInput.java b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractBayesianInput.java index 3fd0d8f1ec07..2af6b4201993 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractBayesianInput.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractBayesianInput.java @@ -30,12 +30,12 @@ public interface AbstractBayesianInput { ABTestingType type(); @JsonProperty("control") - VariantInputPair control(); + VariantBayesianInput control(); @JsonProperty("variantPairs") - List variantPairs(); + List variantPairs(); @JsonProperty("priors") - BayesianPriors priors(); + List priors(); } diff --git a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractBayesianPriors.java b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractBayesianPriors.java index 0f4c5df62296..a3978de046af 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractBayesianPriors.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractBayesianPriors.java @@ -5,9 +5,6 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import org.immutables.value.Value; -import javax.annotation.Nullable; - - /** * Single Bayesian Prior data class to store provided prior (or known) data. * This data is required to make some more calculations regarding the quantiles, eventual histogram rendering, etc. @@ -21,11 +18,9 @@ public interface AbstractBayesianPriors { @JsonProperty("alpha") - @Nullable - Double alpha(); + double alpha(); @JsonProperty("beta") - @Nullable - Double beta(); + double beta(); } diff --git a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractBayesianResult.java b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractBayesianResult.java index ab3424ced3a3..1e8ee525efab 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractBayesianResult.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractBayesianResult.java @@ -26,11 +26,8 @@ public interface AbstractBayesianResult { @JsonProperty("value") double value(); - @JsonProperty("suggestedWinner") - String suggestedWinner(); - - @JsonProperty("probabilities") - List probabilities(); + @JsonProperty("results") + List results(); @Nullable @JsonProperty("distributionPdfs") @@ -44,4 +41,7 @@ public interface AbstractBayesianResult { @JsonProperty("quantiles") Map quantiles(); + @JsonProperty("suggestedWinner") + String suggestedWinner(); + } diff --git a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractVariantProbability.java b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractCredibilityInterval.java similarity index 64% rename from dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractVariantProbability.java rename to dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractCredibilityInterval.java index 135263200591..64d59ded4755 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractVariantProbability.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractCredibilityInterval.java @@ -6,20 +6,20 @@ import org.immutables.value.Value; /** - * Variant successes and failures pair. + * Credibility interval class * * @author vico */ @Value.Style(typeImmutable="*", typeAbstract="Abstract*") @Value.Immutable -@JsonDeserialize(as = VariantProbability.class) +@JsonDeserialize(as = CredibilityInterval.class) @JsonIgnoreProperties(ignoreUnknown = true) -public interface AbstractVariantProbability { +public interface AbstractCredibilityInterval { - @JsonProperty("variant") - String variant(); + @JsonProperty("lower") + double lower(); - @JsonProperty("value") - double value(); + @JsonProperty("upper") + double upper(); } diff --git a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractDifferenceData.java b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractDifferenceData.java index 83d8211805a8..b72ac0fd503e 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractDifferenceData.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractDifferenceData.java @@ -1,7 +1,8 @@ package com.dotcms.analytics.bayesian.model; - +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import org.immutables.value.Value; /** @@ -11,6 +12,8 @@ */ @Value.Style(typeImmutable="*", typeAbstract="Abstract*") @Value.Immutable +@JsonDeserialize(as = DifferenceData.class) +@JsonIgnoreProperties(ignoreUnknown = true) public interface AbstractDifferenceData { @JsonProperty("controlData") @@ -22,4 +25,7 @@ public interface AbstractDifferenceData { @JsonProperty("differences") double[] differences(); + @JsonProperty("relativeDifference") + double relativeDifference(); + } diff --git a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractQuantilePair.java b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractQuantilePair.java index a728545c5821..f793957d1a96 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractQuantilePair.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractQuantilePair.java @@ -1,6 +1,8 @@ package com.dotcms.analytics.bayesian.model; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import org.immutables.value.Value; @@ -11,6 +13,8 @@ */ @Value.Style(typeImmutable="*", typeAbstract="Abstract*") @Value.Immutable +@JsonDeserialize(as = QuantilePair.class) +@JsonIgnoreProperties(ignoreUnknown = true) public interface AbstractQuantilePair { @JsonProperty("quantile") diff --git a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractSampleData.java b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractSampleData.java index 07dd0e14bf63..a47ed173d6b1 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractSampleData.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractSampleData.java @@ -1,7 +1,9 @@ package com.dotcms.analytics.bayesian.model; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import org.immutables.value.Value; /** @@ -11,6 +13,8 @@ */ @Value.Style(typeImmutable="*", typeAbstract="Abstract*") @Value.Immutable +@JsonDeserialize(as = SampleData.class) +@JsonIgnoreProperties(ignoreUnknown = true) public interface AbstractSampleData { @JsonProperty("x") diff --git a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractSampleGroup.java b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractSampleGroup.java index 675fb36c22b6..3e7f5aebaa9a 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractSampleGroup.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractSampleGroup.java @@ -1,6 +1,8 @@ package com.dotcms.analytics.bayesian.model; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import org.immutables.value.Value; import java.util.List; @@ -14,6 +16,8 @@ */ @Value.Style(typeImmutable="*", typeAbstract="Abstract*") @Value.Immutable +@JsonDeserialize(as = SampleGroup.class) +@JsonIgnoreProperties(ignoreUnknown = true) public interface AbstractSampleGroup { @JsonProperty("samples") diff --git a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractVariantInputPair.java b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractVariantBayesianInput.java similarity index 86% rename from dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractVariantInputPair.java rename to dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractVariantBayesianInput.java index 7b5df9ad3c5c..5603198784a7 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractVariantInputPair.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractVariantBayesianInput.java @@ -12,9 +12,9 @@ */ @Value.Style(typeImmutable="*", typeAbstract="Abstract*") @Value.Immutable -@JsonDeserialize(as = VariantInputPair.class) +@JsonDeserialize(as = VariantBayesianInput.class) @JsonIgnoreProperties(ignoreUnknown = true) -public interface AbstractVariantInputPair { +public interface AbstractVariantBayesianInput { @JsonProperty("variant") String variant(); diff --git a/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractVariantResult.java b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractVariantResult.java new file mode 100644 index 000000000000..8d4f448b68ca --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/bayesian/model/AbstractVariantResult.java @@ -0,0 +1,41 @@ +package com.dotcms.analytics.bayesian.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import org.immutables.value.Value; + +import javax.annotation.Nullable; + +/** + * Variant successes and failures pair. + * + * @author vico + */ +@Value.Style(typeImmutable="*", typeAbstract="Abstract*") +@Value.Immutable +@JsonDeserialize(as = VariantResult.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public interface AbstractVariantResult { + + @JsonProperty("variant") + String variant(); + + @JsonProperty("conversionRate") + double conversionRate(); + + @JsonProperty("probability") + double probability(); + + @Nullable + @JsonProperty("medianGrowth") + Double medianGrowth(); + + @Nullable + @JsonProperty("credibilityInterval") + CredibilityInterval credibilityInterval(); + + @JsonProperty("risk") + double risk(); + +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/helper/BayesianHelper.java b/dotCMS/src/main/java/com/dotcms/analytics/helper/BayesianHelper.java index d78e42656615..9eb3eeebef78 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/helper/BayesianHelper.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/helper/BayesianHelper.java @@ -4,7 +4,7 @@ import com.dotcms.analytics.bayesian.model.ABTestingType; import com.dotcms.analytics.bayesian.model.BayesianInput; import com.dotcms.analytics.bayesian.model.BayesianPriors; -import com.dotcms.analytics.bayesian.model.VariantInputPair; +import com.dotcms.analytics.bayesian.model.VariantBayesianInput; import com.dotcms.experiments.business.result.ExperimentResults; import com.dotcms.experiments.business.result.GoalResults; import com.dotcms.experiments.business.result.VariantResults; @@ -40,9 +40,8 @@ private BayesianHelper() {} * @param goalName goal name to get results from * @return {@link BayesianInput} bayesian input object */ - public BayesianInput toBayesianInput(final ExperimentResults experimentResults, - final String goalName) { - return getGoalResults(experimentResults, goalName) + public BayesianInput toBayesianInput(final ExperimentResults experimentResults, final String goalName) { + return extractGoalResults(experimentResults, goalName) .map(results -> { final VariantResults controlResults = results.getVariants().get(DEFAULT_VARIANT.name()); if (Objects.isNull(controlResults)) { @@ -65,11 +64,11 @@ public BayesianInput toBayesianInput(final ExperimentResults experimentResults, return BayesianInput .builder() .type(variantsResults.size() == 1 ? ABTestingType.AB: ABTestingType.ABC) - .control(toVariantPair(experimentResults, controlResults)) + .control(toVariantBayesianInput(experimentResults, controlResults)) .variantPairs( variantsResults .stream() - .map(vr -> toVariantPair(experimentResults, vr)) + .map(vr -> toVariantBayesianInput(experimentResults, vr)) .collect(Collectors.toList())) .priors(resolvePriors(experimentResults)) .build(); @@ -84,8 +83,8 @@ public BayesianInput toBayesianInput(final ExperimentResults experimentResults, * @param experimentResults experiment results * @return resolved priors */ - private BayesianPriors resolvePriors(final ExperimentResults experimentResults) { - return BayesianAPI.NULL_PRIORS; + private List resolvePriors(final ExperimentResults experimentResults) { + return List.of(BayesianAPI.DEFAULT_PRIORS); } /** @@ -95,7 +94,7 @@ private BayesianPriors resolvePriors(final ExperimentResults experimentResults) * @param goalName goal name to get results from * @return */ - private Optional getGoalResults(final ExperimentResults experimentResults, final String goalName) { + private Optional extractGoalResults(final ExperimentResults experimentResults, final String goalName) { return Optional.ofNullable(experimentResults.getGoals().get(goalName)); } @@ -134,10 +133,10 @@ private long extractFailures(final ExperimentResults experimentResult, * @param variantResults variant results * @return {@link BayesianInput} bayesian input object */ - private VariantInputPair toVariantPair(final ExperimentResults experimentResults, - final VariantResults variantResults) { + private VariantBayesianInput toVariantBayesianInput(final ExperimentResults experimentResults, + final VariantResults variantResults) { final long successes = extractSuccesses(variantResults); - return VariantInputPair.builder() + return VariantBayesianInput.builder() .variant(variantResults.getVariantName()) .successes(successes) .failures(extractFailures(experimentResults, variantResults, successes)) diff --git a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentsAPIImpl.java b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentsAPIImpl.java index c37a465e04c7..ebac26eeaff4 100644 --- a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentsAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentsAPIImpl.java @@ -959,7 +959,7 @@ private BayesianResult calcBayesian(final ExperimentResults experimentResults, f final String goal = StringUtils.defaultIfBlank(goalName, PRIMARY_GOAL); final BayesianInput bayesianInput = BayesianHelper.get().toBayesianInput(experimentResults, goal); - return variantsNumber == 2 ? bayesianAPI.calcProbBOverA(bayesianInput) : bayesianAPI.calcProbABC(bayesianInput); + return bayesianAPI.doBayesian(bayesianInput); } @Override diff --git a/dotCMS/src/test/java/com/dotcms/analytics/bayesian/BayesianAPIImplTest.java b/dotCMS/src/test/java/com/dotcms/analytics/bayesian/BayesianAPIImplTest.java index 9fd0c79d6020..35498e871172 100644 --- a/dotCMS/src/test/java/com/dotcms/analytics/bayesian/BayesianAPIImplTest.java +++ b/dotCMS/src/test/java/com/dotcms/analytics/bayesian/BayesianAPIImplTest.java @@ -4,11 +4,15 @@ import com.dotcms.analytics.bayesian.model.BayesianInput; import com.dotcms.analytics.bayesian.model.BayesianPriors; import com.dotcms.analytics.bayesian.model.BayesianResult; -import com.dotcms.analytics.bayesian.model.VariantInputPair; -import org.junit.Assert; +import com.dotcms.analytics.bayesian.model.VariantBayesianInput; import org.junit.Before; import org.junit.Test; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +import static org.junit.Assert.*; /** * Bayesian API implementation class unit tests. @@ -28,29 +32,43 @@ public void setup() { * Given Bayesian input parameters with these values: * *
    -     *     priorAlpha: 10
    -     *     priorBeta: 10
    -     *     controlSuccesses: 5
    -     *     controlFailures: 3
    -     *     testSuccesses: 6
    -     *     testFailures: 2
    +     *     priorAlpha: 2.0
    +     *     priorBeta: 2.0
    +     *     controlSuccesses: 150
    +     *     controlFailures: 50
    +     *     testSuccesses: 200
    +     *     testFailures: 50
          * 
    * - * Expect that the probability B beats A is at least 0.69. + * Expect that the probability B beats A is at least 0.89. */ @Test - public void test_calculateABTesting() { + public void test_doBayesian_AB() { final BayesianInput input = BayesianInput.builder() .type(ABTestingType.AB) - .control(VariantInputPair.builder().variant("control").successes(5).failures(3).build()) - .addVariantPairs(VariantInputPair.builder().variant("test").successes(6).failures(2).build()) - .priors(BayesianPriors.builder().alpha(10.0).beta(10.0).build()) + .control(VariantBayesianInput.builder().variant("control").successes(100).failures(900).build()) + .addVariantPairs(VariantBayesianInput.builder().variant("test").successes(130).failures(870).build()) + .priors(List.of(BayesianPriors.builder().alpha(1.0).beta(1.0).build())) .build(); - final BayesianResult result = bayesianAPI.calcProbBOverA(input); - Assert.assertNotNull(result); - Assert.assertEquals(0.31, result.probabilities().get(0).value(), 0.01); - Assert.assertEquals(0.69, result.probabilities().get(1).value(), 0.01); - Assert.assertEquals("test", result.suggestedWinner()); + final BayesianResult result = bayesianAPI.doBayesian(input); + assertNotNull(result); + assertEquals(0.02, result.results().get(0).probability(), 0.01); + assertEquals(0.10, result.results().get(0).conversionRate(), 0.00); + assertNull(result.results().get(0).medianGrowth()); + assertEquals(0.98, result.results().get(1).probability(), 0.01); + assertEquals(0.13, result.results().get(1).conversionRate(), 0.00); + assertEquals(0.23, result.results().get(1).medianGrowth(), 0.01); + assertEquals("test", result.suggestedWinner()); + assertEquals(1000, result.differenceData().controlData().length); + assertEquals(1000, result.differenceData().testData().length); + assertEquals(1000, result.differenceData().differences().length); + assertEquals(0.03, result.differenceData().relativeDifference(), 0.001); + assertEquals(11, result.quantiles().size()); + assertEquals(2, result.distributionPdfs().samples().size()); + assertEquals(1000, result.distributionPdfs().samples().get("control").size()); + assertEquals(1000, result.distributionPdfs().samples().get("test").size()); + assertTrue(Arrays.stream(BayesianAPI.QUANTILES).allMatch(result.quantiles()::containsKey)); + assertTrue(Arrays.stream(BayesianAPI.QUANTILES).allMatch(key -> Objects.nonNull(result.quantiles().get(key)))); } /** @@ -61,11 +79,11 @@ public void test_calculateABTesting() { public void test_calculateABTesting_invalidPriorAlpha() { final BayesianInput input = BayesianInput.builder() .type(ABTestingType.AB) - .control(VariantInputPair.builder().variant("control").successes(5).failures(3).build()) - .addVariantPairs(VariantInputPair.builder().variant("test").successes(5).failures(3).build()) - .priors(BayesianPriors.builder().alpha(0.0).beta(10.0).build()) + .control(VariantBayesianInput.builder().variant("control").successes(5).failures(3).build()) + .addVariantPairs(VariantBayesianInput.builder().variant("test").successes(5).failures(3).build()) + .priors(List.of(BayesianPriors.builder().alpha(0.0).beta(10.0).build())) .build(); - bayesianAPI.calcProbBOverA(input); + bayesianAPI.doBayesian(input); } /** @@ -76,11 +94,11 @@ public void test_calculateABTesting_invalidPriorAlpha() { public void test_calculateABTesting_invalidPriorBeta() { final BayesianInput input = BayesianInput.builder() .type(ABTestingType.AB) - .control(VariantInputPair.builder().variant("control").successes(5).failures(3).build()) - .addVariantPairs(VariantInputPair.builder().variant("test").successes(5).failures(3).build()) - .priors(BayesianPriors.builder().alpha(10.0).beta(0.0).build()) + .control(VariantBayesianInput.builder().variant("control").successes(5).failures(3).build()) + .addVariantPairs(VariantBayesianInput.builder().variant("test").successes(5).failures(3).build()) + .priors(List.of(BayesianPriors.builder().alpha(10.0).beta(0.0).build())) .build(); - bayesianAPI.calcProbBOverA(input); + bayesianAPI.doBayesian(input); } /** @@ -91,11 +109,11 @@ public void test_calculateABTesting_invalidPriorBeta() { public void test_calculateABTesting_invalidControlSuccesses() { final BayesianInput input = BayesianInput.builder() .type(ABTestingType.AB) - .control(VariantInputPair.builder().variant("control").successes(-1).failures(3).build()) - .addVariantPairs(VariantInputPair.builder().variant("test").successes(5).failures(3).build()) - .priors(BayesianPriors.builder().alpha(10.0).beta(10.0).build()) + .control(VariantBayesianInput.builder().variant("control").successes(-1).failures(3).build()) + .addVariantPairs(VariantBayesianInput.builder().variant("test").successes(5).failures(3).build()) + .priors(List.of(BayesianPriors.builder().alpha(10.0).beta(10.0).build())) .build(); - bayesianAPI.calcProbBOverA(input); + bayesianAPI.doBayesian(input); } /** @@ -106,11 +124,11 @@ public void test_calculateABTesting_invalidControlSuccesses() { public void test_calculateABTesting_invalidControlFailures() { final BayesianInput input = BayesianInput.builder() .type(ABTestingType.AB) - .control(VariantInputPair.builder().variant("control").successes(5).failures(-1).build()) - .addVariantPairs(VariantInputPair.builder().variant("test").successes(5).failures(3).build()) - .priors(BayesianPriors.builder().alpha(10.0).beta(10.0).build()) + .control(VariantBayesianInput.builder().variant("control").successes(5).failures(-1).build()) + .addVariantPairs(VariantBayesianInput.builder().variant("test").successes(5).failures(3).build()) + .priors(List.of(BayesianPriors.builder().alpha(10.0).beta(10.0).build())) .build(); - bayesianAPI.calcProbBOverA(input); + bayesianAPI.doBayesian(input); } /** @@ -121,11 +139,11 @@ public void test_calculateABTesting_invalidControlFailures() { public void test_calculateABTesting_invalidTestSuccesses() { final BayesianInput input = BayesianInput.builder() .type(ABTestingType.AB) - .control(VariantInputPair.builder().variant("control").successes(5).failures(3).build()) - .addVariantPairs(VariantInputPair.builder().variant("test").successes(-1).failures(3).build()) - .priors(BayesianPriors.builder().alpha(10.0).beta(10.0).build()) + .control(VariantBayesianInput.builder().variant("control").successes(5).failures(3).build()) + .addVariantPairs(VariantBayesianInput.builder().variant("test").successes(-1).failures(3).build()) + .priors(List.of(BayesianPriors.builder().alpha(10.0).beta(10.0).build())) .build(); - bayesianAPI.calcProbBOverA(input); + bayesianAPI.doBayesian(input); } /** @@ -136,11 +154,11 @@ public void test_calculateABTesting_invalidTestSuccesses() { public void test_calculateABTesting_invalidTestFailures() { final BayesianInput input = BayesianInput.builder() .type(ABTestingType.AB) - .control(VariantInputPair.builder().variant("control").successes(5).failures(3).build()) - .addVariantPairs(VariantInputPair.builder().variant("test").successes(5).failures(-1).build()) - .priors(BayesianPriors.builder().alpha(10.0).beta(10.0).build()) + .control(VariantBayesianInput.builder().variant("control").successes(5).failures(3).build()) + .addVariantPairs(VariantBayesianInput.builder().variant("test").successes(5).failures(-1).build()) + .priors(List.of(BayesianPriors.builder().alpha(10.0).beta(10.0).build())) .build(); - bayesianAPI.calcProbBOverA(input); + bayesianAPI.doBayesian(input); } /** @@ -163,18 +181,18 @@ public void test_calculateABTesting_invalidTestFailures() { public void test_calculateABCTesting() { final BayesianInput input = BayesianInput.builder() .type(ABTestingType.ABC) - .control(VariantInputPair.builder().variant("control").successes(5).failures(3).build()) + .control(VariantBayesianInput.builder().variant("control").successes(5).failures(3).build()) .addVariantPairs( - VariantInputPair.builder().variant("testB").successes(6).failures(2).build(), - VariantInputPair.builder().variant("testC").successes(7).failures(1).build()) - .priors(BayesianAPI.NULL_PRIORS) + VariantBayesianInput.builder().variant("testB").successes(6).failures(2).build(), + VariantBayesianInput.builder().variant("testC").successes(7).failures(1).build()) + .priors(List.of(BayesianAPI.DEFAULT_PRIORS, BayesianAPI.DEFAULT_PRIORS, BayesianAPI.DEFAULT_PRIORS)) .build(); - final BayesianResult result = bayesianAPI.calcProbABC(input); - Assert.assertNotNull(result); - Assert.assertEquals(0.08, result.probabilities().get(0).value(), 0.01); - Assert.assertEquals(0.25, result.probabilities().get(1).value(), 0.01); - Assert.assertEquals(0.65, result.probabilities().get(2).value(), 0.01); - Assert.assertEquals("testC", result.suggestedWinner()); + final BayesianResult result = bayesianAPI.doBayesian(input); + assertNotNull(result); + assertEquals(0.08, result.results().get(0).probability(), 0.01); + assertEquals(0.25, result.results().get(1).probability(), 0.01); + assertEquals(0.65, result.results().get(2).probability(), 0.01); + assertEquals("testC", result.suggestedWinner()); } } diff --git a/dotCMS/src/test/java/com/dotcms/analytics/bayesian/BetaDistributionTest.java b/dotCMS/src/test/java/com/dotcms/analytics/bayesian/BetaDistributionWrapperTest.java similarity index 87% rename from dotCMS/src/test/java/com/dotcms/analytics/bayesian/BetaDistributionTest.java rename to dotCMS/src/test/java/com/dotcms/analytics/bayesian/BetaDistributionWrapperTest.java index 331d3062fa6e..82c9e90eb923 100644 --- a/dotCMS/src/test/java/com/dotcms/analytics/bayesian/BetaDistributionTest.java +++ b/dotCMS/src/test/java/com/dotcms/analytics/bayesian/BetaDistributionWrapperTest.java @@ -1,5 +1,6 @@ package com.dotcms.analytics.bayesian; +import com.dotcms.analytics.bayesian.beta.BetaDistributionWrapper; import io.vavr.Tuple2; import org.junit.Assert; import org.junit.Before; @@ -14,13 +15,13 @@ * * @author vico */ -public class BetaDistributionTest { +public class BetaDistributionWrapperTest { - private BetaDistribution distribution; + private BetaDistributionWrapper distribution; @Before public void setup() { - distribution = BetaDistribution.create(10, 10); + distribution = BetaDistributionWrapper.create(10, 10); } /** diff --git a/dotCMS/src/test/java/com/dotcms/analytics/helper/BayesianHelperTest.java b/dotCMS/src/test/java/com/dotcms/analytics/helper/BayesianHelperTest.java index ac6d6b0e5f0a..4a2f809dc91f 100644 --- a/dotCMS/src/test/java/com/dotcms/analytics/helper/BayesianHelperTest.java +++ b/dotCMS/src/test/java/com/dotcms/analytics/helper/BayesianHelperTest.java @@ -3,7 +3,7 @@ import com.dotcms.UnitTestBase; import com.dotcms.analytics.bayesian.model.ABTestingType; import com.dotcms.analytics.bayesian.model.BayesianInput; -import com.dotcms.analytics.bayesian.model.VariantInputPair; +import com.dotcms.analytics.bayesian.model.VariantBayesianInput; import com.dotcms.experiments.business.result.ExperimentResults; import com.dotcms.experiments.business.result.GoalResults; import com.dotcms.experiments.business.result.VariantResults; @@ -77,7 +77,7 @@ public void test_toBayesianInput() { assertEquals(16L, bayesianInput.control().successes()); assertEquals(44L, bayesianInput.control().failures()); assertEquals(1, bayesianInput.variantPairs().size()); - final VariantInputPair testInputPair = bayesianInput.variantPairs().get(0); + final VariantBayesianInput testInputPair = bayesianInput.variantPairs().get(0); assertEquals(TEST_VARIANT, testInputPair.variant()); assertEquals(50L, testInputPair.successes()); assertEquals(10L, testInputPair.failures()); From 18ee2b5fc8bf7407e50a9cb5f8dfab7a48d92f7c Mon Sep 17 00:00:00 2001 From: Victor Alfaro Date: Tue, 30 May 2023 09:07:21 -0600 Subject: [PATCH 34/63] #25056: Including test-results history branches for master and release (#25056) --- .../build-src/test-results.sh | 245 +++++++++++------- 1 file changed, 149 insertions(+), 96 deletions(-) diff --git a/.github/actions/publish-test-results/build-src/test-results.sh b/.github/actions/publish-test-results/build-src/test-results.sh index 135ec215f705..2e50355bf4be 100644 --- a/.github/actions/publish-test-results/build-src/test-results.sh +++ b/.github/actions/publish-test-results/build-src/test-results.sh @@ -37,23 +37,6 @@ function resolveResultsPath { echo ${path} } -# Creates required directory structure for the provided results folder and copies them to the new location -# -# $1: results_path: to copy to results location -function addResults { - local results_path=${1} - if [[ -z "${results_path}" ]]; then - echo "Cannot add results path since its empty, ignoring" - exit 1 - fi - - local target_folder=$(resolveResultsPath ${results_path}) - mkdir -p ${target_folder} - echo "Adding test results path ${results_path} to: ${target_folder}" - - executeCmd "cp -R ${OUTPUT_FOLDER}/* ${target_folder}" -} - # Creates initial branch to add tests results to in case it does not exist function initResults { cd ${INPUT_PROJECT_ROOT} @@ -85,58 +68,21 @@ function initResults { && executeCmd "git push ${test_results_repo_url}" } -# Executes logic for matrix partitioned tests such as postman tests -function closeResults { - if [[ "${INPUT_TEST_TYPE}" == 'postman' ]]; then - local test_results_repo_url=$(resolveRepoUrl ${TEST_RESULTS_GITHUB_REPO} ${INPUT_CICD_GITHUB_TOKEN} ${GITHUB_USER}) - local test_results_path=${INPUT_PROJECT_ROOT}/${TEST_RESULTS_GITHUB_REPO} - - gitRemoteLs ${test_results_repo_url} ${BUILD_ID} - local remote_branch=$? - echo "Branch ${BUILD_ID} exists: ${remote_branch}" - [[ ${remote_branch} != 1 ]] \ - && echo "Tests results branch ${BUILD_ID} does not exist, cannot close results" \ - && exit 1 - - gitClone ${test_results_repo_url} ${BUILD_ID} ${test_results_path} - - executeCmd "mkdir -p ${INPUT_TESTS_RESULTS_LOCATION}" - executeCmd "cp -R ${test_results_path}/projects/core/postman/reports/xml/* ${INPUT_TESTS_RESULTS_LOCATION}/" - setOutput tests_results_location ${INPUT_TESTS_RESULTS_LOCATION} - - cd ${test_results_path}/projects/core/postman/reports/html - gitConfig ${GITHUB_USER} - - executeCmd "cat ./postman-results-header.html > ./index.html" - executeCmd "cat ./*.inc >> ./index.html" - executeCmd "cat ./postman-results-footer.html >> ./index.html" - executeCmd "rm ./*.inc" - executeCmd "rm ./postman-results-header.html" - executeCmd "rm ./postman-results-footer.html" - executeCmd "cat ./index.html" - executeCmd "ls -las ." - - executeCmd "git status" - executeCmd "git add ." - executeCmd "git commit -m \"Closing results for branch ${BUILD_ID}\"" - executeCmd "git pull origin ${BUILD}" - executeCmd "git push ${test_results_repo_url}" - if [[ ${cmd_result} != 0 ]]; then - echo "Error pushing to git for ${INPUT_BUILD_HASH} at ${INPUT_BUILD_ID}, error code: ${cmd_result}" - exit 1 - fi +# Creates required directory structure for the provided results folder and copies them to the new location +# +# $1: results_path: to copy to results location +function addResults { + local results_path=${1} + if [[ -z "${results_path}" ]]; then + echo "Cannot add results path since its empty, ignoring" + exit 1 + fi - for rc_file in *.rc; do - local rc_content=$(cat ${rc_file}) - eval "${rc_content}" - if [[ -z "${test_results_rc}" || ${test_results_rc} != 0 ]]; then - echo "Error return code at ${rc_file} with content [${rc_content}]" - return ${test_results_rc} - fi - done + local target_folder=$(resolveResultsPath ${results_path}) + mkdir -p ${target_folder} + echo "Adding test results path ${results_path} to: ${target_folder}" - return 0 - fi + executeCmd "cp -R ${OUTPUT_FOLDER}/* ${target_folder}" } # Persists results in 'test-results' repo in the provided INPUT_BUILD_ID branch. @@ -174,7 +120,14 @@ function persistResults { # Clean test results folders by removing contents and committing them cleanTestFolders - addResults . + if [[ "${MULTI_COMMIT}" == 'true' ]]; then + # Do not add commit results when the branch is master, otherwise add test results to commit + addResults ./${INPUT_BUILD_HASH} + # Add results to current + addResults ./current + else + addResults . + fi # Check for something new to commit [[ "${DEBUG}" == 'true' ]] \ @@ -209,7 +162,6 @@ function persistResults { fi # Finally push the changes - executeCmd "git pull origin ${BUILD}" executeCmd "git push ${test_results_repo_url}" if [[ ${cmd_result} != 0 ]]; then echo "Error pushing to git for ${INPUT_BUILD_HASH} at ${INPUT_BUILD_ID}, error code: ${cmd_result}" @@ -220,6 +172,63 @@ function persistResults { fi } +# Executes logic for matrix partitioned tests such as postman tests +function closeResults { + if [[ "${INPUT_TEST_TYPE}" == 'postman' ]]; then + local test_results_repo_url=$(resolveRepoUrl ${TEST_RESULTS_GITHUB_REPO} ${INPUT_CICD_GITHUB_TOKEN} ${GITHUB_USER}) + local test_results_path=${INPUT_PROJECT_ROOT}/${TEST_RESULTS_GITHUB_REPO} + + gitRemoteLs ${test_results_repo_url} ${BUILD_ID} + local remote_branch=$? + echo "Branch ${BUILD_ID} exists: ${remote_branch}" + [[ ${remote_branch} != 1 ]] \ + && echo "Tests results branch ${BUILD_ID} does not exist, cannot close results" \ + && exit 1 + + gitClone ${test_results_repo_url} ${BUILD_ID} ${test_results_path} + + local results_base_path=${test_results_path}/projects/${INPUT_TARGET_PROJECT} + [[ "${MULTI_COMMIT}" == 'true' ]] && results_base_path="${results_base_path}/${INPUT_BUILD_HASH}" + + executeCmd "mkdir -p ${INPUT_TESTS_RESULTS_LOCATION}" + executeCmd "cp -R ${results_base_path}/postman/reports/xml/* ${INPUT_TESTS_RESULTS_LOCATION}/" + setOutput tests_results_location ${INPUT_TESTS_RESULTS_LOCATION} + + cd ${results_base_path}/postman/reports/html + gitConfig ${GITHUB_USER} + + executeCmd "cat ./postman-results-header.html > ./index.html" + executeCmd "cat ./*.inc >> ./index.html" + executeCmd "cat ./postman-results-footer.html >> ./index.html" + executeCmd "rm ./*.inc" + executeCmd "rm ./postman-results-header.html" + executeCmd "rm ./postman-results-footer.html" + executeCmd "cat ./index.html" + executeCmd "ls -las ." + + executeCmd "git status" + executeCmd "git add ." + executeCmd "git commit -m \"Closing results for branch ${BUILD_ID}\"" + executeCmd "git pull origin ${BUILD}" + executeCmd "git push ${test_results_repo_url}" + if [[ ${cmd_result} != 0 ]]; then + echo "Error pushing to git for ${INPUT_BUILD_HASH} at ${INPUT_BUILD_ID}, error code: ${cmd_result}" + exit 1 + fi + + for rc_file in *.rc; do + local rc_content=$(cat ${rc_file}) + eval "${rc_content}" + if [[ -z "${test_results_rc}" || ${test_results_rc} != 0 ]]; then + echo "Error return code at ${rc_file} with content [${rc_content}]" + return ${test_results_rc} + fi + done + + return 0 + fi +} + # Creates a summary status file for test the specific INPUT_TEST_TYPE, INPUT_DB_TYPE in both commit and branch paths. # # $1: results status @@ -238,7 +247,7 @@ function trackCoreTests { echo "DB_TYPE=${INPUT_DB_TYPE}" >> ${result_file} echo "TEST_TYPE_RESULT=${result_label}" >> ${result_file} echo "BRANCH_TEST_RESULT_URL=${BRANCH_TEST_RESULT_URL}" >> ${result_file} - echo "TEST_LOG_URL=${TEST_LOG_URL}" >> ${result_file} + echo "BRANCH_TEST_LOG_URL=${BRANCH_TEST_LOG_URL}" >> ${result_file} cat ${result_file} } @@ -264,11 +273,11 @@ function copyResults { # Set Github Action outputs to be used by other actions function setOutputs { setOutput tests_report_url ${BRANCH_TEST_RESULT_URL} true - setOutput test_logs_url ${TEST_LOG_URL} + setOutput test_logs_url ${BRANCH_TEST_LOG_URL} if [[ "${INPUT_TEST_TYPE}" == 'integration' ]]; then setOutput ${INPUT_DB_TYPE}_tests_report_url ${BRANCH_TEST_RESULT_URL} true - setOutput ${INPUT_DB_TYPE}_test_logs_url ${TEST_LOG_URL} + setOutput ${INPUT_DB_TYPE}_test_logs_url ${BRANCH_TEST_LOG_URL} fi } @@ -276,7 +285,7 @@ function setOutputs { function appendLogLocation { if [[ "${INPUT_TEST_TYPE}" != 'postman' ]]; then # Now we want to add the logs link at the end of index.html results report file - logs_link="

    dotcms.log

    " + logs_link="

    dotcms.log

    " echo " ${logs_link} " >> ${HTML_REPORTS_FOLDER}/index.html @@ -286,6 +295,8 @@ function appendLogLocation { # Prints information about the status of any test type function printStatus { local pull_request_url="https://github.com/dotCMS/${INPUT_TARGET_PROJECT}/pull/${INPUT_PULL_REQUEST}" + local results_base_path=${INPUT_PROJECT_ROOT}/${TEST_RESULTS_GITHUB_REPO}/projects/${INPUT_TARGET_PROJECT} + [[ "${MULTI_COMMIT}" == 'true' ]] && results_base_path="${results_base_path}/${INPUT_BUILD_HASH}" echo echo -e "\e[36m==========================================================================================================================\e[0m" @@ -293,20 +304,41 @@ function printStatus { echo -e "\e[1;36m REPORTING\e[0m" echo - [[ "${INCLUDE_RESULTS}" == 'true' ]] && echo -e "\e[31m ${BRANCH_TEST_RESULT_URL}\e[0m" - if [[ "${INCLUDE_LOGS}" == 'true' ]]; then - if [[ "${INPUT_TEST_TYPE}" == 'postman' ]]; then - if [[ -n "${INPUT_RUN_IDENTIFIER}" ]]; then - echo -e "\e[31m ${TEST_LOG_URL}\e[0m" + cd ${results_base_path}/postman/logs + + if [[ "${MULTI_COMMIT}" == 'true' ]]; then + [[ "${INCLUDE_RESULTS}" == 'true' ]] && echo -e "\e[31m ${COMMIT_TEST_RESULT_URL}\e[0m" + if [[ "${INCLUDE_LOGS}" == 'true' ]]; then + if [[ "${INPUT_TEST_TYPE}" == 'postman' ]]; then + if [[ -n "${INPUT_RUN_IDENTIFIER}" ]]; then + echo -e "\e[31m ${COMMIT_TEST_LOG_URL}\e[0m" + else + for l in *.log + do + echo -e "\e[31m ${GITHUB_PERSIST_COMMIT_URL}/${HTML_REPORTS_LOCATION}/${l%.*}.html\e[0m" + echo -e "\e[31m ${GITHUB_PERSIST_COMMIT_URL}/logs/${l}\e[0m" + done + fi else - cd ${INPUT_PROJECT_ROOT}/${TEST_RESULTS_GITHUB_REPO}/projects/core/postman/logs - for l in *.log - do - echo -e "\e[31m ${GITHUB_PERSIST_BRANCH_URL}/logs/${l}\e[0m" - done + echo -e "\e[31m ${COMMIT_TEST_LOG_URL}\e[0m" + fi + fi + else + [[ "${INCLUDE_RESULTS}" == 'true' ]] && echo -e "\e[31m ${BRANCH_TEST_RESULT_URL}\e[0m" + if [[ "${INCLUDE_LOGS}" == 'true' ]]; then + if [[ "${INPUT_TEST_TYPE}" == 'postman' ]]; then + if [[ -n "${INPUT_RUN_IDENTIFIER}" ]]; then + echo -e "\e[31m ${BRANCH_TEST_LOG_URL}\e[0m" + else + for l in *.log + do + echo -e "\e[31m ${GITHUB_PERSIST_BRANCH_URL}/${HTML_REPORTS_LOCATION}/${l%.*}.html\e[0m" + echo -e "\e[31m ${GITHUB_PERSIST_BRANCH_URL}/logs/${l}\e[0m" + done + fi + else + echo -e "\e[31m ${BRANCH_TEST_LOG_URL}\e[0m" fi - else - echo -e "\e[31m ${TEST_LOG_URL}\e[0m" fi fi @@ -328,10 +360,14 @@ function printStatus { # More Env-Vars definition, specifically to results storage githack_url=$(resolveRepoPath ${TEST_RESULTS_GITHUB_REPO} | sed -e 's/github.com/raw.githack.com/') -BUILD_ID=${INPUT_BUILD_ID} -[[ "${INPUT_BUILD_ID}" != 'master' && ! ${INPUT_BUILD_ID} =~ ^release-[0-9]{2}.[0-9]{2}(.[0-9]{1,2})?$|^v[0-9]{2}.[0-9]{2}(.[0-9]{1,2})?$ ]] \ - && BUILD_ID="${INPUT_BUILD_ID}_${INPUT_BUILD_HASH}" -export BUILD_ID +if [[ "${INPUT_BUILD_ID}" != 'master' \ + && ! ${INPUT_BUILD_ID} =~ ^release-[0-9]{2}.[0-9]{2}(.[0-9]{1,2})?$|^v[0-9]{2}.[0-9]{2}(.[0-9]{1,2})?$ ]]; then + export MULTI_COMMIT=false + export BUILD_ID="${INPUT_BUILD_ID}_${INPUT_BUILD_HASH}" +else + export MULTI_COMMIT=true + export BUILD_ID=${INPUT_BUILD_ID} +fi export OUTPUT_FOLDER="${INPUT_PROJECT_ROOT}/output" export HTML_REPORTS_LOCATION='reports/html' export HTML_REPORTS_FOLDER="${OUTPUT_FOLDER}/${HTML_REPORTS_LOCATION}" @@ -339,17 +375,33 @@ export XML_REPORTS_LOCATION='reports/xml' export XML_REPORTS_FOLDER="${OUTPUT_FOLDER}/${XML_REPORTS_LOCATION}" export LOGS_FOLDER="${OUTPUT_FOLDER}/logs" export BASE_STORAGE_URL="${githack_url}/$(urlEncode ${BUILD_ID})/projects/${INPUT_TARGET_PROJECT}" -export STORAGE_JOB_BRANCH_FOLDER="$(resolveResultsPath '')" + +if [[ "${MULTI_COMMIT}" == 'true' ]]; then + export STORAGE_JOB_BRANCH_FOLDER="$(resolveResultsPath current)" + export STORAGE_JOB_COMMIT_FOLDER="$(resolveResultsPath ${INPUT_BUILD_HASH})" + export GITHUB_PERSIST_COMMIT_URL="${BASE_STORAGE_URL}/${STORAGE_JOB_COMMIT_FOLDER}" + export REPORT_PERSIST_COMMIT_URL="${GITHUB_PERSIST_COMMIT_URL}/${HTML_REPORTS_LOCATION}" + COMMIT_TEST_RESULT_URL=${REPORT_PERSIST_COMMIT_URL}/index.html + COMMIT_TEST_LOG_URL=${GITHUB_PERSIST_COMMIT_URL}/logs/dotcms.log + [[ -n "${INPUT_RUN_IDENTIFIER}" ]] \ + && COMMIT_TEST_RESULT_URL=${REPORT_PERSIST_COMMIT_URL}/${INPUT_RUN_IDENTIFIER}.html \ + && COMMIT_TEST_LOG_URL=${GITHUB_PERSIST_COMMIT_URL}/logs/${INPUT_RUN_IDENTIFIER}.log + export COMMIT_TEST_RESULT_URL + export COMMIT_TEST_LOG_URL +else + export STORAGE_JOB_BRANCH_FOLDER="$(resolveResultsPath '')" +fi + export GITHUB_PERSIST_BRANCH_URL="${BASE_STORAGE_URL}/${STORAGE_JOB_BRANCH_FOLDER}" export REPORT_PERSIST_BRANCH_URL="${GITHUB_PERSIST_BRANCH_URL}/${HTML_REPORTS_LOCATION}" BRANCH_TEST_RESULT_URL=${REPORT_PERSIST_BRANCH_URL}/index.html -TEST_LOG_URL=${GITHUB_PERSIST_BRANCH_URL}/logs/dotcms.log -if [[ -n "${INPUT_RUN_IDENTIFIER}" ]]; then - BRANCH_TEST_RESULT_URL=${REPORT_PERSIST_BRANCH_URL}/${INPUT_RUN_IDENTIFIER}.html - TEST_LOG_URL=${GITHUB_PERSIST_BRANCH_URL}/logs/${INPUT_RUN_IDENTIFIER}.log -fi +BRANCH_TEST_LOG_URL=${GITHUB_PERSIST_BRANCH_URL}/logs/dotcms.log +[[ -n "${INPUT_RUN_IDENTIFIER}" ]] \ + && BRANCH_TEST_RESULT_URL=${REPORT_PERSIST_BRANCH_URL}/${INPUT_RUN_IDENTIFIER}.html \ + && BRANCH_TEST_LOG_URL=${GITHUB_PERSIST_BRANCH_URL}/logs/${INPUT_RUN_IDENTIFIER}.log export BRANCH_TEST_RESULT_URL -export TEST_LOG_URL +export BRANCH_TEST_LOG_URL + [[ ${INPUT_MODE} =~ ALL|RESULTS ]] && export INCLUDE_RESULTS=true [[ ${INPUT_MODE} =~ ALL|LOGS ]] && export INCLUDE_LOGS=true @@ -364,6 +416,7 @@ LOGS_FOLDER: ${LOGS_FOLDER} BASE_STORAGE_URL: ${BASE_STORAGE_URL} GITHUB_PERSIST_BRANCH_URL: ${GITHUB_PERSIST_BRANCH_URL} STORAGE_JOB_BRANCH_FOLDER: ${STORAGE_JOB_BRANCH_FOLDER} +STORAGE_JOB_COMMIT_FOLDER: ${STORAGE_JOB_COMMIT_FOLDER} INPUT_MODE: ${INPUT_MODE} INCLUDE_RESULTS: ${INCLUDE_RESULTS} INCLUDE_LOGS: ${INCLUDE_LOGS} From efc33ad2916a75db0508bef46250dba32db26da4 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 30 May 2023 11:48:38 -0600 Subject: [PATCH 35/63] Fix #24980 Adding just a minor change to hide dot fav on content search (#24989) * #24980 adding just a minor change to hide dot fav on content search * #24980 adding it test * #24980 fixing an it test --- .../business/ContentTypeInitializerTest.java | 32 +++++++++++++++++++ .../business/ESMappingAPIImpl.java | 1 + .../constants/ESMappingConstants.java | 8 ++++- .../business/ContentTypeInitializer.java | 20 ++++++++++++ .../action/ViewContentletAction.java | 2 +- .../contentlet/ajax/ContentletAjax.java | 2 ++ 6 files changed, 63 insertions(+), 2 deletions(-) diff --git a/dotCMS/src/integration-test/java/com/dotcms/contenttype/business/ContentTypeInitializerTest.java b/dotCMS/src/integration-test/java/com/dotcms/contenttype/business/ContentTypeInitializerTest.java index eb67355f6e47..6fe0b8dd39ca 100644 --- a/dotCMS/src/integration-test/java/com/dotcms/contenttype/business/ContentTypeInitializerTest.java +++ b/dotCMS/src/integration-test/java/com/dotcms/contenttype/business/ContentTypeInitializerTest.java @@ -1,16 +1,19 @@ package com.dotcms.contenttype.business; import com.dotcms.IntegrationTestBase; +import com.dotcms.content.elasticsearch.constants.ESMappingConstants; import com.dotcms.contenttype.model.type.ContentType; import com.dotcms.datagen.ContentletDataGen; import com.dotcms.datagen.UserDataGen; import com.dotcms.util.IntegrationTestInitService; import com.dotmarketing.beans.Permission; import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.ApiProvider; import com.dotmarketing.business.PermissionAPI; import com.dotmarketing.business.Role; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.contentlet.model.IndexPolicy; import com.liferay.portal.model.User; import io.vavr.control.Try; import org.junit.Assert; @@ -32,6 +35,35 @@ public static void prepare() throws Exception { IntegrationTestInitService.getInstance().init(); } + /** + * Method to test: {@link com.dotmarketing.portlets.contentlet.business.ContentletAPI#search(String, int, int, String, User, boolean)} + * Given Scenario: Creates a few favorite pages + * ExpectedResult: On search, since favorite pages are system content, they should not be returned + * + */ + @Test + public void test_system_field() throws Exception { + + final ContentType contentType = Try.of(()-> APILocator.getContentTypeAPI(APILocator.systemUser()) + .find(ContentTypeInitializer.FAVORITE_PAGE_VAR_NAME)).getOrNull(); + + new ContentletDataGen(contentType).setPolicy(IndexPolicy.WAIT_FOR) + .setProperty("title", "test").setProperty("url", "test" + System.currentTimeMillis()).nextPersisted(); + new ContentletDataGen(contentType).setPolicy(IndexPolicy.WAIT_FOR) + .setProperty("title", "test").setProperty("url", "test" + System.currentTimeMillis()).nextPersisted(); + new ContentletDataGen(contentType).setPolicy(IndexPolicy.WAIT_FOR) + .setProperty("title", "test").setProperty("url", "test" + System.currentTimeMillis()).nextPersisted(); + + final StringBuffer luceneQuery = new StringBuffer(); + luceneQuery.append("+contentType:"+contentType.variable() + " "); + luceneQuery.append("+" + ESMappingConstants.SYSTEM_TYPE + ":false "); + final List contentlets = APILocator.getContentletAPI().search(luceneQuery.toString(), + 0, 10, null, APILocator.systemUser(), false); + + Assert.assertNotNull("The contentlets shouldn't be null", contentlets); + Assert.assertTrue("Should not return any contentlet since they are system contentlets",contentlets.isEmpty()); + } + /** * Method to test: {@link ContentTypeInitializer#init()} * Given Scenario: If the content type exists is being deleted, and try the initializer to see if works diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESMappingAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESMappingAPIImpl.java index 113c038e1bf0..e5b4122850ae 100644 --- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESMappingAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESMappingAPIImpl.java @@ -369,6 +369,7 @@ public Map toMap(final Contentlet contentlet) throws DotMappingEx } contentletMap.put(ESMappingConstants.TITLE, contentlet.getTitle()); + contentletMap.put(ESMappingConstants.SYSTEM_TYPE, contentType.system()); contentletMap.put(ESMappingConstants.STRUCTURE_NAME, contentType.variable()); // marked for DEPRECATION contentletMap.put(ESMappingConstants.CONTENT_TYPE, contentType.variable()); contentletMap.put(ESMappingConstants.STRUCTURE_TYPE, contentType.baseType().getType()); // marked for DEPRECATION diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/constants/ESMappingConstants.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/constants/ESMappingConstants.java index be397fe132df..2e7fa6d14473 100644 --- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/constants/ESMappingConstants.java +++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/constants/ESMappingConstants.java @@ -9,6 +9,8 @@ */ public final class ESMappingConstants { + + /** * Constructor */ @@ -47,6 +49,10 @@ private ESMappingConstants(){} public static final String TYPE ="type"; public static final String CONTENT = "content"; public static final String INODE = "inode"; + + // boolean that says if a content type is or not a system. + public static final String SYSTEM_TYPE = "systemType"; + public static final String MOD_DATE = "modDate"; public static final String OWNER = "owner"; public static final String MOD_USER = "modUser"; @@ -102,4 +108,4 @@ private ESMappingConstants(){} public static final String SUFFIX_ORDER = "-order"; -} \ No newline at end of file +} diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeInitializer.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeInitializer.java index 1a02c8d62d12..492e23c81426 100644 --- a/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeInitializer.java +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeInitializer.java @@ -71,6 +71,7 @@ private void checkFavoritePage() { .variable(FAVORITE_PAGE_VAR_NAME) .host(Host.SYSTEM_HOST) .host(Folder.SYSTEM_FOLDER) + .system(true) .fixed(true); final SimpleContentType simpleContentType = builder.build(); @@ -90,6 +91,25 @@ private void checkFavoritePage() { if (null != contentType) { checkDefaultPermissions(contentType); + checkIfMarkedAsSystem(contentType, contentTypeAPI); + } + } + + private void checkIfMarkedAsSystem(final ContentType contentType, final ContentTypeAPI contentTypeAPI) { + + if (!contentType.system()) { + + try { + final ImmutableSimpleContentType newType = ImmutableSimpleContentType.builder() + .from(contentType).system(true).build(); + + final ContentType savedContentType = contentTypeAPI.save(newType); + APILocator.getWorkflowAPI().saveSchemeIdsForContentType(savedContentType, + new HashSet<>(Arrays.asList(WorkflowAPI.SYSTEM_WORKFLOW_ID))); + } catch (DotDataException | DotSecurityException e) { + + Logger.warnAndDebug(this.getClass(), e); + } } } diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/action/ViewContentletAction.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/action/ViewContentletAction.java index 5e74b6ec5de2..ff7eff40249d 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/action/ViewContentletAction.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/action/ViewContentletAction.java @@ -160,7 +160,7 @@ else if (UtilMethods.isSet(contentTypesRaw)) { addAll = true; } - contentTypesList.removeIf(t->t.variable().equalsIgnoreCase("forms")); + contentTypesList.removeIf(t->t.variable().equalsIgnoreCase("forms") || t.system()); if (!addAll) { request.setAttribute("contentTypesJs", buildJsArray(contentTypesList)); diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/ajax/ContentletAjax.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/ajax/ContentletAjax.java index a6c7e9d96fb3..8dfb5aac2bd0 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/ajax/ContentletAjax.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/ajax/ContentletAjax.java @@ -629,6 +629,8 @@ public List searchContentletsByUser(List types, String structur break; } } + + luceneQuery.append("+" + ESMappingConstants.SYSTEM_TYPE + ":false "); luceneQuery.append("-contentType:forms "); luceneQuery.append("-contentType:Host "); } From 8d5b0f5e3d02e57ff59ee43df41b69b0a534a97d Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 30 May 2023 11:49:11 -0600 Subject: [PATCH 36/63] Fix #18123 fixing feedback internal qa (#25054) * #18123 fixing feedback internal qa * #18123 fixing feedback internal qa --- .../velocity/viewtools/content/util/ContentUtils.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtils.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtils.java index 23e3ae423ac7..0f18dbd28d46 100644 --- a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtils.java +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/util/ContentUtils.java @@ -824,6 +824,9 @@ public static void addRelationships(final Contentlet contentlet, final User user contentlet.getIdentifier() + ", msg:" + e.getMessage(), e); throw new DotRuntimeException(e); } + } else { + + throw new IllegalArgumentException("Depth must be a number between 0 and 3"); } } From 5a03609d6462a6e05aac44ebab91425e1621243c Mon Sep 17 00:00:00 2001 From: AndreyDotcms <127987858+AndreyDotcms@users.noreply.github.com> Date: Tue, 30 May 2023 16:47:54 -0600 Subject: [PATCH 37/63] Delete validation on publish/expire fields related with content type (#25066) * dotCMS/core#24266 using cache content type values and adding postman tests * dotCMS/core#24266 cleaning the code --- .../curl-test/ContentTypeResourceTests.json | 160 +++++++++++++++++- .../api/v3/contenttype/FieldResource.java | 15 ++ 2 files changed, 172 insertions(+), 3 deletions(-) diff --git a/dotCMS/src/curl-test/ContentTypeResourceTests.json b/dotCMS/src/curl-test/ContentTypeResourceTests.json index 0f52f4cc9db8..79582d0bb4c5 100644 --- a/dotCMS/src/curl-test/ContentTypeResourceTests.json +++ b/dotCMS/src/curl-test/ContentTypeResourceTests.json @@ -1,10 +1,9 @@ { "info": { - "_postman_id": "de4c1a47-b07d-4b24-8b2b-d7d6c987b946", + "_postman_id": "4a7bb840-b421-4ff9-a020-4986dbd0dca1", "name": "ContentType Resource", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "5403727", - "_collection_link": "https://cloudy-robot-285072.postman.co/workspace/JCastro-Workspace~5bfa586e-54db-429b-b7d5-c4ff997e3a0d/collection/5403727-de4c1a47-b07d-4b24-8b2b-d7d6c987b946?action=share&creator=5403727&source=collection_link" + "_exporter_id": "27636414" }, "item": [ { @@ -2667,6 +2666,161 @@ } ], "description": "Verifies that operations related to retrieving data from fields in Content Types are aworking as expected." + }, + { + "name": "Test delete publish/expire field", + "item": [ + { + "name": "Create ContentType with publish/expire fields", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "", + "pm.collectionVariables.set(\"contentTypeID\", jsonData.entity[0].id);", + "pm.collectionVariables.set(\"contentTypeVAR\", jsonData.entity[0].variable);", + "pm.collectionVariables.set(\"contentTypeFieldID\", jsonData.entity[0].fields[0].id);", + "", + "pm.test(\"Status code should be ok 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"icon check\", function () {", + " pm.expect(jsonData.entity[0].icon).to.eql('testIcon');", + "});", + "", + "pm.test(\"Fields check\", function () {", + " pm.expect(jsonData.entity[0].fields.length).to.eql(2);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "saveHelperData", + "type": "any" + }, + { + "key": "showPassword", + "value": false, + "type": "boolean" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"clazz\": \"com.dotcms.contenttype.model.type.SimpleContentType\",\n\t\"description\": \"My Structure\",\n\t\"defaultType\": false,\n\t\"system\": false,\n\t\"folder\": \"SYSTEM_FOLDER\",\n\t\"name\": \"Test dates content {{$randomBankAccount}}\",\n\t\"variable\": \"testDatesContent{{$randomBankAccount}}\",\n\t\"host\": \"SYSTEM_HOST\",\n\t\"fixed\": false,\n \"icon\": \"testIcon\",\n \"sortOrder\": 3,\n \"publishDateVar\": \"publishDate\",\n \"expireDateVar\": \"expireDate\",\n\t\"fields\": [\n {\n \"clazz\": \"com.dotcms.contenttype.model.field.ImmutableDateTimeField\",\n \"dataType\": \"DATE\",\n \"fieldType\": \"Date-and-Time\",\n \"fieldTypeLabel\": \"Date and Time\",\n \"fieldVariables\": [],\n \"fixed\": false,\n \"forceIncludeInApi\": false,\n \"indexed\": true,\n \"listed\": true,\n \"name\": \"Publish Date\",\n \"readOnly\": false,\n \"required\": false,\n \"searchable\": false,\n \"sortOrder\": 3,\n \"unique\": false,\n \"variable\": \"publishDate\"\n },\n {\n \"clazz\": \"com.dotcms.contenttype.model.field.ImmutableDateTimeField\",\n \"dataType\": \"DATE\",\n \"fieldType\": \"Date-and-Time\",\n \"fieldTypeLabel\": \"Date and Time\",\n \"fieldVariables\": [],\n \"fixed\": false,\n \"forceIncludeInApi\": false,\n \"iDate\": 1685054935000,\n \"indexed\": true,\n \"listed\": true,\n \"modDate\": 1685054935000,\n \"name\": \"Expire Date\",\n \"readOnly\": false,\n \"required\": false,\n \"searchable\": false,\n \"sortOrder\": 3,\n \"unique\": false,\n \"variable\": \"expireDate\"\n }\n\n\t],\n \"workflow\":[\"d61a59e1-a49c-46f2-a929-db2b4bfa88b2\"]\n}" + }, + "url": { + "raw": "{{serverURL}}/api/v1/contenttype", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "contenttype" + ] + }, + "description": "Given a content type payload containing field variables.\nWhen sending a POST.\nExpect that code is 200.\nExpect content type is created with the provided fields.\nExpect that new properties of content types are set (icon and sortOrder)." + }, + "response": [] + }, + { + "name": "Delete dateTime field", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 400\", function () {", + " pm.response.to.have.status(400);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "saveHelperData", + "type": "any" + }, + { + "key": "showPassword", + "value": false, + "type": "boolean" + } + ] + }, + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "{\"fieldsID\": [\"{{contentTypeFieldID}}\"]}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v3/contenttype/{{contentTypeID}}/fields", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v3", + "contenttype", + "{{contentTypeID}}", + "fields" + ] + }, + "description": "Given a content type ID.\nExpect that code is 200.\nExpect content type is deleted successfully." + }, + "response": [] + } + ] } ], "variable": [ diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v3/contenttype/FieldResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v3/contenttype/FieldResource.java index b13c7f6b6fbc..a2feea9befd9 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v3/contenttype/FieldResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v3/contenttype/FieldResource.java @@ -22,6 +22,10 @@ import javax.servlet.http.HttpServletRequest; import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; + import static com.dotcms.util.CollectionsUtils.map; /** @@ -206,6 +210,17 @@ public Response deleteFields( final List fieldsID = deleteFieldsForm.getFieldsID(); final ContentType contentType = APILocator.getContentTypeAPI(user).find(typeIdOrVarName); + final String publishDateVar = contentType.publishDateVar(); + final String expireDateVar = contentType.expireDateVar(); + + final List filteredFields = contentType.fields().stream().filter(field -> fieldsID.contains(field.id())).collect(Collectors.toList()); + + for (final Field field : filteredFields) { + if ((publishDateVar != null && publishDateVar.equals(field.variable())) || + (expireDateVar != null && expireDateVar.equals(field.variable()))){ + throw new DotDataException("Field is being used as Publish or Expire Field at Content Type Level. Please unlink the field before deleting it."); + } + }; final ContentTypeFieldLayoutAPI.DeleteFieldResult deleteFieldResult = this.contentTypeFieldLayoutAPI.deleteField(contentType, fieldsID, user); From f4b0478055922301a66ca53fdb8ac163605e7598 Mon Sep 17 00:00:00 2001 From: Fabrizzio Araya <37148755+fabrizzio-dotCMS@users.noreply.github.com> Date: Tue, 30 May 2023 18:17:00 -0600 Subject: [PATCH 38/63] #25079 adding missing sort clause in new code (#25086) --- .../business/ESContentFactoryImplTest.java | 14 ++++++++++++++ .../business/ESContentFactoryImpl.java | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/dotCMS/src/integration-test/java/com/dotcms/content/elasticsearch/business/ESContentFactoryImplTest.java b/dotCMS/src/integration-test/java/com/dotcms/content/elasticsearch/business/ESContentFactoryImplTest.java index 2397f5174554..2a5af7c60b25 100644 --- a/dotCMS/src/integration-test/java/com/dotcms/content/elasticsearch/business/ESContentFactoryImplTest.java +++ b/dotCMS/src/integration-test/java/com/dotcms/content/elasticsearch/business/ESContentFactoryImplTest.java @@ -61,6 +61,7 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.Date; import java.util.HashSet; import java.util.List; @@ -69,6 +70,7 @@ import java.util.Random; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.IntStream; import org.apache.commons.lang3.BooleanUtils; import org.elasticsearch.search.SearchHit; import org.elasticsearch.search.SearchHits; @@ -1337,6 +1339,12 @@ public void findAllVersionsByVariant() throws DotDataException, DotSecurityExcep .equals(contentletLanguage2DefaultVariant.getIdentifier()))); assertTrue(contentlets.stream().anyMatch(contentlet -> contentlet.getIdentifier() .equals(contentletLanguage3DefaultVariant.getIdentifier()))); + + //Now test findAllVersions is returning the versions in descending order + final List copy = new ArrayList<>(contentlets); + copy.sort((o1, o2) -> o2.getModDate().compareTo(o1.getModDate())); + assertEquals(copy,contentlets); + } /** @@ -1410,6 +1418,12 @@ public void findAllVersionsWithOldVersionsByVariant() throws DotDataException, D expectedInodes.forEach(inode -> assertTrue(contentlets.stream() .anyMatch(contentlet -> contentlet.getInode().equals(inode)))); + + //Now test findAllVersions is returning the versions in descending order + final List copy = new ArrayList<>(contentlets); + copy.sort((o1, o2) -> o2.getModDate().compareTo(o1.getModDate())); + assertEquals(copy,contentlets); + } } diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentFactoryImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentFactoryImpl.java index 237dfcefa0f3..ae17a7db3257 100644 --- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentFactoryImpl.java +++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentFactoryImpl.java @@ -1021,7 +1021,7 @@ protected List findAllVersions(final Identifier identifier, final V return new DotConnect() .setSQL(String.format( - "select inode from contentlet where identifier = ? and %s = ?", + "select inode from contentlet where identifier = ? and %s = ? order by mod_date desc", columnName)) .addParam(identifier.getId()) .addParam(variant.name()) From e4697b98ebc8014c290b3c42635ac2ce62615f7e Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 30 May 2023 18:23:17 -0600 Subject: [PATCH 39/63] #25081 fixing the ct test (#25082) * #25081 fixing the ct test * Update assert message --------- Co-authored-by: Nollymar Longa --- .../util/pagination/ContentTypesPaginatorTest.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dotCMS/src/integration-test/java/com/dotcms/util/pagination/ContentTypesPaginatorTest.java b/dotCMS/src/integration-test/java/com/dotcms/util/pagination/ContentTypesPaginatorTest.java index 351d05639b98..2484ae399127 100644 --- a/dotCMS/src/integration-test/java/com/dotcms/util/pagination/ContentTypesPaginatorTest.java +++ b/dotCMS/src/integration-test/java/com/dotcms/util/pagination/ContentTypesPaginatorTest.java @@ -215,7 +215,7 @@ public void getAllAllowedContentTypesOrderedByName() { // Initialization final String DEFAULT_ORDER_BY = "UPPER(name)"; final String NO_FILTER = StringPool.BLANK; - final String contentTypeVars = "webPageContent,calendarEvent,Vanityurl,DotAsset,htmlpageasset"; + final String contentTypeVars = "webPageContent,Vanityurl,DotAsset,htmlpageasset"; final List typeVarNames = Arrays.asList(contentTypeVars.split(COMMA)); final Map extraParams = new HashMap<>(); extraParams.put(ContentTypesPaginator.TYPES_PARAMETER_NAME, typeVarNames); @@ -226,7 +226,7 @@ public void getAllAllowedContentTypesOrderedByName() { paginator.getItems(user, NO_FILTER, -1, -1, DEFAULT_ORDER_BY, OrderDirection.ASC, extraParams); // Assertions - assertEquals("There must be 5 Content Types returned by the paginator", 5, contentTypes.size()); + assertEquals("There must be 4 Content Types returned by the paginator", 4, contentTypes.size()); assertEquals("The 'Content (Generic)' type must come first", "Content (Generic)", contentTypes.get(0).get("name").toString()); } @@ -243,8 +243,8 @@ public void getAllAllowedContentTypesOrderedByName() { public void getFilteredAllowedContentTypesOrderedByName() { // Initialization final String DEFAULT_ORDER_BY = "UPPER(name)"; - final String FILTER = "ent"; - final String contentTypeVars = "webPageContent,calendarEvent,Vanityurl,DotAsset,htmlpageasset"; + final String FILTER = "va"; + final String contentTypeVars = "webPageContent,Vanityurl,DotAsset,htmlpageasset,languagevariable,vanityurl"; final List typeVarNames = Arrays.asList(contentTypeVars.split(COMMA)); final Map extraParams = new HashMap<>(); extraParams.put(ContentTypesPaginator.TYPES_PARAMETER_NAME, typeVarNames); @@ -256,7 +256,7 @@ public void getFilteredAllowedContentTypesOrderedByName() { // Assertions assertEquals("There must be 2 Content Types returned by the paginator", 2, contentTypes.size()); - assertEquals("The 'Content (Generic)' type must come first", "Content (Generic)", + assertEquals("The 'Language Variable' type must come first", "Language Variable", contentTypes.get(0).get("name").toString()); } From ec9b32709a35e83e5d64f8f5831bed3385a9ce48 Mon Sep 17 00:00:00 2001 From: Nollymar Longa Date: Tue, 30 May 2023 19:31:12 -0500 Subject: [PATCH 40/63] Issue 23934 fix urlmap titles (#25084) * #23934 Tika fragment has been added in order to resolve tika dependencies issue. * SLF4J has been upgraded to 1.7.35 * org.apache.logging.log4j has been included as part of osgi-extra.conf to avoid dependency clashes inside of Tika Microsoft parsers. * #23934 Including keys from tika 1.x that were missing in tika 2.x * #23934 Removing log4j libraries from felix-system --------- Co-authored-by: daniel.colina Co-authored-by: nollymar --- dotCMS/build.gradle | 4 +- .../dotcms/storage/FileMetadataAPITest.java | 24 ++ .../storage/5-snow-sports-to-try-this-winter | 288 ++++++++++++++++++ .../storage/model/ExtendedMetadataFields.java | 55 ++++ .../main/java/com/dotcms/tika/TikaUtils.java | 17 ++ .../src/main/resources/osgi/osgi-extra.conf | 1 + .../resources/osgi/system/osgi-extra.conf | 1 + 7 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 dotCMS/src/integration-test/resources/com/dotcms/storage/5-snow-sports-to-try-this-winter create mode 100644 dotCMS/src/main/java/com/dotcms/storage/model/ExtendedMetadataFields.java diff --git a/dotCMS/build.gradle b/dotCMS/build.gradle index 88753e3b00f9..d0cc68cbc1bf 100644 --- a/dotCMS/build.gradle +++ b/dotCMS/build.gradle @@ -283,8 +283,8 @@ dependencies { transitive = false } - felix group: 'org.slf4j', name: 'slf4j-simple', version: '1.7.25' - felix group: 'org.slf4j', name: 'jcl-over-slf4j', version: '1.7.25' + felix group: 'org.slf4j', name: 'slf4j-simple', version: '1.7.35' + felix group: 'org.slf4j', name: 'jcl-over-slf4j', version: '1.7.35' //// Felix system configuration felixsystem group: 'com.dotcms.tika', name: 'com.dotcms.tika', version: '2.7.0' diff --git a/dotCMS/src/integration-test/java/com/dotcms/storage/FileMetadataAPITest.java b/dotCMS/src/integration-test/java/com/dotcms/storage/FileMetadataAPITest.java index cbe4c5b6ca1a..211e6f44d6c4 100644 --- a/dotCMS/src/integration-test/java/com/dotcms/storage/FileMetadataAPITest.java +++ b/dotCMS/src/integration-test/java/com/dotcms/storage/FileMetadataAPITest.java @@ -26,6 +26,7 @@ import com.dotcms.storage.model.BasicMetadataFields; import com.dotcms.storage.model.ContentletMetadata; import com.dotcms.storage.model.Metadata; +import com.dotcms.util.CollectionsUtils; import com.dotcms.util.IntegrationTestInitService; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.CacheLocator; @@ -45,10 +46,12 @@ import java.io.Serializable; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.SortedSet; +import java.util.function.Supplier; import javax.servlet.http.HttpServletRequest; import org.apache.commons.io.FilenameUtils; import org.junit.Assert; @@ -92,6 +95,27 @@ public void Test_Get_Metadata_Property() throws Exception { assertTrue(fileAssetContent.get(FileAssetAPI.META_DATA_FIELD) instanceof Map); } + /** + * Method to test: {@link FileMetadataAPI#getFullMetadataNoCache(File, Supplier)}
    + * Given scenario: Getting metadata from a urlMap
    + * Expected Result: Some keywords must be present in the metadata + * @throws Exception + */ + @Test + public void Test_Generate_Metadata_From_HtmlPage_Should_Resolve_ExtendedMetadata() throws Exception { + prepareIfNecessary(); + final List extendedMetadata = CollectionsUtils.list("metaKeyword", "keywords", "dcSubject", + "title", "dcTitle", "description", "copyright", "ogTitle", "language", "ogUrl", "ogImage"); + Metadata metadata = fileMetadataAPI.getFullMetadataNoCache(new + File(FileMetadataAPITest.class.getResource("5-snow-sports-to-try-this-winter").getFile()), + null); + assertNotNull(metadata); + assertTrue(metadata.getMap().keySet().containsAll(extendedMetadata)); + assertEquals("5 Snow Sports to Try This Winter", metadata.getMap().get("dcTitle")); + assertEquals("5 Snow Sports to Try This Winter", metadata.getMap().get("title")); + + } + /** * This test evaluates both basic vs full MD diff --git a/dotCMS/src/integration-test/resources/com/dotcms/storage/5-snow-sports-to-try-this-winter b/dotCMS/src/integration-test/resources/com/dotcms/storage/5-snow-sports-to-try-this-winter new file mode 100644 index 000000000000..637c4f66e73a --- /dev/null +++ b/dotCMS/src/integration-test/resources/com/dotcms/storage/5-snow-sports-to-try-this-winter @@ -0,0 +1,288 @@ + + + + + + + + + + + + +5 Snow Sports to Try This Winter + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + +
    +
    +
    + + + +
    + + + +
    + +
    +

    5 Snow Sports to Try This Winter

    +
    +
    +
    +
    + + by John Smith +
    +
    + + +
    +
    +
    +
    +
    +
    + + 193 +
    +
    + + 3 +
    +
    +
    +
    +
    + + +
    + + 5 Snow Sports to Try This Winter + cross-country skiing +
    + + +
    + +

    + + + Checkout the following sports to have fun this winter. + + +

    + +

    + + + Snow Shoeing + + +

    + +

    + + + Snowshoeing is the fasted growing winter sport in the world, primarily because it is simple to get to grips with. There are many levels of snowshoeing, whether you want to hike for pleasure, trek through the backcountry, or competitively race. Snowshoeing is a fantastic alternative for skiing, especially if you like running! + + +

    + +

    + + + Ice Skating + + +

    + +

    + + + A fun activity with the kids, a silly activity to try after a few drinks, or a fantastic innovative date idea, ice skating is great fun for children and adults alike. However, if you’re a bit unsteady on your feet, just make sure you take a good friend you can desperately cling to + + +

    + +

    + + + Snow Tubing + + +

    + +

    + + + Tubing is often seen as something for kids to enjoy, but it can be a rewarding snow sport for adults, too. Sliding down the slopes is a great way to let go of work stress and have fun without a lot of effort. It also burns a surprising number of calories. If you want to mix up your workout routine or need a break from some of your heavier routines, consider giving tubing a try. + + +

    + +

    + + + Ice Climbing + + +

    + +

    + + + Climbing up a vertical sheet of ice may seem like an extreme sport (and it can be), but if you enlist a certified instructor to get you started, ice climbing is actually surprisingly accessible for beginners. In fact, because you wear special shoes (crampons) with spikes on the front, it's actually easier to get your footing than when going rock climbing. + + +

    + +

    + + + Fat Biking + + +

    + +

    + + + You don't need to give up on cycling as soon as cold weather hits, but you might need to rent or buy a bike designed to take on the snow. "Fat bikes" are outfitted with oversized fat tires. + + +

    + +

    + + + Destinations + + +

    + +

    + + + A couple of places we recommend visiting to do this type of sports: + + +

    + +
    + Colorado & The Rockies + +
    +

    Colorado & The Rockies

    + + Book an Colorado vacation rental and get ready to experience small-town charm infused with luxury living in one of the top US vacation destinations. + +
    + +
    +
    + French Alps + +
    +

    French Alps

    + + Courchevel is part of the world’s biggest lift-linked ski area, has the most balanced range of runs in the 3 Vallées to suit all abilities, reliable snow: what more could you wish for? You could come to Les 3 Vallées for weeks and never ski the same slope twice; and there’s no better place to experience it all than at Courchevel! Courchevel’s four separate villages sit amongst the north-facing slopes on the left side of the map and offer probably the most balanced selection of greens, blues, reds, and blacks in the 3 Vallées, making it our top resort here. If you’re up for exploring more however, the ski area is well linked so getting to the other end isn’t too difficult. + +
    + +
    +
    + + + +
    + + +
    + + + +
    +

    Comments

    + +
    + + + + + + +
    +
    +

    Leave a Comment

    + +
    + + + + + +
    + + +
    + +
    + +
    +
    + + + +
    +
    + +
    +
    +
    + +
    + + + + + \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/storage/model/ExtendedMetadataFields.java b/dotCMS/src/main/java/com/dotcms/storage/model/ExtendedMetadataFields.java new file mode 100644 index 000000000000..7725867e3b1c --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/storage/model/ExtendedMetadataFields.java @@ -0,0 +1,55 @@ +package com.dotcms.storage.model; + +import com.dotcms.util.CollectionsUtils; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Encapsulates a collection with keys generated by Tika. + * It is used to translate keys from Tika 2.0 to the old ones (list with possible values). + */ +public enum ExtendedMetadataFields { + + DC_CREATOR("dcCreator", CollectionsUtils.list("author, metaAuthor")), + META_LAST_AUTHOR("metaLastAuthor", CollectionsUtils.list("lastAuthor")), + DC_TITLE("dcTitle", CollectionsUtils.list("title")), + DC_TERMS_CREATED("dctermsCreated", CollectionsUtils.list("date", "creationDate")), + DC_TERMS_MODIFIED("dctermsModified", CollectionsUtils.list("lastModified", "modified")), + + META_SAVE_DATE("metaSaveDate", CollectionsUtils.list("lastSaveDate")), + EXTENDED_PROPERTIES_APPLICATION("extendedPropertiesApplication", CollectionsUtils.list("applicationName")), + META_CHARACTER_COUNT("metaCharacterCount", CollectionsUtils.list("characterCount")), + EXTENDED_PROPERTIES_COMPANY("extendedPropertiesCompany", CollectionsUtils.list("company")), + EXTENDED_PROPERTIES_TOTAL_TIME("extendedPropertiesTotalTime", CollectionsUtils.list("editTime")), + META_KEYWORD("metaKeyword", CollectionsUtils.list("keywords", "dcSubject")), + META_PAGE_COUNT("metaPageCount", CollectionsUtils.list("pageCount")), + REVISION_NUMBER("cpRevision", CollectionsUtils.list("revisionNumber")), + DC_SUBJECT("dcSubject", CollectionsUtils.list("subject", "cpSubject", "metaKeyword", "keywords")), + EXTENDED_TEMPLATE("extendedPropertiesTemplate", CollectionsUtils.list("template")), + WORD_COUNT("metaWordCount", CollectionsUtils.list("wordCount")), + DC_IDENTIFIER("dcIdentifier", CollectionsUtils.list("identifier")), + DC_PUBLISHER("dcPublisher", CollectionsUtils.list("publisher")); + + private final String key; + private final List possibleValues; + + ExtendedMetadataFields(String key, List possibleValues) { + this.key = key; + this.possibleValues = possibleValues; + } + + public String key() { + return key; + } + + public List possibleValues() { + return possibleValues; + } + + public static Map> keyMap() { + return Stream.of(values()).collect( + Collectors.toMap(ExtendedMetadataFields::key, ExtendedMetadataFields::possibleValues)); + } +} \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/tika/TikaUtils.java b/dotCMS/src/main/java/com/dotcms/tika/TikaUtils.java index a775ba2135c8..2c80ece1526f 100644 --- a/dotCMS/src/main/java/com/dotcms/tika/TikaUtils.java +++ b/dotCMS/src/main/java/com/dotcms/tika/TikaUtils.java @@ -4,6 +4,7 @@ import com.dotcms.contenttype.model.field.Field; import com.dotcms.contenttype.model.type.BaseContentType; import com.dotcms.osgi.OSGIConstants; +import com.dotcms.storage.model.ExtendedMetadataFields; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; import com.dotcms.rest.api.v1.DotObjectMapperProvider; @@ -384,6 +385,9 @@ public Map getForcedMetaDataMap(final File binFile, //Creating the meta data map to use by our content metaMap.putAll(this.buildMetaDataMap()); metaMap.put(FileAssetAPI.CONTENT_FIELD, content); + + //Adding missing keys that were excluded in Tika 2.0 + includeMissingKeys(metaMap); } catch (IOException ioExc) { if (this.isZeroByteFileException(ioExc.getCause())) { logWarning(binFile, ioExc.getCause()); @@ -403,6 +407,19 @@ public Map getForcedMetaDataMap(final File binFile, return metaMap; } + /** + * This method adds missing keys from Tika 1.x that were excluded in Tika 2.0. For example: keywords and title + * For further details, please visit https://cwiki.apache.org/confluence/display/TIKA/Migrating+to+Tika+2.0.0 + * @param metaMap + */ + private static void includeMissingKeys(Map metaMap) { + ExtendedMetadataFields.keyMap().forEach((key, value) -> { + if(metaMap.containsKey(key)){ + value.forEach(v -> metaMap.putIfAbsent(v, metaMap.get(key))); + } + }); + } + private void parseFallbackAsPlainText(final File binFile, final Map metaMap, final IOException ioExc) { try { //On error lets try a fallback operation diff --git a/dotCMS/src/main/resources/osgi/osgi-extra.conf b/dotCMS/src/main/resources/osgi/osgi-extra.conf index 7667bd361d3e..ce0c511f3cf9 100644 --- a/dotCMS/src/main/resources/osgi/osgi-extra.conf +++ b/dotCMS/src/main/resources/osgi/osgi-extra.conf @@ -645,6 +645,7 @@ javax.xml.xpath;version=0.1.0, net.spy.memcached;version=0.1.0, net.spy.memcached.internal;version=0.1.0, net.spy.memcached.transcoders;version=0.1.0, +org.apache.logging.log4j; org.apache.commons.codec;version=0.1.0, org.apache.commons.codec.binary;version=0.1.0, org.apache.commons.codec.digest;version=0.1.0, diff --git a/dotCMS/src/main/resources/osgi/system/osgi-extra.conf b/dotCMS/src/main/resources/osgi/system/osgi-extra.conf index 7667bd361d3e..ce0c511f3cf9 100644 --- a/dotCMS/src/main/resources/osgi/system/osgi-extra.conf +++ b/dotCMS/src/main/resources/osgi/system/osgi-extra.conf @@ -645,6 +645,7 @@ javax.xml.xpath;version=0.1.0, net.spy.memcached;version=0.1.0, net.spy.memcached.internal;version=0.1.0, net.spy.memcached.transcoders;version=0.1.0, +org.apache.logging.log4j; org.apache.commons.codec;version=0.1.0, org.apache.commons.codec.binary;version=0.1.0, org.apache.commons.codec.digest;version=0.1.0, From 0b8f7d6fdd12d231bb91bc421c56f1697680f519 Mon Sep 17 00:00:00 2001 From: Rafael Velazco Date: Wed, 31 May 2023 12:43:34 -0400 Subject: [PATCH 41/63] Fix #25016 Template Builder: Develop Custom Components (#25049) * dev: custom component for template builder drag box #25016 * fix: test * rename component from drag-box to add-widget * clean up * feedback * clean up v2 * fix test * add: base64 icons * fix: test * feedback v2 * dev: add icon fallback --- .../libs/dotcms-scss/shared/_shadows.scss | 1 + .../template-builder/assets/icons.ts | 5 ++ .../add-widget/add-widget.component.html | 12 +++ .../add-widget/add-widget.component.scss | 35 ++++++++ .../add-widget/add-widget.component.spec.ts | 80 +++++++++++++++++++ .../add-widget.component.stories.ts | 33 ++++++++ .../add-widget/add-widget.component.ts | 17 ++++ .../template-builder-row.component.html | 11 +-- .../template-builder-row.component.spec.ts | 8 +- .../template-builder-row.component.ts | 4 +- .../template-builder/models/models.ts | 2 + .../template-builder.component.html | 28 +++---- .../template-builder.component.scss | 16 ++-- .../template-builder.component.spec.ts | 5 +- .../template-builder.component.stories.ts | 3 +- .../template-builder.component.ts | 6 +- .../src/lib/template-builder.module.ts | 3 +- 17 files changed, 231 insertions(+), 38 deletions(-) create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/assets/icons.ts create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.html create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.scss create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.spec.ts create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.stories.ts create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.ts diff --git a/core-web/libs/dotcms-scss/shared/_shadows.scss b/core-web/libs/dotcms-scss/shared/_shadows.scss index 162969db8041..f514b20c0023 100644 --- a/core-web/libs/dotcms-scss/shared/_shadows.scss +++ b/core-web/libs/dotcms-scss/shared/_shadows.scss @@ -3,3 +3,4 @@ $md-shadow-2: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23); $md-shadow-3: 0 10px 24px 0 rgba(0, 0, 0, 0.2); $md-shadow-4: 0 2px 4px 0 rgba(0, 0, 0, 0.1); $md-shadow-5: 0 10px 20px 0 rgba(0, 0, 0, 0.15); +$md-shadow-6: 0px 0px 4px rgba(20, 21, 26, 0.04), 0px 8px 16px rgba(20, 21, 26, 0.08); diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/assets/icons.ts b/core-web/libs/template-builder/src/lib/components/template-builder/assets/icons.ts new file mode 100644 index 000000000000..030e112a585d --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/assets/icons.ts @@ -0,0 +1,5 @@ +export const rowIcon = + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3QgeD0iMyIgeT0iMyIgd2lkdGg9IjE4IiBoZWlnaHQ9IjQiIHJ4PSIyIiBmaWxsPSIjRDFENERCIi8+CjxyZWN0IHg9IjMiIHk9IjEwIiB3aWR0aD0iMTgiIGhlaWdodD0iNCIgcng9IjIiIGZpbGw9IiNEMUQ0REIiLz4KPHJlY3QgeD0iMyIgeT0iMTciIHdpZHRoPSIxOCIgaGVpZ2h0PSI0IiByeD0iMiIgZmlsbD0iIzQyNkJGMCIvPgo8L3N2Zz4K'; + +export const colIcon = + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3QgeD0iMyIgeT0iOCIgd2lkdGg9IjgiIGhlaWdodD0iOCIgcng9IjIiIGZpbGw9IiNEMUQ0REIiLz4KPHJlY3QgeD0iMTMiIHk9IjgiIHdpZHRoPSI4IiBoZWlnaHQ9IjgiIHJ4PSIyIiBmaWxsPSIjNDI2QkYwIi8+Cjwvc3ZnPgo='; diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.html b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.html new file mode 100644 index 000000000000..cbe6a31e0dfe --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.html @@ -0,0 +1,12 @@ +
    +
    + + drag-icon + +
    + {{ label }} +
    + + + {{ icon }} + diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.scss b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.scss new file mode 100644 index 000000000000..0bd31bad217d --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.scss @@ -0,0 +1,35 @@ +@use "variables" as *; + +:host { + display: block; +} + +.add-widget { + display: flex; + justify-content: center; + align-items: center; + padding: $spacing-1 $spacing-3; + gap: $spacing-3; + width: fit-content; + background: $white; + border: 1.5px solid $color-palette-primary-500; + border-radius: $border-radius-md; + + &:hover { + box-shadow: $md-shadow-6; + } + + .add-widget__label { + font-size: $font-size-md; + line-height: 140%; + color: $black; + } + + .add-widget__icon { + display: flex; + justify-content: center; + align-items: center; + min-width: $spacing-4; + min-height: $spacing-4; + } +} diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.spec.ts new file mode 100644 index 000000000000..175ad8c51d2a --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.spec.ts @@ -0,0 +1,80 @@ +import { Component, DebugElement, Input } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { AddWidgetComponent } from './add-widget.component'; + +import { colIcon, rowIcon } from '../../assets/icons'; + +@Component({ + selector: 'dotcms-host-component', + template: ` ` +}) +class HostComponent { + @Input() label = 'Add Widget'; + @Input() icon = rowIcon; +} + +describe('AddWidgetComponent', () => { + let fixture: ComponentFixture; + let de: DebugElement; + let component: AddWidgetComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [HostComponent], + imports: [AddWidgetComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(HostComponent); + + component = fixture.debugElement.query(By.css('dotcms-add-widget')).componentInstance; + de = fixture.debugElement; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('inputs', () => { + it('should set label', () => { + expect(component.label).toBe('Add Widget'); + }); + + it('should have row icon', () => { + expect(component.icon).toBe(rowIcon); + }); + + it('should have col icon', () => { + component.icon = colIcon; + fixture.detectChanges(); + expect(component.icon).toBe(colIcon); + }); + }); + + describe('template', () => { + it('should have label', () => { + de.query(By.css('[data-testid="cancelBtn"]')); + const label = de.query(By.css('[data-testid="addWidgetLabel"]')); + expect(label.nativeElement.textContent).toBe('Add Widget'); + }); + + it('should have a image element with the row icon', () => { + component.icon = rowIcon; + fixture.detectChanges(); + const img = de.query(By.css('img')); + expect(img.nativeElement.src).toContain(rowIcon); + }); + + it('it should have material icon element when image load fails', () => { + component.icon = 'add'; + fixture.detectChanges(); + const img = de.query(By.css('img')); + img.triggerEventHandler('error', null); + fixture.detectChanges(); + const icon = de.query(By.css('.material-icons')); + expect(icon?.nativeElement.textContent).toContain('add'); + }); + }); +}); diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.stories.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.stories.ts new file mode 100644 index 000000000000..81fdc9e5526e --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.stories.ts @@ -0,0 +1,33 @@ +import { moduleMetadata, Story, Meta } from '@storybook/angular'; + +import { AddWidgetComponent } from './add-widget.component'; + +import { rowIcon } from '../../assets/icons'; + +export default { + title: 'AddWidgetComponent', + component: AddWidgetComponent, + decorators: [ + moduleMetadata({ + imports: [] + }) + ] +} as Meta; + +const Template: Story = (args: AddWidgetComponent) => ({ + props: args +}); + +export const Primary = Template.bind({}); + +export const MaterialIcon = Template.bind({}); + +Primary.args = { + label: 'Add Row', + icon: rowIcon +}; + +MaterialIcon.args = { + label: 'Add Box', + icon: 'add' +}; diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.ts new file mode 100644 index 000000000000..6507a518ca07 --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.ts @@ -0,0 +1,17 @@ +import { NgIf } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +@Component({ + selector: 'dotcms-add-widget', + templateUrl: './add-widget.component.html', + styleUrls: ['./add-widget.component.scss'], + standalone: true, + imports: [NgIf], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AddWidgetComponent { + @Input() label = 'Add Widget'; + @Input() icon = ''; + + protected imageError = false; +} diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.html b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.html index 7648e72c0304..63f3e32caaad 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.html +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.html @@ -1,10 +1,11 @@
    - + > + drag_indicator +
    diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.spec.ts index 4fc7e776beda..035118bca98c 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.spec.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.spec.ts @@ -6,8 +6,6 @@ import { By } from '@angular/platform-browser'; import { ButtonModule } from 'primeng/button'; -import { DotIconModule } from '@dotcms/ui'; - import { TemplateBuilderRowComponent } from './template-builder-row.component'; @Component({ @@ -34,7 +32,7 @@ describe('TemplateBuilderRowComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [DotIconModule, ButtonModule, TemplateBuilderRowComponent], + imports: [ButtonModule, TemplateBuilderRowComponent], declarations: [HostComponent] }).compileComponents(); @@ -51,9 +49,7 @@ describe('TemplateBuilderRowComponent', () => { }); it('should have a drag handler', () => { - expect( - fixture.debugElement.query(By.css('dot-icon[data-testid="row-drag-handler"]')) - ).toBeTruthy(); + expect(fixture.debugElement.query(By.css('[data-testid="row-drag-handler"]'))).toBeTruthy(); }); it('should have a style class edit button', () => { expect( diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.ts index 52740c065899..c86f489a093c 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.ts @@ -2,12 +2,10 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angul import { ButtonModule } from 'primeng/button'; -import { DotIconModule } from '@dotcms/ui'; - @Component({ selector: 'dotcms-template-builder-row', standalone: true, - imports: [DotIconModule, ButtonModule], + imports: [ButtonModule], templateUrl: './template-builder-row.component.html', styleUrls: ['./template-builder-row.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/models/models.ts b/core-web/libs/template-builder/src/lib/components/template-builder/models/models.ts index 5de6554f9665..886c29a9284a 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/models/models.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/models/models.ts @@ -26,3 +26,5 @@ export interface DotGridStackNode extends GridStackNode { export interface DotTemplateBuilderState { items: DotGridStackWidget[]; } + +export type WidgetType = 'col' | 'row'; diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.html b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.html index dffe70295acb..d2d18c1e057a 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.html +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.html @@ -1,18 +1,18 @@
    -
    -
    -
    - ROW -
    -
    -
    -
    -
    -
    - COL -
    -
    -
    + +
    { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [TemplateBuilderComponent], - providers: [DotTemplateBuilderStore] + providers: [DotTemplateBuilderStore], + imports: [AddWidgetComponent, TemplateBuilderRowComponent] }).compileComponents(); fixture = TestBed.createComponent(TemplateBuilderComponent); diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts index b0e59a41f4af..ed5f5caf0313 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts @@ -2,6 +2,7 @@ import { moduleMetadata, Story, Meta } from '@storybook/angular'; import { NgFor, AsyncPipe } from '@angular/common'; +import { AddWidgetComponent } from './components/add-widget/add-widget.component'; import { TemplateBuilderRowComponent } from './components/template-builder-row/template-builder-row.component'; import { DotTemplateBuilderStore } from './store/template-builder.store'; import { TemplateBuilderComponent } from './template-builder.component'; @@ -12,7 +13,7 @@ export default { component: TemplateBuilderComponent, decorators: [ moduleMetadata({ - imports: [NgFor, AsyncPipe, TemplateBuilderRowComponent], + imports: [NgFor, AsyncPipe, TemplateBuilderRowComponent, AddWidgetComponent], providers: [DotTemplateBuilderStore] }) ] diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.ts b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.ts index 6900d33267ea..51757c5f6eb1 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.ts @@ -21,6 +21,7 @@ import { import { DotLayout } from '@dotcms/dotcms-models'; +import { colIcon, rowIcon } from './assets/icons'; import { DotGridStackWidget } from './models/models'; import { DotTemplateBuilderStore } from './store/template-builder.store'; import { gridOptions, subGridOptions } from './utils/gridstack-options'; @@ -50,6 +51,9 @@ export class TemplateBuilderComponent implements OnInit, AfterViewInit, OnDestro grid!: GridStack; + public readonly rowIcon = rowIcon; + public readonly colIcon = colIcon; + constructor(private store: DotTemplateBuilderStore) { this.items$ = this.store.items$; } @@ -63,7 +67,7 @@ export class TemplateBuilderComponent implements OnInit, AfterViewInit, OnDestro this.store.moveRow(nodes as DotGridStackWidget[]); }); - GridStack.setupDragIn('.add', { + GridStack.setupDragIn('dotcms-add-widget', { appendTo: 'body', helper: 'clone' }); diff --git a/core-web/libs/template-builder/src/lib/template-builder.module.ts b/core-web/libs/template-builder/src/lib/template-builder.module.ts index 10e8cbd7c6f6..4b4089e8d836 100644 --- a/core-web/libs/template-builder/src/lib/template-builder.module.ts +++ b/core-web/libs/template-builder/src/lib/template-builder.module.ts @@ -1,12 +1,13 @@ import { AsyncPipe, NgFor } from '@angular/common'; import { NgModule } from '@angular/core'; +import { AddWidgetComponent } from './components/template-builder/components/add-widget/add-widget.component'; import { TemplateBuilderRowComponent } from './components/template-builder/components/template-builder-row/template-builder-row.component'; import { DotTemplateBuilderStore } from './components/template-builder/store/template-builder.store'; import { TemplateBuilderComponent } from './components/template-builder/template-builder.component'; @NgModule({ - imports: [NgFor, AsyncPipe, TemplateBuilderRowComponent], + imports: [NgFor, AsyncPipe, TemplateBuilderRowComponent, AddWidgetComponent], declarations: [TemplateBuilderComponent], providers: [DotTemplateBuilderStore], exports: [TemplateBuilderComponent] From 1d1c77e082c6687008349ee9242599fa8a711c93 Mon Sep 17 00:00:00 2001 From: Manuel Rojas Date: Wed, 31 May 2023 11:13:04 -0600 Subject: [PATCH 42/63] Fix #24979 Template builder implement the box component UI (#25061) * Adding builder-box components * Adding test suite * Adding builder-box components * Adding PR feedback * Adding PR feedback * Adding PR feedback * Adding PR feedback * Adding PR feedback * Adding PR feedback * Adding PR feedback * Adding events to the box component * Adding events to the box component * Adding events to the box component --------- Co-authored-by: Freddy Montes <751424+fmontes@users.noreply.github.com> --- .../libs/dotcms-scss/shared/_spacing.scss | 1 + .../template-builder-box.component.html | 68 ++++++++++++++++ .../template-builder-box.component.scss | 74 ++++++++++++++++++ .../template-builder-box.component.spec.ts | 78 +++++++++++++++++++ .../template-builder-box.component.stories.ts | 49 ++++++++++++ .../template-builder-box.component.ts | 37 +++++++++ .../src/lib/template-builder.module.ts | 3 +- 7 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.html create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.scss create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.spec.ts create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.stories.ts create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.ts diff --git a/core-web/libs/dotcms-scss/shared/_spacing.scss b/core-web/libs/dotcms-scss/shared/_spacing.scss index 1eb0b08b382f..ca05cee4e9e6 100644 --- a/core-web/libs/dotcms-scss/shared/_spacing.scss +++ b/core-web/libs/dotcms-scss/shared/_spacing.scss @@ -9,3 +9,4 @@ $spacing-7: 3rem; $spacing-8: 4rem; $spacing-9: 4.5rem; $spacing-10: 7rem; +$spacing-11: 11rem; diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.html b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.html new file mode 100644 index 000000000000..588a1c35ce0c --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.html @@ -0,0 +1,68 @@ +
    +
    + + +
    + + + +
    +
    + +
    +

    {{ item.identifier }}

    + +
    +
    +
    + + +
    + + + +
    +
    diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.scss b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.scss new file mode 100644 index 000000000000..fa5c9f9aea6f --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.scss @@ -0,0 +1,74 @@ +@use "variables" as *; +@import "mixins"; + +::ng-deep { + .template-builder-box, + .template-builder-box--small { + .pi.pi-trash { + color: $color-palette-primary-400; + } + } + + .template-builder-box--small { + .p-button-text { + margin: $spacing-3 0; + } + } +} + +.template-builder-box { + border: 1px solid $color-palette-primary-300; + border-radius: $border-radius-sm; +} + +.template-builder-box__header-container { + background-color: $color-palette-primary-100; + padding: $spacing-1 $spacing-4 $spacing-1 $spacing-2; + + border-top: 1px solid $color-palette-primary-100; + border-radius: $border-radius-sm; + + display: flex; + justify-content: space-between; + align-items: center; +} + +.template-builder-box__item { + padding: 0px $spacing-1 0px $spacing-3; + border: 1px solid $color-palette-primary-300; + margin: $spacing-1 $spacing-3; + + &:first-child { + margin-top: $spacing-3; + } + + &:last-child { + margin-bottom: $spacing-3; + } + + display: flex; + align-items: center; + justify-content: space-between; + + p { + @include truncate-text; + } +} + +.template-builder-box--medium { + width: $spacing-11; +} + +.template-builder-box--large { + width: 100%; +} + +.template-builder-box--small { + border: 1px solid $color-palette-primary-300; + border-radius: $border-radius-sm; + display: flex; + width: $spacing-9; + justify-content: center; + align-items: center; + flex-direction: column; +} diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.spec.ts new file mode 100644 index 000000000000..49619c737200 --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.spec.ts @@ -0,0 +1,78 @@ +import { byTestId, createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; + +import { NgClass, NgIf } from '@angular/common'; + +import { ButtonModule } from 'primeng/button'; +import { ScrollPanelModule } from 'primeng/scrollpanel'; + +import { + TemplateBuilderBoxComponent, + TemplateBuilderBoxSize +} from './template-builder-box.component'; + +describe('TemplateBuilderBoxComponent', () => { + let spectator: SpectatorHost; + + const createHost = createHostFactory({ + component: TemplateBuilderBoxComponent, + imports: [NgClass, NgIf, ButtonModule, ScrollPanelModule] + }); + + beforeEach(() => { + spectator = createHost( + ` `, + { + hostProps: { + size: 'large' + } + } + ); + }); + + it('should create the component', () => { + expect(spectator).toBeTruthy(); + }); + + it('should render with default size', () => { + expect(spectator.query(byTestId('template-builder-box'))).toHaveClass( + 'template-builder-box--large' + ); + }); + + it('should render with medium size and update the class', () => { + spectator.setInput('size', TemplateBuilderBoxSize.medium); + spectator.detectComponentChanges(); + expect(spectator.query(byTestId('template-builder-box'))).toHaveClass( + 'template-builder-box--medium' + ); + }); + + it('should render with small size and update the class', () => { + spectator.setInput('size', TemplateBuilderBoxSize.small); + spectator.detectComponentChanges(); + expect(spectator.query(byTestId('template-builder-box-small'))).toHaveClass( + 'template-builder-box--small' + ); + }); + + it('should render the first ng-template for large and medium sizes', () => { + spectator.setInput('size', TemplateBuilderBoxSize.large); + spectator.detectComponentChanges(); + const firstTemplate = spectator.query(byTestId('template-builder-box')); + const secondTemplate = spectator.query(byTestId('template-builder-box-small')); + expect(firstTemplate).toBeTruthy(); + expect(secondTemplate).toBeNull(); + }); + + it('should show all buttons for small size', () => { + spectator.setInput('size', TemplateBuilderBoxSize.small); + spectator.detectComponentChanges(); + + const addButton = spectator.query(byTestId('btn-plus-small')); + const paletteButton = spectator.query(byTestId('btn-palette-small')); + const deleteButton = spectator.query(byTestId('btn-trash-small')); + expect(addButton).toBeTruthy(); + expect(paletteButton).toBeTruthy(); + expect(deleteButton).toBeTruthy(); + }); +}); diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.stories.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.stories.ts new file mode 100644 index 000000000000..a14c2dfeda39 --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.stories.ts @@ -0,0 +1,49 @@ +import { moduleMetadata, Story, Meta } from '@storybook/angular'; + +import { ButtonModule } from 'primeng/button'; +import { ScrollPanelModule } from 'primeng/scrollpanel'; + +import { TemplateBuilderBoxComponent } from './template-builder-box.component'; + +export default { + title: 'TemplateBuilderBoxComponent', + component: TemplateBuilderBoxComponent, + decorators: [ + moduleMetadata({ + imports: [ButtonModule, ScrollPanelModule], + providers: [] + }) + ] +} as Meta; + +const Template: Story = (args: TemplateBuilderBoxComponent) => ({ + props: args +}); + +const items = [ + { identifier: 'demo.dotcms.com' }, + { identifier: 'System Container' }, + { identifier: 'demo.dotcms.com' }, + { identifier: 'demo.dotcms.com' }, + { identifier: 'demo.dotcms.com' }, + { identifier: 'demo.dotcms.com' } +]; + +export const Small = Template.bind({}); + +export const Medium = Template.bind({}); + +export const Large = Template.bind({}); + +Small.args = { + size: 'small', + items +}; +Medium.args = { + size: 'medium', + items +}; +Large.args = { + size: 'large', + items +}; diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.ts new file mode 100644 index 000000000000..02f135c5963c --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.ts @@ -0,0 +1,37 @@ +import { NgClass, NgFor, NgIf } from '@angular/common'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; +import { ScrollPanelModule } from 'primeng/scrollpanel'; + +import { DotContainers } from '../../models/models'; + +export enum TemplateBuilderBoxSize { + large = 'large', + medium = 'medium', + small = 'small' +} + +@Component({ + selector: 'dotcms-template-builder-box', + templateUrl: './template-builder-box.component.html', + styleUrls: ['./template-builder-box.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [NgFor, NgIf, NgClass, ButtonModule, ScrollPanelModule] +}) +export class TemplateBuilderBoxComponent { + @Output() + editStyle: EventEmitter = new EventEmitter(); + @Output() + addContainer: EventEmitter = new EventEmitter(); + @Output() + addColumn: EventEmitter = new EventEmitter(); + @Output() + deleteColumn: EventEmitter = new EventEmitter(); + + @Input() items: DotContainers; + + protected readonly templateBuilderSizes = TemplateBuilderBoxSize; + @Input() size: TemplateBuilderBoxSize = TemplateBuilderBoxSize.large; +} diff --git a/core-web/libs/template-builder/src/lib/template-builder.module.ts b/core-web/libs/template-builder/src/lib/template-builder.module.ts index 4b4089e8d836..ffae8cbea142 100644 --- a/core-web/libs/template-builder/src/lib/template-builder.module.ts +++ b/core-web/libs/template-builder/src/lib/template-builder.module.ts @@ -2,12 +2,13 @@ import { AsyncPipe, NgFor } from '@angular/common'; import { NgModule } from '@angular/core'; import { AddWidgetComponent } from './components/template-builder/components/add-widget/add-widget.component'; +import { TemplateBuilderBoxComponent } from './components/template-builder/components/template-builder-box/template-builder-box.component'; import { TemplateBuilderRowComponent } from './components/template-builder/components/template-builder-row/template-builder-row.component'; import { DotTemplateBuilderStore } from './components/template-builder/store/template-builder.store'; import { TemplateBuilderComponent } from './components/template-builder/template-builder.component'; @NgModule({ - imports: [NgFor, AsyncPipe, TemplateBuilderRowComponent, AddWidgetComponent], + imports: [NgFor, AsyncPipe, TemplateBuilderRowComponent, AddWidgetComponent, TemplateBuilderBoxComponent], declarations: [TemplateBuilderComponent], providers: [DotTemplateBuilderStore], exports: [TemplateBuilderComponent] From 5ae35313535098dc405d87c825ab508d5dd576ff Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 31 May 2023 14:15:22 -0300 Subject: [PATCH 43/63] Fix #24991: Fix scroll issue on Listing Panel (#25065) * move site change logic to listing panel * delete test focus * delete duplicated tests --- .../dot-pages-listing-panel.component.spec.ts | 29 +++++++++-- .../dot-pages-listing-panel.component.ts | 14 +++++- .../dot-pages/dot-pages.component.spec.ts | 50 ++----------------- .../portlets/dot-pages/dot-pages.component.ts | 9 +--- 4 files changed, 43 insertions(+), 59 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.spec.ts index e252e47c9b0a..4a88fd46b494 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.spec.ts @@ -1,3 +1,5 @@ +import { Subject } from 'rxjs'; + import { CommonModule } from '@angular/common'; import { Component, DebugElement, Input } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -19,11 +21,12 @@ import { UiDotIconButtonModule } from '@components/_common/dot-icon-button/dot-i import { DotAutofocusModule } from '@directives/dot-autofocus/dot-autofocus.module'; import { DotMessagePipeModule } from '@dotcms/app/view/pipes/dot-message/dot-message-pipe.module'; import { DotMessageService } from '@dotcms/data-access'; -import { CoreWebService, CoreWebServiceMock } from '@dotcms/dotcms-js'; +import { CoreWebService, CoreWebServiceMock, SiteService } from '@dotcms/dotcms-js'; import { dotcmsContentletMock, dotcmsContentTypeBasicMock, - MockDotMessageService + MockDotMessageService, + mockSites } from '@dotcms/utils-testing'; import { DotPagesListingPanelComponent } from './dot-pages-listing-panel.component'; @@ -67,6 +70,8 @@ describe('DotPagesListingPanelComponent', () => { let de: DebugElement; let store: DotPageStore; + const switchSiteSubject = new Subject(); + class storeMock { get vm$() { return of({ @@ -152,7 +157,19 @@ describe('DotPagesListingPanelComponent', () => { DialogService, { provide: CoreWebService, useClass: CoreWebServiceMock }, { provide: DotPageStore, useClass: storeMock }, - { provide: DotMessageService, useValue: messageServiceMock } + { provide: DotMessageService, useValue: messageServiceMock }, + { + provide: SiteService, + useValue: { + get currentSite() { + return undefined; + }, + + get switchSite$() { + return switchSiteSubject.asObservable(); + } + } + } ] }).compileComponents(); }); @@ -247,5 +264,11 @@ describe('DotPagesListingPanelComponent', () => { 'abc123?language_id=1&device_inode=' ); }); + + it('should reload portlet only when the site change', () => { + switchSiteSubject.next(mockSites[0]); // setting the site + switchSiteSubject.next(mockSites[1]); // switching the site + expect(store.getPages).toHaveBeenCalledWith({ offset: 0 }); + }); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.ts index 76c076df4bba..9dc268a54c79 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.ts @@ -15,9 +15,10 @@ import { LazyLoadEvent } from 'primeng/api'; import { ContextMenu } from 'primeng/contextmenu'; import { Table } from 'primeng/table'; -import { filter, takeUntil } from 'rxjs/operators'; +import { filter, skip, takeUntil } from 'rxjs/operators'; import { DotMessageService } from '@dotcms/data-access'; +import { SiteService } from '@dotcms/dotcms-js'; import { DotPagesState, DotPageStore } from '../dot-pages-store/dot-pages.store'; import { DotActionsMenuEventParams } from '../dot-pages.component'; @@ -46,7 +47,11 @@ export class DotPagesListingPanelComponent implements OnInit, OnDestroy, AfterVi draft: this.dotMessageService.get('Draft') }; - constructor(private store: DotPageStore, private dotMessageService: DotMessageService) {} + constructor( + private store: DotPageStore, + private dotMessageService: DotMessageService, + private dotSiteService: SiteService + ) {} ngOnInit() { this.store.actionMenuDomId$ @@ -70,6 +75,11 @@ export class DotPagesListingPanelComponent implements OnInit, OnDestroy, AfterVi this.closeContextMenu(); this.tableScroll.emit(); }); + + this.dotSiteService.switchSite$.pipe(takeUntil(this.destroy$), skip(1)).subscribe(() => { + this.store.getPages({ offset: 0 }); + this.scrollElement.scrollTop = 0; // To reset the scroll so it shows the data it retrieves + }); } ngOnDestroy(): void { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.component.spec.ts index 7e28216bd309..7d12b23c59c8 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.component.spec.ts @@ -1,4 +1,4 @@ -import { Subject, throwError } from 'rxjs'; +import { throwError } from 'rxjs'; import { HttpClient, HttpErrorResponse, HttpHandler, HttpResponse } from '@angular/common/http'; import { Component, DebugElement, EventEmitter, Input, Output } from '@angular/core'; @@ -16,13 +16,7 @@ import { DotHttpErrorManagerService } from '@dotcms/app/api/services/dot-http-er import { DotRouterService } from '@dotcms/app/api/services/dot-router/dot-router.service'; import { MockDotHttpErrorManagerService } from '@dotcms/app/test/dot-http-error-manager.service.mock'; import { DotEventsService, DotPageRenderService } from '@dotcms/data-access'; -import { - CoreWebService, - CoreWebServiceMock, - HttpCode, - mockSites, - SiteService -} from '@dotcms/dotcms-js'; +import { CoreWebService, CoreWebServiceMock, HttpCode } from '@dotcms/dotcms-js'; import { ComponentStatus } from '@dotcms/dotcms-models'; import { dotcmsContentletMock, @@ -140,8 +134,6 @@ describe('DotPagesComponent', () => { let dotPageRenderService: DotPageRenderService; let dotHttpErrorManagerService: DotHttpErrorManagerService; - const switchSiteSubject = new Subject(); - beforeEach(() => { TestBed.configureTestingModule({ declarations: [ @@ -162,19 +154,7 @@ describe('DotPagesComponent', () => { }, { provide: CoreWebService, useClass: CoreWebServiceMock }, { provide: DotMessageDisplayService, useClass: DotMessageDisplayServiceMock }, - { provide: DotRouterService, useClass: MockDotRouterService }, - { - provide: SiteService, - useValue: { - get currentSite() { - return undefined; - }, - - get switchSite$() { - return switchSiteSubject.asObservable(); - } - } - } + { provide: DotRouterService, useClass: MockDotRouterService } ] }).compileComponents(); }); @@ -352,28 +332,4 @@ describe('DotPagesComponent', () => { isFavoritePage: true }); }); - - it('should reload portlet only when the site change', () => { - switchSiteSubject.next(mockSites[0]); // setting the site - switchSiteSubject.next(mockSites[1]); // switching the site - expect(store.getPages).toHaveBeenCalledWith({ offset: 0 }); - }); - - it('should reload portlet only when the site change', () => { - switchSiteSubject.next(mockSites[0]); // setting the site - switchSiteSubject.next(mockSites[1]); // switching the site - expect(store.getPages).toHaveBeenCalledWith({ offset: 0 }); - }); - - it('should reload portlet only when the site change', () => { - switchSiteSubject.next(mockSites[0]); // setting the site - switchSiteSubject.next(mockSites[1]); // switching the site - expect(store.getPages).toHaveBeenCalledWith({ offset: 0 }); - }); - - it('should reload portlet only when the site change', () => { - switchSiteSubject.next(mockSites[0]); // setting the site - switchSiteSubject.next(mockSites[1]); // switching the site - expect(store.getPages).toHaveBeenCalledWith({ offset: 0 }); - }); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.component.ts index ce7e5a2f9cf1..ec8e5b69b313 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages.component.ts @@ -13,14 +13,14 @@ import { import { Menu } from 'primeng/menu'; import { Observable } from 'rxjs/internal/Observable'; -import { filter, skip, take, takeUntil } from 'rxjs/operators'; +import { filter, take, takeUntil } from 'rxjs/operators'; import { DotMessageSeverity, DotMessageType } from '@components/dot-message-display/model'; import { DotMessageDisplayService } from '@components/dot-message-display/services'; import { DotHttpErrorManagerService } from '@dotcms/app/api/services/dot-http-error-manager/dot-http-error-manager.service'; import { DotRouterService } from '@dotcms/app/api/services/dot-router/dot-router.service'; import { DotEventsService, DotPageRenderService } from '@dotcms/data-access'; -import { HttpCode, SiteService } from '@dotcms/dotcms-js'; +import { HttpCode } from '@dotcms/dotcms-js'; import { ComponentStatus, DotCMSContentlet } from '@dotcms/dotcms-models'; import { @@ -54,7 +54,6 @@ export class DotPagesComponent implements AfterViewInit, OnDestroy { private dotMessageDisplayService: DotMessageDisplayService, private dotEventsService: DotEventsService, private dotHttpErrorManagerService: DotHttpErrorManagerService, - private dotSiteService: SiteService, private dotPageRenderService: DotPageRenderService, private element: ElementRef ) { @@ -177,10 +176,6 @@ export class DotPagesComponent implements AfterViewInit, OnDestroy { type: DotMessageType.SIMPLE_MESSAGE }); }); - - this.dotSiteService.switchSite$.pipe(takeUntil(this.destroy$), skip(1)).subscribe(() => { - this.store.getPages({ offset: 0 }); - }); } ngOnDestroy(): void { From 7a2c02455accc01d6e3feec5678d8cc11b043819 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 31 May 2023 14:17:39 -0300 Subject: [PATCH 44/63] Fix #25073: Favorite Pages Table not showing sorting information (#25075) * add default sorting options * delete focus --- .../dot-pages-listing-panel.component.html | 2 ++ .../dot-pages-listing-panel.component.spec.ts | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.html index c7f702bc94dc..a7f631c201cc 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.html @@ -13,10 +13,12 @@ [virtualScroll]="true" [virtualScrollItemSize]="47" [lazy]="true" + [sortOrder]="-1" (onLazyLoad)="loadPagesLazy($event)" (onRowSelect)="onRowSelect($event)" selectionMode="single" scrollHeight="flex" + sortField="modDate" >
    diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.spec.ts index 4a88fd46b494..12ac389a0fe1 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.spec.ts @@ -201,6 +201,8 @@ describe('DotPagesListingPanelComponent', () => { expect(elem.lazy).toBe(true); expect(elem.selectionMode).toBe('single'); expect(elem.scrollHeight).toBe('flex'); + expect(elem.sortField).toEqual('modDate'); + expect(elem.sortOrder).toEqual(-1); }); it('should contain header with filter for keyword, language and archived', () => { @@ -217,8 +219,8 @@ describe('DotPagesListingPanelComponent', () => { it('should getPages method from store have been called', () => { expect(store.getPages).toHaveBeenCalledWith({ offset: 0, - sortField: '', - sortOrder: 1 + sortField: 'modDate', + sortOrder: -1 }); }); From 409c3ca2ec4470f25f89ca7b7f5f20ad22db1cd4 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 31 May 2023 14:18:42 -0300 Subject: [PATCH 45/63] Fix #25702: Change styles of Remove Button on Add Bookmark Dialog --- .../dot-favorite-page/dot-favorite-page.component.html | 3 ++- .../dotcms-scss/angular/dotcms-theme/components/_button.scss | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/components/dot-favorite-page/dot-favorite-page.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/components/dot-favorite-page/dot-favorite-page.component.html index 70c2ef511bb0..b9ada0941255 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/components/dot-favorite-page/dot-favorite-page.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/components/dot-favorite-page/dot-favorite-page.component.html @@ -87,10 +87,11 @@
    - +

    {{ item.identifier }}

    -
    +
    { let spectator: SpectatorHost; @@ -20,10 +22,11 @@ describe('TemplateBuilderBoxComponent', () => { beforeEach(() => { spectator = createHost( - ` `, + ` `, { hostProps: { - size: 'large' + size: TemplateBuilderBoxSize.large, + items: CONTAINERS_DATA_MOCK } } ); @@ -75,4 +78,39 @@ describe('TemplateBuilderBoxComponent', () => { expect(paletteButton).toBeTruthy(); expect(deleteButton).toBeTruthy(); }); + + it('should trigger addContainer when click on plus button', () => { + const addContainerMock = jest.spyOn(spectator.component.addContainer, 'emit'); + const addButton = spectator.query(byTestId('btn-plus')); + + spectator.dispatchFakeEvent(addButton, 'onClick'); + expect(addContainerMock).toHaveBeenCalled(); + }); + + it('should trigger editStyle when click on palette button', () => { + const editStyleMock = jest.spyOn(spectator.component.editStyle, 'emit'); + const paletteButton = spectator.query(byTestId('btn-palette')); + + spectator.dispatchFakeEvent(paletteButton, 'onClick'); + + expect(editStyleMock).toHaveBeenCalled(); + }); + + it('should trigger deleteContainer when click on container trash button', () => { + const deleteContainerMock = jest.spyOn(spectator.component.deleteContainer, 'emit'); + const containerTrashButton = spectator.query(byTestId('btn-trash-container')); + + spectator.dispatchFakeEvent(containerTrashButton, 'onClick'); + + expect(deleteContainerMock).toHaveBeenCalled(); + }); + + it('should trigger deleteColumn when click on column trash button', () => { + const deleteColumnMock = jest.spyOn(spectator.component.deleteColumn, 'emit'); + const columnTrashButton = spectator.query(byTestId('btn-trash-column')); + + spectator.dispatchFakeEvent(columnTrashButton, 'onClick'); + + expect(deleteColumnMock).toHaveBeenCalled(); + }); }); diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.ts index 02f135c5963c..1d4c9f3661c3 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.ts @@ -4,7 +4,7 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from import { ButtonModule } from 'primeng/button'; import { ScrollPanelModule } from 'primeng/scrollpanel'; -import { DotContainers } from '../../models/models'; +import { DotTemplateBuilderContainer } from '../../models/models'; export enum TemplateBuilderBoxSize { large = 'large', @@ -26,11 +26,11 @@ export class TemplateBuilderBoxComponent { @Output() addContainer: EventEmitter = new EventEmitter(); @Output() - addColumn: EventEmitter = new EventEmitter(); + deleteContainer: EventEmitter = new EventEmitter(); @Output() deleteColumn: EventEmitter = new EventEmitter(); - @Input() items: DotContainers; + @Input() items: DotTemplateBuilderContainer[]; protected readonly templateBuilderSizes = TemplateBuilderBoxSize; @Input() size: TemplateBuilderBoxSize = TemplateBuilderBoxSize.large; diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/models/models.ts b/core-web/libs/template-builder/src/lib/components/template-builder/models/models.ts index 886c29a9284a..fe50c01946a6 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/models/models.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/models/models.ts @@ -4,20 +4,20 @@ export interface DotGridStackOptions extends GridStackOptions { children: DotGridStackWidget[]; } -export interface DotContainers { +export interface DotTemplateBuilderContainer { identifier: string; uuid: string; } export interface DotGridStackWidget extends GridStackWidget { - containers?: DotContainers[]; + containers?: DotTemplateBuilderContainer[]; styleClass?: string[]; // We can join the classes in the parser, might be easier to work with subGridOpts?: DotGridStackOptions; parentId?: string; } export interface DotGridStackNode extends GridStackNode { - containers?: DotContainers[]; + containers?: DotTemplateBuilderContainer[]; styleClass?: string[]; // We can join the classes in the parser, might be easier to work with subGridOpts?: DotGridStackOptions; parentId?: string; diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/utils/mocks.ts b/core-web/libs/template-builder/src/lib/components/template-builder/utils/mocks.ts index e6cd6be6df5b..d8c19c42fcf5 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/utils/mocks.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/utils/mocks.ts @@ -18,6 +18,25 @@ export const GRIDSTACK_DATA_MOCK: DotGridStackWidget[] = [ } ]; +export const CONTAINERS_DATA_MOCK = [ + { + identifier: '//demo.dotcms.com/application/containers/banner/', + uuid: '1' + }, + { + identifier: '//demo.dotcms.com/application/containers/banner/', + uuid: '2' + }, + { + identifier: '//demo.dotcms.com/application/containers/banner/', + uuid: '3' + }, + { + identifier: '//demo.dotcms.com/application/containers/banner/', + uuid: '4' + } +]; + export const MINIMAL_DATA_MOCK: DotLayoutBody = { rows: [ { From ef14d739504a6b2070a8a93e4f6a33acc7cfec88 Mon Sep 17 00:00:00 2001 From: Daniel Montes de Oca Date: Fri, 2 Jun 2023 08:20:39 -0600 Subject: [PATCH 53/63] Fix #25071: Favorite pages update bookmarks cards and add star (#25107) * favorite pages update cards and add star * design improvements for show all button --- .../dot-pages-card.component.html | 12 ---- .../dot-pages-card.component.scss | 2 +- .../dot-pages-card.component.spec.ts | 48 ------------- .../dot-pages-favorite-panel.component.html | 9 ++- .../dot-pages-favorite-panel.component.scss | 68 ++++++++++--------- ...dot-pages-favorite-panel.component.spec.ts | 8 ++- .../dotcms-theme/components/_panel.scss | 39 +++++++++++ .../angular/dotcms-theme/theme.scss | 1 + 8 files changed, 91 insertions(+), 96 deletions(-) create mode 100644 core-web/libs/dotcms-scss/angular/dotcms-theme/components/_panel.scss diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-card/dot-pages-card.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-card/dot-pages-card.component.html index d44ef0ea6137..3a347b53103a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-card/dot-pages-card.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-card/dot-pages-card.component.html @@ -13,18 +13,6 @@ - -
    {{ title }}
    { ).toBeTrue(); }); - it('should set highlighted star icon', () => { - expect( - fixture.debugElement - .query(By.css('[data-testid="favoriteCardIconButton"]')) - .nativeElement.classList.contains('dot-favorite-page-highlight') - ).toBeTrue(); - expect( - fixture.debugElement.query(By.css('[data-testid="favoriteCardIconButton"]')) - .componentInstance.icon - ).toBe('grade'); - }); - it('should set title and url as content', () => { expect( fixture.debugElement.query(By.css('.dot-pages-favorite-card-content__title')) @@ -108,42 +96,6 @@ describe('DotPagesCardComponent', () => { expect(component.goTo.emit).toHaveBeenCalledWith(true); expect(component.edit.emit).not.toHaveBeenCalledWith(true); }); - - it('should emit edit event when clicked on star icon', () => { - const elem = de.query(By.css('[data-testid="favoriteCardIconButton"]')); - elem.triggerEventHandler('click', { - stopPropagation: () => { - // - } - }); - - expect(component.goTo.emit).not.toHaveBeenCalledWith(true); - expect(component.edit.emit).toHaveBeenCalledWith(true); - }); - }); - - describe('Without ownerPage', () => { - beforeEach(() => { - component.imageUri = - '/dA/792c7c9f-6b6f-427b-80ff-1643376c9999/photo/mountain-persona.jpg'; - component.title = 'test'; - component.url = '/index'; - component.ownerPage = false; - - fixture.detectChanges(); - }); - - it('should not set highlighted star icon', () => { - expect( - fixture.debugElement - .query(By.css('[data-testid="favoriteCardIconButton"]')) - .nativeElement.classList.contains('dot-favorite-page-highlight') - ).toBeFalse(); - expect( - fixture.debugElement.query(By.css('[data-testid="favoriteCardIconButton"]')) - .componentInstance.icon - ).toBe('star_outline'); - }); }); describe('Without thumbnail', () => { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.html index 93f4592f5880..2939af8a2b6d 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.html @@ -6,11 +6,10 @@ 'dot-pages-panel__empty-state': vm.favoritePages?.items?.length === 0 }" [toggleable]="true" - [header]="'favoritePage.panel.header' | dm" [collapsed]="vm.isFavoritePanelCollaped" (onAfterToggle)="toggleFavoritePagesPanel($event)" toggler="header" - iconPos="start" + iconPos="end" expandIcon="pi pi-angle-down" collapseIcon="pi pi-angle-up" > @@ -34,6 +33,12 @@ data-testid="seeAllBtn" > + + + + {{ 'favoritePage.panel.header' | dm }} + + { it('should set panel inputs and attributes', () => { const elem = de.query(By.css('p-panel')); expect(elem.nativeElement.classList.contains('dot-pages-panel__expanded')).toBeFalse(); - expect(elem.componentInstance['iconPos']).toBe('start'); + expect(elem.componentInstance['iconPos']).toBe('end'); expect(elem.componentInstance['expandIcon']).toBe('pi pi-angle-down'); expect(elem.componentInstance['collapseIcon']).toBe('pi pi-angle-up'); - expect(elem.componentInstance['header']).toBe('favoritePage.panel.header'); expect(elem.componentInstance['toggleable']).toBe(true); }); + it('should have an icon for bookmarks in the header', () => { + const elem = de.query(By.css('.dot-pages-panel__header [data-testId="bookmarksIcon"]')); + expect(elem).toBeTruthy(); + }); + it('should set secondary button in panel', () => { const elem = de.query(By.css('.dot-pages-panel-action__button span')); expect(elem.nativeElement.outerText.toUpperCase()).toBe('SEE.ALL'.toUpperCase()); diff --git a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_panel.scss b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_panel.scss new file mode 100644 index 000000000000..b22e814c4a5d --- /dev/null +++ b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_panel.scss @@ -0,0 +1,39 @@ +@use "variables" as *; + +p-panel { + display: block; + margin-bottom: $spacing-5; +} + +.p-panel-toggleable { + cursor: pointer; + background-color: $color-palette-primary-100; + + &.p-panel-expanded { + cursor: default; + } +} + +.p-panel-header { + font-size: $font-size-xl; + margin: $spacing-1 0; + padding: $spacing-3 0; + position: relative; + + .p-panel-icons span { + color: $color-palette-primary; + font-size: $font-size-xl; + margin-left: $spacing-1; + margin-right: $spacing-2; + } +} + +.p-panel-icons { + display: flex; + justify-content: end; + flex: 1; +} + +.p-panel-header .p-panel-icons span.pi { + margin-right: $spacing-4; +} diff --git a/core-web/libs/dotcms-scss/angular/dotcms-theme/theme.scss b/core-web/libs/dotcms-scss/angular/dotcms-theme/theme.scss index 2988d93a0522..ec2ba2204b06 100644 --- a/core-web/libs/dotcms-scss/angular/dotcms-theme/theme.scss +++ b/core-web/libs/dotcms-scss/angular/dotcms-theme/theme.scss @@ -27,6 +27,7 @@ @use "components/message"; @use "components/multiselect"; @use "components/overlaypanel"; +@use "components/panel"; @use "components/paginator"; @use "components/progressbar"; @use "components/progress-spinner"; From e2c7b1469274fd0f5e0a7a53c806b63c100b9372 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Fri, 2 Jun 2023 11:27:02 -0300 Subject: [PATCH 54/63] Fix #25090: Refactor of Listing Panel in Favorite Pages to be Pagination instead of Infinity Scroll (#25101) * refactor to add pagination * small refactor to update the collapse state * add resize to table on collapse * add transition to resize * Update dot-pages-listing-panel.component.scss * fix broken tests * small paginator style change * remove first and last page button * fix broken test * increase code escalability * Update dot-pages-listing-panel.component.scss --- ...dot-pages-favorite-panel.component.spec.ts | 9 ++- .../dot-pages-favorite-panel.component.ts | 1 + .../dot-pages-listing-panel.component.html | 21 ++---- .../dot-pages-listing-panel.component.scss | 13 ++++ .../dot-pages-listing-panel.component.spec.ts | 20 ++++-- .../dot-pages-listing-panel.component.ts | 2 +- .../dot-pages-store/dot-pages.store.spec.ts | 16 ++--- .../dot-pages-store/dot-pages.store.ts | 67 ++++++++++--------- 8 files changed, 87 insertions(+), 62 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.spec.ts index bbf7b2f9c884..19ead2d428be 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.spec.ts @@ -109,11 +109,14 @@ describe('DotPagesFavoritePanelComponent', () => { setLocalStorageFavoritePanelCollapsedParams(_collapsed: boolean): void { /* */ } + setFavoritePages() { + /* */ + } } describe('Empty state', () => { - beforeEach(() => { - TestBed.configureTestingModule({ + beforeEach(async () => { + await TestBed.configureTestingModule({ declarations: [DotPagesFavoritePanelComponent, MockDotIconComponent], imports: [ BrowserAnimationsModule, @@ -155,6 +158,7 @@ describe('DotPagesFavoritePanelComponent', () => { it('should set panel collapsed state', () => { spyOn(store, 'setLocalStorageFavoritePanelCollapsedParams'); + spyOn(store, 'setFavoritePages'); component.toggleFavoritePagesPanel( new Event('myevent', { bubbles: true, @@ -163,6 +167,7 @@ describe('DotPagesFavoritePanelComponent', () => { }) ); expect(store.setLocalStorageFavoritePanelCollapsedParams).toHaveBeenCalledTimes(1); + expect(store.setFavoritePages).toHaveBeenCalledTimes(1); }); it('should load empty pages cards container', () => { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.ts index 007c86ee5172..113d65fedfc5 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-favorite-panel/dot-pages-favorite-panel.component.ts @@ -72,6 +72,7 @@ export class DotPagesFavoritePanelComponent { */ toggleFavoritePagesPanel($event: Event): void { this.store.setLocalStorageFavoritePanelCollapsedParams($event['collapsed']); + this.store.setFavoritePages({ collapsed: $event['collapsed'] as boolean }); } /** diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.html index a7f631c201cc..7989660db16c 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.html @@ -8,16 +8,20 @@ #table [contextMenu]="cm" [value]="vm.pages.items" + [totalRecords]="vm.pages.total" [loading]="vm.isPagesLoading" [scrollable]="true" - [virtualScroll]="true" - [virtualScrollItemSize]="47" [lazy]="true" + [paginator]="true" + [rows]="40" + [scrollHeight]="vm.isFavoritePanelCollaped ? 'calc(100vh - 350px)' : 'calc(100vh - 600px)'" [sortOrder]="-1" + [showPageLinks]="false" + [showCurrentPageReport]="true" + [showFirstLastIcon]="false" (onLazyLoad)="loadPagesLazy($event)" (onRowSelect)="onRowSelect($event)" selectionMode="single" - scrollHeight="flex" sortField="modDate" > @@ -134,17 +138,6 @@ - - - - - - - - - - - diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.scss b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.scss index af7983661bed..7ebc6aa2b280 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.scss +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.scss @@ -32,6 +32,19 @@ .p-datatable .p-datatable-tbody tr td.dot-pages-listing__empty-content { text-align: center; } + + .p-datatable { + .p-datatable-wrapper { + height: calc( + 100vh - 350px + ); // To prevent the table from resizing when the results are not enough to fill the page + + transition: max-height 400ms cubic-bezier(0.86, 0, 0.07, 1); + -moz-transition: max-height 400ms cubic-bezier(0.86, 0, 0.07, 1); + -webkit-transition: max-height 400ms cubic-bezier(0.86, 0, 0.07, 1); + -o-transition: max-height 400ms cubic-bezier(0.86, 0, 0.07, 1); + } + } } .dot-pages-listing-header__inputs { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.spec.ts index 12ac389a0fe1..38fe1be2441e 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.spec.ts @@ -20,9 +20,16 @@ import { of } from 'rxjs/internal/observable/of'; import { UiDotIconButtonModule } from '@components/_common/dot-icon-button/dot-icon-button.module'; import { DotAutofocusModule } from '@directives/dot-autofocus/dot-autofocus.module'; import { DotMessagePipeModule } from '@dotcms/app/view/pipes/dot-message/dot-message-pipe.module'; +import { DotRelativeDatePipe } from '@dotcms/app/view/pipes/dot-relative-date/dot-relative-date.pipe'; import { DotMessageService } from '@dotcms/data-access'; -import { CoreWebService, CoreWebServiceMock, SiteService } from '@dotcms/dotcms-js'; import { + CoreWebService, + CoreWebServiceMock, + DotcmsConfigService, + SiteService +} from '@dotcms/dotcms-js'; +import { + DotcmsConfigServiceMock, dotcmsContentletMock, dotcmsContentTypeBasicMock, MockDotMessageService, @@ -146,6 +153,7 @@ describe('DotPagesListingPanelComponent', () => { DropdownModule, DotAutofocusModule, DotMessagePipeModule, + DotRelativeDatePipe, InputTextModule, SkeletonModule, TableModule, @@ -155,6 +163,7 @@ describe('DotPagesListingPanelComponent', () => { ], providers: [ DialogService, + { provide: DotcmsConfigService, useClass: DotcmsConfigServiceMock }, { provide: CoreWebService, useClass: CoreWebServiceMock }, { provide: DotPageStore, useClass: storeMock }, { provide: DotMessageService, useValue: messageServiceMock }, @@ -196,13 +205,16 @@ describe('DotPagesListingPanelComponent', () => { const elem = de.query(By.css('p-table')).componentInstance; expect(elem.scrollable).toBe(true); expect(elem.loading).toBe(undefined); - expect(elem.virtualScroll).toBe(true); - expect(elem.virtualScrollItemSize).toBe(47); expect(elem.lazy).toBe(true); expect(elem.selectionMode).toBe('single'); - expect(elem.scrollHeight).toBe('flex'); + expect(elem.scrollHeight).toBe('calc(100vh - 600px)'); expect(elem.sortField).toEqual('modDate'); expect(elem.sortOrder).toEqual(-1); + expect(elem.rows).toEqual(40); + expect(elem.paginator).toEqual(true); + expect(elem.showPageLinks).toEqual(false); + expect(elem.showCurrentPageReport).toEqual(true); + expect(elem.showFirstLastIcon).toEqual(false); }); it('should contain header with filter for keyword, language and archived', () => { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.ts index 9dc268a54c79..a22729851401 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-listing-panel/dot-pages-listing-panel.component.ts @@ -69,7 +69,7 @@ export class DotPagesListingPanelComponent implements OnInit, OnDestroy, AfterVi } ngAfterViewInit(): void { - this.scrollElement = this.table.el.nativeElement.querySelector('div.p-scroller'); + this.scrollElement = this.table.el.nativeElement.querySelector('.p-datatable-wrapper'); this.scrollElement.addEventListener('scroll', () => { this.closeContextMenu(); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts index 1c12c02a3709..fb7a71500ede 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts @@ -212,9 +212,9 @@ describe('DotPageStore', () => { it('should limit Favorite Pages', () => { spyOn(dotPageStore, 'setFavoritePages').and.callThrough(); dotPageStore.limitFavoritePages(5); - expect(dotPageStore.setFavoritePages).toHaveBeenCalledWith( - favoritePagesInitialTestData.slice(0, 5) - ); + expect(dotPageStore.setFavoritePages).toHaveBeenCalledWith({ + items: favoritePagesInitialTestData.slice(0, 5) + }); }); // Selectors @@ -287,14 +287,14 @@ describe('DotPageStore', () => { // Updaters it('should update Favorite Pages', () => { - dotPageStore.setFavoritePages(favoritePagesInitialTestData); + dotPageStore.setFavoritePages({ items: favoritePagesInitialTestData }); dotPageStore.state$.subscribe((data) => { expect(data.favoritePages.items).toEqual(favoritePagesInitialTestData); }); }); it('should update Pages', () => { - dotPageStore.setPages(favoritePagesInitialTestData); + dotPageStore.setPages({ items: favoritePagesInitialTestData }); dotPageStore.state$.subscribe((data) => { expect(data.pages.items).toEqual(favoritePagesInitialTestData); }); @@ -466,7 +466,7 @@ describe('DotPageStore', () => { } ]; - dotPageStore.setPages(pagesData); + dotPageStore.setPages({ items: pagesData }); spyOn(dotESContentService, 'get').and.returnValue( of({ @@ -499,7 +499,7 @@ describe('DotPageStore', () => { }); it('should keep fetching Pages data until new value comes from the DB in store', fakeAsync(() => { - dotPageStore.setPages(favoritePagesInitialTestData); + dotPageStore.setPages({ items: favoritePagesInitialTestData }); const old = { contentTook: 0, jsonObjectView: { @@ -563,7 +563,7 @@ describe('DotPageStore', () => { })); it('should remove page archived from pages collection and add undefined at the bottom', fakeAsync(() => { - dotPageStore.setPages(favoritePagesInitialTestData); + dotPageStore.setPages({ items: favoritePagesInitialTestData }); const old = { contentTook: 0, jsonObjectView: { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts index ba60b070f1ea..b987f161c669 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.ts @@ -60,14 +60,27 @@ import { generateDotFavoritePageUrl } from '@dotcms/utils'; import { DotFavoritePageComponent } from '../../dot-edit-page/components/dot-favorite-page/dot-favorite-page.component'; import { DotPagesCreatePageDialogComponent } from '../dot-pages-create-page-dialog/dot-pages-create-page-dialog.component'; +export interface DotPagesInfo { + actionMenuDomId?: string; + addToBundleCTId?: string; + archived?: boolean; + items: DotCMSContentlet[]; + keyword?: string; + languageId?: string; + menuActions?: MenuItem[]; + status?: ComponentStatus; + total?: number; +} + +export interface DotFavoritePagesInfo { + collapsed?: boolean; + items: DotCMSContentlet[]; + showLoadMoreButton: boolean; + total: number; +} export interface DotPagesState { - favoritePages: { - collapsed?: boolean; - items: DotCMSContentlet[]; - showLoadMoreButton: boolean; - total: number; - }; + favoritePages: DotFavoritePagesInfo; environments: boolean; isEnterprise: boolean; languages: DotLanguage[]; @@ -76,16 +89,7 @@ export interface DotPagesState { canWrite: { contentlets: boolean; htmlPages: boolean }; id: string; }; - pages?: { - actionMenuDomId?: string; - addToBundleCTId?: string; - archived?: boolean; - items: DotCMSContentlet[]; - keyword?: string; - languageId?: string; - menuActions?: MenuItem[]; - status: ComponentStatus; - }; + pages?: DotPagesInfo; pageTypes?: DotCMSContentType[]; portletStatus: ComponentStatus; } @@ -185,26 +189,26 @@ export class DotPageStore extends ComponentStore { readonly pageTypes$ = this.select(({ pageTypes }) => pageTypes); - readonly setFavoritePages = this.updater( - (state: DotPagesState, favoritePages: DotCMSContentlet[]) => { + readonly setFavoritePages = this.updater>( + (state: DotPagesState, favoritePages: DotFavoritePagesInfo) => { return { ...state, favoritePages: { ...state.favoritePages, - items: [...favoritePages] + ...favoritePages } }; } ); - readonly setPages = this.updater( - (state: DotPagesState, pages: DotCMSContentlet[]) => { + readonly setPages = this.updater>( + (state: DotPagesState, pagesInfo: DotPagesInfo) => { return { ...state, pages: { ...state.pages, - items: [...pages], - status: ComponentStatus.LOADED + status: ComponentStatus.LOADED, + ...pagesInfo } }; } @@ -416,7 +420,7 @@ export class DotPageStore extends ComponentStore { } ); - this.setFavoritePages(pagesData); + this.setFavoritePages({ items: pagesData }); } else { let pagesData = this.get().pages.items; @@ -436,7 +440,7 @@ export class DotPageStore extends ComponentStore { }); } - this.setPages(pagesData); + this.setPages({ items: pagesData }); } } }, @@ -467,13 +471,10 @@ export class DotPageStore extends ComponentStore { return this.getPagesData(offset, sortOrderValue, sortField).pipe( tapResponse( (items) => { - const currentPages = Array.from({ length: items.resultsSize }); - - Array.prototype.splice.apply(currentPages, [ - ...[offset, 40], - ...items.jsonObjectView.contentlets - ]); - this.setPages(currentPages as DotCMSContentlet[]); + this.setPages({ + items: items.jsonObjectView.contentlets as DotCMSContentlet[], + total: items.resultsSize + }); }, (error: HttpErrorResponse) => { this.setPagesStatus(ComponentStatus.LOADED); @@ -1011,7 +1012,7 @@ export class DotPageStore extends ComponentStore { */ limitFavoritePages(limit: number): void { const favoritePages = this.get().favoritePages.items; - this.setFavoritePages(favoritePages.slice(0, limit)); + this.setFavoritePages({ items: favoritePages.slice(0, limit) }); } /** From e72b239e94ef24415d2c549b2baa6e2ef236c460 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Fri, 2 Jun 2023 09:09:54 -0600 Subject: [PATCH 55/63] #25121 avoid a NPE (#25123) --- .../velocity/services/ContentletLoader.java | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/services/ContentletLoader.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/services/ContentletLoader.java index eb02de4a150d..4c461cb591dc 100644 --- a/dotCMS/src/main/java/com/dotcms/rendering/velocity/services/ContentletLoader.java +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/services/ContentletLoader.java @@ -177,32 +177,33 @@ public InputStream buildVelocity(final Contentlet content, final PageMode mode, if (field instanceof StoryBlockField) { contFieldValueObject = conAPI.getFieldValue(content, field); - if (JsonUtil.isValidJSON(contFieldValueObject.toString())) { - sb.append("#set($") - .append(field.variable()) - .append("= $json.generate(") - .append(contFieldValueObject) - .append("))"); - } else { - Logger.warn(this, String.format("Story Block field '%s' in contentlet with ID '%s' does not " + - "contain valid JSON data. Please try to re-publish it.", - field.variable(), content.getIdentifier())); - if (contFieldValueObject.toString().contains("$") || contFieldValueObject.toString().contains("#")) { - String velPath = new VelocityResourceKey(field, Optional.of(content), mode).path ; + if (UtilMethods.isSet(contFieldValueObject)) { + if (JsonUtil.isValidJSON(contFieldValueObject.toString())) { sb.append("#set($") .append(field.variable()) - .append("= $velutil.mergeTemplate(\"") - .append(velPath) - - .append("\"))"); + .append("= $json.generate(") + .append(contFieldValueObject) + .append("))"); } else { - sb.append("#set($") - .append(field.variable()) - .append("= \"") - .append(UtilMethods.espaceForVelocity(contFieldValueObject.toString()).trim()) - .append("\")"); + Logger.warn(this, String.format("Story Block field '%s' in contentlet with ID '%s' does not " + + "contain valid JSON data. Please try to re-publish it.", + field.variable(), content.getIdentifier())); + if (contFieldValueObject.toString().contains("$") || contFieldValueObject.toString().contains("#")) { + String velPath = new VelocityResourceKey(field, Optional.of(content), mode).path; + sb.append("#set($") + .append(field.variable()) + .append("= $velutil.mergeTemplate(\"") + .append(velPath) + + .append("\"))"); + } else { + sb.append("#set($") + .append(field.variable()) + .append("= \"") + .append(UtilMethods.espaceForVelocity(contFieldValueObject.toString()).trim()) + .append("\")"); + } } - } continue; } From 5e674913281bfddbabc10e6aadeb1d2a63b3c1ad Mon Sep 17 00:00:00 2001 From: Jonathan Date: Fri, 2 Jun 2023 09:30:41 -0600 Subject: [PATCH 56/63] Fix #25076 removing contentlet cache after wait for, force cache (#25077) * #25076 removing contentlet cache after wait for, force cache * #25076 invalidating the es cache * #25076 now just invalidating the es query cache --- .../content/elasticsearch/business/ContentletIndexAPIImpl.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java index 68d1493d81db..4037e165fc03 100644 --- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java @@ -23,6 +23,7 @@ import com.dotcms.variant.model.Variant; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.CacheLocator; +import com.dotmarketing.business.cache.provider.CacheProvider; import com.dotmarketing.common.db.DotConnect; import com.dotmarketing.common.reindex.BulkProcessorListener; import com.dotmarketing.common.reindex.ReindexEntry; @@ -576,12 +577,14 @@ private void indexContentListNow(final List contentToIndex) { final BulkRequest bulkRequest = createBulkRequest(contentToIndex); bulkRequest.setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE); putToIndex(bulkRequest); + CacheLocator.getESQueryCache().clearCache(); } // indexContentListNow. private void indexContentListWaitFor(final List contentToIndex) { final BulkRequest bulkRequest = createBulkRequest(contentToIndex); bulkRequest.setRefreshPolicy(WriteRequest.RefreshPolicy.WAIT_UNTIL); putToIndex(bulkRequest); + CacheLocator.getESQueryCache().clearCache(); } // indexContentListWaitFor. private void indexContentListDefer(final List contentToIndex) { From d0ded63a3f8d3a79f7010a7268e461ccfaa35557 Mon Sep 17 00:00:00 2001 From: Humberto Morera <31667212+hmoreras@users.noreply.github.com> Date: Fri, 2 Jun 2023 13:44:52 -0300 Subject: [PATCH 57/63] dotCMS/core#25028 [UI] Add a Go to Report Experiment button in a Edit Page if the page has an RUNNING Experiment (#25067) * progrss * clean * tests * cleanup * feedback and add test * clean up * PR feedback * add missing param * ui and PR feedback * remove label --- .../dot-edit-page-toolbar.component.html | 17 ++++++ .../dot-edit-page-toolbar.component.scss | 4 ++ .../dot-edit-page-toolbar.component.spec.ts | 52 +++++++++++++++++-- .../dot-edit-page-toolbar.component.ts | 2 + .../dot-edit-page-toolbar.module.ts | 6 ++- .../content/dot-edit-content.component.html | 1 + .../dot-edit-content.component.spec.ts | 12 +++++ .../content/dot-edit-content.component.ts | 20 ++++++- .../services/dot-experiments.service.spec.ts | 6 +++ .../services/dot-experiments.service.ts | 16 ++++++ 10 files changed, 129 insertions(+), 7 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/components/dot-edit-page-toolbar/dot-edit-page-toolbar.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/components/dot-edit-page-toolbar/dot-edit-page-toolbar.component.html index aa93f38ee19f..efab85914503 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/components/dot-edit-page-toolbar/dot-edit-page-toolbar.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/components/dot-edit-page-toolbar/dot-edit-page-toolbar.component.html @@ -78,6 +78,23 @@

    {{ variant.experimentName }}

    + + + science + ` + template: ` + + ` }) class TestHostComponent { @Input() pageState: DotPageRenderState = mockDotRenderedPageState; + @Input() runningExperiment: DotExperiment = null; } @Component({ @@ -149,7 +163,14 @@ describe('DotEditPageToolbarComponent', () => { DotPipesModule, DotWizardModule, TooltipModule, - DotExperimentClassDirective + TagModule, + DotExperimentClassDirective, + RouterTestingModule.withRoutes([ + { + path: 'edit-page/experiments/pageId/id/reports', + component: TestHostComponent + } + ]) ], providers: [ { provide: DotLicenseService, useClass: MockDotLicenseService }, @@ -158,7 +179,9 @@ describe('DotEditPageToolbarComponent', () => { useValue: new MockDotMessageService({ 'dot.common.whats.changed': 'Whats', 'dot.common.cancel': 'Cancel', - 'favoritePage.dialog.header': 'Add Favorite Page' + 'favoritePage.dialog.header': 'Add Favorite Page', + 'dot.edit.page.toolbar.preliminary.results': 'Preliminary Results', + running: 'Running' }) }, { @@ -372,6 +395,25 @@ describe('DotEditPageToolbarComponent', () => { }); }); + describe('Go to Experiment results', () => { + it('should show an experiment is running an go to results', (done) => { + const location = de.injector.get(Location); + componentHost.runningExperiment = { pageId: 'pageId', id: 'id' } as DotExperiment; + + fixtureHost.detectChanges(); + + const experimentTag = de.query(By.css('[data-testId="runningExperimentTag"]')); + + experimentTag.nativeElement.click(); + + expect(experimentTag.componentInstance.value).toEqual('Running'); + fixtureHost.whenStable().then(() => { + expect(location.path()).toEqual('/edit-page/experiments/pageId/id/reports'); + done(); + }); + }); + }); + describe('events', () => { let whatsChangedElem: DebugElement; beforeEach(() => { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/components/dot-edit-page-toolbar/dot-edit-page-toolbar.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/components/dot-edit-page-toolbar/dot-edit-page-toolbar.component.ts index 3fe97535d65e..1ccb388d8b56 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/components/dot-edit-page-toolbar/dot-edit-page-toolbar.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/components/dot-edit-page-toolbar/dot-edit-page-toolbar.component.ts @@ -15,6 +15,7 @@ import { take } from 'rxjs/operators'; import { DotLicenseService, DotPropertiesService } from '@dotcms/data-access'; import { DotCMSContentlet, + DotExperiment, DotPageMode, DotPageRenderState, DotVariantData, @@ -29,6 +30,7 @@ import { export class DotEditPageToolbarComponent implements OnInit, OnChanges, OnDestroy { @Input() pageState: DotPageRenderState; @Input() variant: DotVariantData | null = null; + @Input() runningExperiment: DotExperiment | null = null; @Output() cancel = new EventEmitter(); @Output() actionFired = new EventEmitter(); @Output() favoritePage = new EventEmitter(); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/components/dot-edit-page-toolbar/dot-edit-page-toolbar.module.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/components/dot-edit-page-toolbar/dot-edit-page-toolbar.module.ts index edc96d048492..4f2990ee0627 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/components/dot-edit-page-toolbar/dot-edit-page-toolbar.module.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/components/dot-edit-page-toolbar/dot-edit-page-toolbar.module.ts @@ -1,10 +1,12 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; import { ButtonModule } from 'primeng/button'; import { CheckboxModule } from 'primeng/checkbox'; import { DialogService } from 'primeng/dynamicdialog'; +import { TagModule } from 'primeng/tag'; import { ToolbarModule } from 'primeng/toolbar'; import { TooltipModule } from 'primeng/tooltip'; @@ -42,7 +44,9 @@ import { DotEditPageWorkflowsActionsModule } from '../dot-edit-page-workflows-ac DotFavoritePageModule, UiDotIconButtonModule, DotIconModule, - DotEditPageNavDirective + DotEditPageNavDirective, + RouterLink, + TagModule ], exports: [DotEditPageToolbarComponent], declarations: [DotEditPageToolbarComponent], diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/dot-edit-content.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/dot-edit-content.component.html index c1710fa772a9..7a72c6bb7f3f 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/dot-edit-content.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/dot-edit-content.component.html @@ -15,6 +15,7 @@ class="dot-edit__toolbar" [pageState]="pageState" [variant]="variantData | async" + [runningExperiment]="runningExperiment$ | async" (actionFired)="reload($event)" (backToExperiment)="backToExperiment()" (cancel)="onCancelToolbar()" diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/dot-edit-content.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/dot-edit-content.component.spec.ts index cd689b52ea53..d27e7b9be258 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/dot-edit-content.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/dot-edit-content.component.spec.ts @@ -85,6 +85,7 @@ import { processedContainers, SiteServiceMock } from '@dotcms/utils-testing'; +import { DotExperimentsService } from '@portlets/dot-experiments/shared/services/dot-experiments.service'; import { getExperimentMock } from '@portlets/dot-experiments/test/mocks'; import { DotEditPageWorkflowsActionsModule } from './components/dot-edit-page-workflows-actions/dot-edit-page-workflows-actions.module'; @@ -152,6 +153,7 @@ export class MockDotFormSelectorComponent { export class MockDotEditPageToolbarComponent { @Input() pageState = mockDotRenderedPageState; @Input() variant; + @Input() runningExperiment; @Output() actionFired = new EventEmitter(); @Output() cancel = new EventEmitter(); @Output() favoritePage = new EventEmitter(); @@ -273,6 +275,7 @@ describe('DotEditContentComponent', () => { DotSessionStorageService, DotCopyContentModalService, DotFavoritePageService, + DotExperimentsService, { provide: LoginService, useClass: LoginServiceMock @@ -407,9 +410,14 @@ describe('DotEditContentComponent', () => { describe('dot-edit-page-toolbar', () => { let toolbarElement: DebugElement; + let dotExperimentsService: DotExperimentsService; beforeEach(() => { + dotExperimentsService = de.injector.get(DotExperimentsService); + spyOn(dialogService, 'open'); + spyOn(dotExperimentsService, 'getByStatus').and.returnValue(of([EXPERIMENT_MOCK])); + fixture.detectChanges(); toolbarElement = de.query(By.css('dot-edit-page-toolbar')); }); @@ -440,6 +448,10 @@ describe('DotEditContentComponent', () => { }); }); + it('should pass running experiment', () => { + expect(toolbarElement.componentInstance.runningExperiment).toEqual(EXPERIMENT_MOCK); + }); + describe('events', () => { it('cancel > should go to site browser', () => { toolbarElement.triggerEventHandler('cancel', {}); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/dot-edit-content.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/dot-edit-content.component.ts index 7278f82f65c4..322993d23346 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/dot-edit-content.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/content/dot-edit-content.component.ts @@ -32,6 +32,7 @@ import { DotCMSContentType, DotContainerStructure, DotExperiment, + DotExperimentStatusList, DotIframeEditEvent, DotPageContainer, DotPageMode, @@ -41,6 +42,7 @@ import { ESContent } from '@dotcms/dotcms-models'; import { DotLoadingIndicatorService, generateDotFavoritePageUrl } from '@dotcms/utils'; +import { DotExperimentsService } from '@portlets/dot-experiments/shared/services/dot-experiments.service'; import { DotEditContentHtmlService } from './services/dot-edit-content-html/dot-edit-content-html.service'; import { @@ -84,6 +86,7 @@ export class DotEditContentComponent implements OnInit, OnDestroy { paletteCollapsed = false; isEnterpriseLicense = false; variantData: Observable; + runningExperiment$: Observable; private readonly customEventsHandler; private destroy$: Subject = new Subject(); @@ -113,7 +116,8 @@ export class DotEditContentComponent implements OnInit, OnDestroy { private dotESContentService: DotESContentService, private dotSessionStorageService: DotSessionStorageService, private dotCurrentUser: DotCurrentUserService, - private dotFavoritePageService: DotFavoritePageService + private dotFavoritePageService: DotFavoritePageService, + private dotExperimentsService: DotExperimentsService ) { if (!this.customEventsHandler) { this.customEventsHandler = { @@ -181,6 +185,7 @@ browse from the page internal links this.subscribeOverlayService(); this.subscribeDraggedContentType(); this.getExperimentResolverData(); + this.getRunningExperiment(); } ngOnDestroy(): void { @@ -634,4 +639,17 @@ browse from the page internal links }) ); } + + private getRunningExperiment(): void { + this.runningExperiment$ = this.pageState$.pipe( + take(1), + switchMap((content) => + this.dotExperimentsService.getByStatus( + content.page.identifier, + DotExperimentStatusList.RUNNING + ) + ), + map((experiments) => (experiments.length ? experiments[0] : null)) + ); + } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/services/dot-experiments.service.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/services/dot-experiments.service.spec.ts index 981aa18ce1c7..bf317243baac 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/services/dot-experiments.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/services/dot-experiments.service.spec.ts @@ -3,6 +3,7 @@ import { createHttpFactory, HttpMethod, SpectatorHttp } from '@ngneat/spectator' import { DefaultGoalConfiguration, DotExperiment, + DotExperimentStatusList, Goals, GoalsLevels, TrafficProportionTypes @@ -37,6 +38,11 @@ describe('DotExperimentsService', () => { spectator.expectOne(`${API_ENDPOINT}?pageId=${PAGE_Id}`, HttpMethod.GET); }); + it('should get a list of experiments filter by status', () => { + spectator.service.getByStatus(PAGE_Id, DotExperimentStatusList.RUNNING).subscribe(); + spectator.expectOne(`${API_ENDPOINT}?pageId=${PAGE_Id}&status=RUNNING`, HttpMethod.GET); + }); + it('should get an experiment by getById using experimentId', () => { spectator.service.getById(EXPERIMENT_ID).subscribe(); spectator.expectOne(`${API_ENDPOINT}/${EXPERIMENT_ID}`, HttpMethod.GET); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/services/dot-experiments.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/services/dot-experiments.service.ts index 291cda22addd..b04365b82b12 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/services/dot-experiments.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/services/dot-experiments.service.ts @@ -9,6 +9,7 @@ import { DotCMSResponse } from '@dotcms/dotcms-js'; import { DotExperiment, DotExperimentResults, + DotExperimentStatusList, Goals, GoalsLevels, RangeOfDateAndTime, @@ -47,6 +48,21 @@ export class DotExperimentsService { .pipe(pluck('entity')); } + /** + * Get an array of experiments of a pageId filter by status + * @param {string} pageId + * @param {DotExperimentStatusList} status + * @returns Observable + * @memberof DotExperimentsService + */ + getByStatus(pageId: string, status: DotExperimentStatusList): Observable { + return this.http + .get>( + `${API_ENDPOINT}?pageId=${pageId}&status=${status}` + ) + .pipe(pluck('entity')); + } + /** * Get details of an experiment * @param {string} experimentId From edea388c4fe6f195e58691ce70c93f1fe83b34a0 Mon Sep 17 00:00:00 2001 From: erickgonzalez Date: Sun, 4 Jun 2023 15:23:31 -0600 Subject: [PATCH 58/63] #25037 consider TZ when pushBundle (#25038) --- .../ajax/RemotePublishAjaxAction.java | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/publisher/ajax/RemotePublishAjaxAction.java b/dotCMS/src/main/java/com/dotcms/publisher/ajax/RemotePublishAjaxAction.java index fd75580bfa6e..a08164c27379 100644 --- a/dotCMS/src/main/java/com/dotcms/publisher/ajax/RemotePublishAjaxAction.java +++ b/dotCMS/src/main/java/com/dotcms/publisher/ajax/RemotePublishAjaxAction.java @@ -890,6 +890,7 @@ public void pushBundle(final HttpServletRequest request, final HttpServletRespon final String iWantTo = request.getParameter( "iWantTo" ); final String whoToSendTmp = request.getParameter( "whoToSend" ); final String filterKey = request.getParameter("filterKey"); + final String timezoneId = request.getParameter( "timezoneId" ); try { final boolean forcePush = (boolean) APILocator.getPublisherAPI().getFilterDescriptorByKey(filterKey).getFilters().getOrDefault(FilterDescriptor.FORCE_PUSH_KEY,false); @@ -910,13 +911,19 @@ public void pushBundle(final HttpServletRequest request, final HttpServletRespon return; } + final TimeZone currentTimeZone = + UtilMethods.isSet(timezoneId) ? TimeZone.getTimeZone(timezoneId) + : APILocator.systemTimeZone(); + + final Date publishDate = DateUtil + .convertDate(contentPushPublishDate + "-" + contentPushPublishTime, + currentTimeZone, STANDARD_DATE_FORMAT); + //Put the selected environments in session in order to have the list of the last selected environments request.getSession().setAttribute( WebKeys.SELECTED_ENVIRONMENTS + getUser().getUserId(), envsToSendTo ); //Clean up the selected bundle request.getSession().removeAttribute( WebKeys.SELECTED_BUNDLE + getUser().getUserId() ); - final SimpleDateFormat dateFormat = new SimpleDateFormat(STANDARD_DATE_FORMAT); - final Date publishDate = dateFormat.parse( contentPushPublishDate + "-" + contentPushPublishTime ); final Bundle bundle = bundleAPI.getBundleById(bundleId); bundle.setForcePush(forcePush); bundle.setFilterKey(filterKey); @@ -927,12 +934,16 @@ public void pushBundle(final HttpServletRequest request, final HttpServletRespon bundleAPI.updateBundle(bundle); publisherAPI.publishBundleAssets(bundle.getId(), publishDate); } else if (iWantTo.equals(RemotePublishAjaxAction.DIALOG_ACTION_EXPIRE) && UtilMethods.isSet(contentPushExpireDate) && UtilMethods.isSet(contentPushExpireTime)) { - final Date expireDate = dateFormat.parse( contentPushExpireDate + "-" + contentPushExpireTime ); + final Date expireDate = DateUtil + .convertDate(contentPushExpireDate + "-" + contentPushExpireTime, + currentTimeZone, STANDARD_DATE_FORMAT); bundle.setExpireDate(expireDate); bundleAPI.updateBundle(bundle); publisherAPI.unpublishBundleAssets(bundle.getId(), expireDate); } else if (iWantTo.equals(RemotePublishAjaxAction.DIALOG_ACTION_PUBLISH_AND_EXPIRE) && UtilMethods.isSet(contentPushExpireDate) && UtilMethods.isSet(contentPushExpireTime)) { - final Date expireDate = dateFormat.parse( contentPushExpireDate + "-" + contentPushExpireTime ); + final Date expireDate = DateUtil + .convertDate(contentPushExpireDate + "-" + contentPushExpireTime, + currentTimeZone, STANDARD_DATE_FORMAT); bundle.setPublishDate(publishDate); bundle.setExpireDate(expireDate); bundleAPI.updateBundle(bundle); From b7bfc444f8df61f3642bc0bde31a211fc6d2d4cb Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 5 Jun 2023 12:26:02 -0400 Subject: [PATCH 59/63] Goal name max length to 255 characters (#25146) * (#24197) chage constant to use MAX_INPUT_DESCRIPTIVE_LENGTH in goal name * (#24197) fix test --- ...experiments-configuration-goal-select.component.spec.ts | 4 ++-- .../dot-experiments-configuration-goal-select.component.ts | 4 ++-- ...dot-experiments-configuration-variants-add.component.ts | 6 ++---- .../dot-experiments-configuration-variants.component.ts | 7 ++----- .../dot-experiments-create.component.ts | 4 ++-- .../dotcms-models/src/lib/dot-experiments-constants.ts | 4 +++- 6 files changed, 13 insertions(+), 16 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/components/dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/components/dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component.spec.ts index 7e4ea5d1f239..9670358f23b9 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/components/dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/components/dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component.spec.ts @@ -214,11 +214,11 @@ describe('DotExperimentsConfigurationGoalSelectComponent', () => { expect(store.setSelectedGoal).toHaveBeenCalledWith(expectedGoal); }); - it('should disable submit button if the input name of the goal has more than MAX_INPUT_LENGTH constant', () => { + it('should disable submit button if the input name of the goal has more than MAX_INPUT_DESCRIPTIVE_LENGTH constant', () => { const invalidFormValues = { primary: { ...DefaultGoalConfiguration.primary, - name: 'Really really really really really long name for a goal', + name: 'Really really really really really Really really really really really Really really Really really really really really Really really really really really Really really Really really really really really Really really really really really Really really Really really really really really Really really really really really Really really Really really really really really Really really really really really Really really Really really really really really Really really really really really Really really really really really long name for a goal', type: GOAL_TYPES.BOUNCE_RATE } }; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/components/dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/components/dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component.ts index 0729198b0e04..6a39e9b99c88 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/components/dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/components/dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component.ts @@ -27,7 +27,7 @@ import { GOAL_TYPES, Goals, GOALS_METADATA_MAP, - MAX_INPUT_LENGTH, + MAX_INPUT_DESCRIPTIVE_LENGTH, StepStatus } from '@dotcms/dotcms-models'; import { DotMessagePipeModule } from '@pipes/dot-message/dot-message-pipe.module'; @@ -76,7 +76,7 @@ export class DotExperimentsConfigurationGoalSelectComponent implements OnInit, O statusList = ComponentStatus; vm$: Observable<{ experimentId: string; goals: Goals; status: StepStatus }> = this.dotExperimentsConfigurationStore.goalsStepVm$; - protected readonly maxNameLength = MAX_INPUT_LENGTH; + protected readonly maxNameLength = MAX_INPUT_DESCRIPTIVE_LENGTH; private destroy$: Subject = new Subject(); private BOUNCE_RATE_LABEL = this.dotMessageService.get( 'experiments.goal.conditions.minimize.bounce.rate' diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.ts index 44143869f971..a54cfb055281 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.ts @@ -12,7 +12,7 @@ import { DotFieldValidationMessageModule } from '@components/_common/dot-field-v import { DotAutofocusModule } from '@directives/dot-autofocus/dot-autofocus.module'; import { ComponentStatus, - MAX_INPUT_LENGTH, + MAX_INPUT_TITLE_LENGTH, StepStatus, TrafficProportion } from '@dotcms/dotcms-models'; @@ -44,17 +44,15 @@ import { DotSidebarHeaderComponent } from '@shared/dot-sidebar-header/dot-sideba changeDetection: ChangeDetectionStrategy.OnPush }) export class DotExperimentsConfigurationVariantsAddComponent implements OnInit { - protected readonly maxNameLength = MAX_INPUT_LENGTH; stepStatus = ComponentStatus; - form: FormGroup; - vm$: Observable<{ experimentId: string; trafficProportion: TrafficProportion; status: StepStatus; isExperimentADraft: boolean; }> = this.dotExperimentsConfigurationStore.variantsStepVm$; + protected readonly maxNameLength = MAX_INPUT_TITLE_LENGTH; constructor( private readonly dotExperimentsConfigurationStore: DotExperimentsConfigurationStore diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.ts index 9c26303fa37d..78fa3ee0ae78 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.ts @@ -24,7 +24,7 @@ import { DEFAULT_VARIANT_NAME, DotPageMode, ExperimentSteps, - MAX_INPUT_LENGTH, + MAX_INPUT_TITLE_LENGTH, MAX_VARIANTS_ALLOWED, SIDEBAR_STATUS, StepStatus, @@ -73,17 +73,14 @@ export class DotExperimentsConfigurationVariantsComponent { }> = this.dotExperimentsConfigurationStore.variantsStepVm$.pipe( tap(({ status }) => this.handleSidebar(status)) ); - - protected readonly maxNameLength = MAX_INPUT_LENGTH; statusList = ComponentStatus; sidebarStatusList = SIDEBAR_STATUS; maxVariantsAllowed = MAX_VARIANTS_ALLOWED; defaultVariantName = DEFAULT_VARIANT_NAME; experimentStepName = ExperimentSteps.VARIANTS; dotPageMode = DotPageMode; - @ViewChild(DotDynamicDirective, { static: true }) sidebarHost!: DotDynamicDirective; - + protected readonly maxNameLength = MAX_INPUT_TITLE_LENGTH; private componentRef: ComponentRef; constructor( diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.ts index 1908fbe2c14f..7510784effbe 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.ts @@ -12,7 +12,7 @@ import { SidebarModule } from 'primeng/sidebar'; import { DotFieldValidationMessageModule } from '@components/_common/dot-field-validation-message/dot-file-validation-message.module'; import { UiDotIconButtonModule } from '@components/_common/dot-icon-button/dot-icon-button.module'; import { DotAutofocusModule } from '@directives/dot-autofocus/dot-autofocus.module'; -import { DotExperiment, MAX_INPUT_LENGTH } from '@dotcms/dotcms-models'; +import { DotExperiment, MAX_INPUT_TITLE_LENGTH } from '@dotcms/dotcms-models'; import { DotMessagePipeModule } from '@pipes/dot-message/dot-message-pipe.module'; import { DotExperimentsListStore, @@ -54,7 +54,7 @@ export class DotExperimentsCreateComponent implements OnInit { vm$: Observable = this.dotExperimentsListStore.createVm$; form: FormGroup; - protected readonly maxNameLength = MAX_INPUT_LENGTH; + protected readonly maxNameLength = MAX_INPUT_TITLE_LENGTH; constructor(private readonly dotExperimentsListStore: DotExperimentsListStore) {} diff --git a/core-web/libs/dotcms-models/src/lib/dot-experiments-constants.ts b/core-web/libs/dotcms-models/src/lib/dot-experiments-constants.ts index 2f45177b1ad8..e97fe48c7211 100644 --- a/core-web/libs/dotcms-models/src/lib/dot-experiments-constants.ts +++ b/core-web/libs/dotcms-models/src/lib/dot-experiments-constants.ts @@ -25,7 +25,9 @@ export enum TrafficProportionTypes { CUSTOM_PERCENTAGES = 'CUSTOM_PERCENTAGES' } -export const MAX_INPUT_LENGTH = 50; +export const MAX_INPUT_TITLE_LENGTH = 50; + +export const MAX_INPUT_DESCRIPTIVE_LENGTH = 255; // Keep the order of this enum is important to respect the order of the experiment listing. export enum DotExperimentStatusList { From f89b13cbd4fe2c96eea36d329e8a64dc38b70e4f Mon Sep 17 00:00:00 2001 From: Humberto Morera <31667212+hmoreras@users.noreply.github.com> Date: Mon, 5 Jun 2023 17:13:43 -0300 Subject: [PATCH 60/63] dotCMS/core#25108 [UI] Allow cancel a Experiment in SCHEDULED status (#25142) * stop experiment when schedule * feedback --- ...t-experiments-configuration.component.html | 12 ++++++- ...xperiments-configuration.component.spec.ts | 19 +++++++++- ...dot-experiments-configuration.component.ts | 20 +++++++++++ ...ot-experiments-configuration-store.spec.ts | 36 +++++++++++++++++++ .../dot-experiments-configuration-store.ts | 27 ++++++++++++++ .../services/dot-experiments.service.spec.ts | 5 +++ .../services/dot-experiments.service.ts | 15 ++++++++ .../WEB-INF/messages/Language.properties | 5 +++ 8 files changed, 137 insertions(+), 2 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/dot-experiments-configuration.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/dot-experiments-configuration.component.html index bfcfad8d4d48..a6550930a270 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/dot-experiments-configuration.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/dot-experiments-configuration.component.html @@ -17,6 +17,16 @@ pButton type="button" > + diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/dot-experiments-configuration.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/dot-experiments-configuration.component.spec.ts index 551c6e063a16..2850bcd1bb2a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/dot-experiments-configuration.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/dot-experiments-configuration.component.spec.ts @@ -199,13 +199,30 @@ describe('DotExperimentsConfigurationComponent', () => { ); }); - it('should show hide stop Experiment button if experiment status is different than running', () => { + it('should show Cancel Scheduling button if experiment status is Schedule and call cancel after confirmation', () => { + spyOn(dotExperimentsConfigurationStore, 'cancelSchedule'); + spectator.component.vm$ = of({ + ...defaultVmMock, + experimentStatus: DotExperimentStatusList.SCHEDULED + }); + spectator.detectChanges(); + + spectator.click(byTestId('cancel-schedule-experiment-button')); + spectator.query(ConfirmPopup).accept(); + + expect(dotExperimentsConfigurationStore.cancelSchedule).toHaveBeenCalledWith( + EXPERIMENT_MOCK + ); + }); + + it('should show hide stop Experiment and unscheduled button if experiment status is different than running', () => { spectator.component.vm$ = of({ ...defaultVmMock, experimentStatus: DotExperimentStatusList.DRAFT }); spectator.detectChanges(); expect(spectator.query(byTestId('stop-experiment-button'))).not.toExist(); + expect(spectator.query(byTestId('cancel-schedule-experiment-button'))).not.toExist(); }); it('should show Start Experiment button disabled if disabledStartExperiment true', () => { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/dot-experiments-configuration.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/dot-experiments-configuration.component.ts index b28147d5e1d4..b6b30978e03c 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/dot-experiments-configuration.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/dot-experiments-configuration.component.ts @@ -111,4 +111,24 @@ export class DotExperimentsConfigurationComponent implements OnInit { } }); } + + /** + * Cancel the Schedule Experiment and set the status to Draft + * @param {MouseEvent} $event + * @param {DotExperiment} experiment + * @returns void + * @memberof DotExperimentsConfigurationVariantsComponent + */ + cancelScheduleExperiment($event: MouseEvent, experiment: DotExperiment) { + this.confirmationService.confirm({ + target: $event.target, + message: this.dotMessagePipe.transform('experiments.action.cancel.schedule-confirm'), + icon: 'pi pi-exclamation-triangle', + acceptLabel: this.dotMessagePipe.transform('dot.common.dialog.accept'), + rejectLabel: this.dotMessagePipe.transform('dot.common.dialog.reject'), + accept: () => { + this.dotExperimentsConfigurationStore.cancelSchedule(experiment); + } + }); + } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/store/dot-experiments-configuration-store.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/store/dot-experiments-configuration-store.spec.ts index 8e687b1d97d9..19fef2731223 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/store/dot-experiments-configuration-store.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/store/dot-experiments-configuration-store.spec.ts @@ -631,5 +631,41 @@ describe('DotExperimentsConfigurationStore', () => { 'error' as unknown as HttpErrorResponse ); }); + + it('should call the cancel experiment method when cancel scheduling', (done) => { + dotExperimentsService.getById.and + .callThrough() + .and.returnValue( + of({ ...EXPERIMENT_MOCK_2, status: DotExperimentStatusList.SCHEDULED }) + ); + + dotExperimentsService.cancelSchedule.and.callThrough().and.returnValue( + of({ + ...EXPERIMENT_MOCK_2, + status: DotExperimentStatusList.DRAFT + }) + ); + + spectator.service.loadExperiment(EXPERIMENT_MOCK_2.id); + + store.cancelSchedule(EXPERIMENT_MOCK_2); + + store.state$.subscribe(() => { + expect(dotExperimentsService.cancelSchedule).toHaveBeenCalledOnceWith( + EXPERIMENT_MOCK_2.id + ); + done(); + }); + }); + + it('should handle error when canceling the experiment', () => { + dotExperimentsService.cancelSchedule.and.returnValue(throwError('error')); + + store.cancelSchedule(EXPERIMENT_MOCK_2); + + expect(dotHttpErrorManagerService.handle).toHaveBeenCalledOnceWith( + 'error' as unknown as HttpErrorResponse + ); + }); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/store/dot-experiments-configuration-store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/store/dot-experiments-configuration-store.ts index 89aea03eb19e..36dc7244416c 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/store/dot-experiments-configuration-store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/dot-experiments-configuration/store/dot-experiments-configuration-store.ts @@ -298,6 +298,33 @@ export class DotExperimentsConfigurationStore extends ComponentStore) => { + return experiment$.pipe( + tap(() => this.setComponentStatus(ComponentStatus.SAVING)), + switchMap((experiment) => + this.dotExperimentsService.cancelSchedule(experiment.id).pipe( + tapResponse( + (response) => { + this.messageService.add({ + severity: 'info', + summary: this.dotMessageService.get( + 'experiments.notification.cancel.schedule-title' + ), + detail: this.dotMessageService.get( + 'experiments.notification.cancel.schedule', + experiment.name + ) + }); + this.setExperiment(response); + }, + (error: HttpErrorResponse) => this.dotHttpErrorManagerService.handle(error), + () => this.setComponentStatus(ComponentStatus.IDLE) + ) + ) + ) + ); + }); + // Variants readonly addVariant = this.effect( (variant$: Observable<{ experimentId: string; name: string }>) => { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/services/dot-experiments.service.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/services/dot-experiments.service.spec.ts index bf317243baac..861f159f2762 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/services/dot-experiments.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/services/dot-experiments.service.spec.ts @@ -68,6 +68,11 @@ describe('DotExperimentsService', () => { spectator.expectOne(`${API_ENDPOINT}/${EXPERIMENT_ID}/_end`, HttpMethod.POST); }); + it('should cancel schedule an experiment with experimentId as param', () => { + spectator.service.cancelSchedule(EXPERIMENT_ID).subscribe(); + spectator.expectOne(`${API_ENDPOINT}/scheduled/${EXPERIMENT_ID}/_cancel`, HttpMethod.POST); + }); + it('should delete a experiment with experimentId', () => { spectator.service.delete(EXPERIMENT_ID).subscribe(); spectator.expectOne(`${API_ENDPOINT}/${EXPERIMENT_ID}`, HttpMethod.DELETE); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/services/dot-experiments.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/services/dot-experiments.service.ts index b04365b82b12..fd91afad565b 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/services/dot-experiments.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-experiments/shared/services/dot-experiments.service.ts @@ -135,6 +135,21 @@ export class DotExperimentsService { .pipe(pluck('entity')); } + /** + * Cancel schedule experiment and set it to draft + * @param {string} experimentId + * @returns Observable + * @memberof DotExperimentsService + */ + cancelSchedule(experimentId: string): Observable { + return this.http + .post>( + `${API_ENDPOINT}/scheduled/${experimentId}/_cancel`, + {} + ) + .pipe(pluck('entity')); + } + /** * Add variant to experiment * @param {number} experimentId diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 95b74460765f..4b1505472649 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -5148,10 +5148,14 @@ experiments.action.start.confirm-message=Experiment "{0}" started successfully experiments.action.scheduled.confirm-title=Experiment scheduled experiments.action.scheduled.confirm-message=Experiment "{0}" scheduled successfully experiments.action.stop-experiment=Stop Experiment +experiments.action.end-experiment=End Experiment experiments.action.stop.confirm-title=Experiment stopped experiments.action.stop.confirm-message=Experiment "{0}" stopped successfully experiments.action.stop.delete-confirm=Are you sure you want to stop the experiment? experiments.action.schedule-experiment=Schedule Experiment +experiments.action.cancel.schedule-confirm=Are you sure you want to cancel the schedule of the experiment? +experiments.notification.cancel.schedule-title=Experiment unscheduled +experiments.notification.cancel.schedule=Experiment "{0}" unscheduled successfully experiments.action.configuration=Configuration stop=stop experiments.configure.variant.delete.confirm=Are you sure you want to delete this variant? @@ -5244,6 +5248,7 @@ experiments.configure.scheduling.start.time=Start time experiments.configure.scheduling.end.time=End time experiments.configure.scheduling.add.confirm.title=Assigned scheduling to experiment experiments.configure.scheduling.add.confirm.message=Scheduling assigned and saved successfully +experiments.configure.scheduling.cancel=Cancel Scheduling experiments.configure.traffic.allocation=Traffic Allocation experiments.configure.traffic.allocation.add.description=This controls the percentage of visitors that are eligible to see the experience. Note: eligible visitors must still match your targeting rules to see the experience. experiments.configure.traffic.allocation.add.confirm.title=Assigned traffic allocation to experiment From 31f131cbd91db40b5230d40b84e78b9ab41c2a24 Mon Sep 17 00:00:00 2001 From: Manuel Rojas Date: Mon, 5 Jun 2023 14:21:08 -0600 Subject: [PATCH 61/63] Fix #25015 template builder implement delete row functionality (#25141) * Adding remove row component * Adding remove box component * Updating to the new confirm popup * Adding new confirm pop up * Fix the browseranimationmodule debacle * renaming the component to make more generic * Adding more testing for open dialog * Adding more testing for open dialog * Adding more testing for open dialog * Adding PR feedback * Adding delete row test --------- Co-authored-by: Freddy Montes <751424+fmontes@users.noreply.github.com> --- .../components/_confirmpopup.scss | 8 ++- .../remove-confirm-dialog.component.html | 8 +++ .../remove-confirm-dialog.component.scss | 7 +++ .../remove-confirm-dialog.component.spec.ts | 44 +++++++++++++++++ .../remove-confirm-dialog.component.ts | 34 +++++++++++++ .../template-builder-box.component.stories.ts | 3 +- .../template-builder-row.component.html | 9 ++-- .../template-builder-row.component.scss | 8 --- .../template-builder-row.component.spec.ts | 22 +++------ .../template-builder-row.component.ts | 6 ++- .../template-builder.component.html | 2 +- .../template-builder.component.spec.ts | 49 +++++++++---------- .../template-builder.component.stories.ts | 11 ++++- .../template-builder.component.ts | 4 ++ .../src/lib/template-builder.module.ts | 10 +++- 15 files changed, 164 insertions(+), 61 deletions(-) create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component.html create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component.scss create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component.spec.ts create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component.ts diff --git a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_confirmpopup.scss b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_confirmpopup.scss index deb64d458d23..fea15bb94601 100755 --- a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_confirmpopup.scss +++ b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_confirmpopup.scss @@ -5,15 +5,17 @@ margin-top: $spacing-2; top: 0; left: 0; + width: 21.75rem; background: $white; color: $color-palette-gray-800; border: 0 none; - border-radius: $border-radius-xs; + border-radius: $border-radius-md; box-shadow: $md-shadow-1; .p-confirm-popup-icon { font-size: $font-size-xl; + color: $color-palette-primary-500; } .p-confirm-popup-message { @@ -89,3 +91,7 @@ color: $color-palette-primary; border-color: transparent; } + +.p-confirm-popup .p-button.p-button-text.p-confirm-popup-reject { + border: 1.5px solid $color-palette-primary-400; +} diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component.html b/core-web/libs/template-builder/src/lib/components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component.html new file mode 100644 index 000000000000..e799f773f605 --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component.html @@ -0,0 +1,8 @@ + + + diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component.scss b/core-web/libs/template-builder/src/lib/components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component.scss new file mode 100644 index 000000000000..c870f347d4c5 --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component.scss @@ -0,0 +1,7 @@ +@use "variables" as *; + +::ng-deep { + .p-button.p-button-text .pi.pi-trash { + color: $color-palette-primary-400; + } +} diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component.spec.ts new file mode 100644 index 000000000000..e5702fd1ef8c --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component.spec.ts @@ -0,0 +1,44 @@ +import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator/jest'; + +import { ConfirmationService } from 'primeng/api'; +import { ConfirmPopup } from 'primeng/confirmpopup'; + +import { RemoveConfirmDialogComponent } from './remove-confirm-dialog.component'; + +describe('RemoveConfirmDialogComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: RemoveConfirmDialogComponent, + providers: [ConfirmationService] + }); + + beforeEach(() => { + spectator = createComponent(); + jest.spyOn(ConfirmPopup.prototype, 'bindScrollListener').mockImplementation(jest.fn()); + }); + + it('should emit confirm event and call accept function', async () => { + const confirmEventSpy = jest.spyOn(spectator.component.deleteConfirmed, 'emit'); + + const deleteButton = spectator.query(byTestId('btn-remove-item')); + spectator.dispatchMouseEvent(deleteButton, 'onClick'); + + const confirmAccept = spectator.query('.p-confirm-popup-accept'); + spectator.click(confirmAccept); + + expect(confirmEventSpy).toHaveBeenCalled(); + }); + + it('should emit confirm event and call reject function', () => { + const rejectEventSpy = jest.spyOn(spectator.component.deleteRejected, 'emit'); + + const deleteButton = spectator.query(byTestId('btn-remove-item')); + spectator.dispatchMouseEvent(deleteButton, 'onClick'); + + const confirmRejected = spectator.query('.p-confirm-popup-reject'); + spectator.click(confirmRejected); + + expect(rejectEventSpy).toHaveBeenCalled(); + }); +}); diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component.ts new file mode 100644 index 000000000000..bfdbb1ae9115 --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component.ts @@ -0,0 +1,34 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angular/core'; + +import { ConfirmationService } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { ConfirmPopupModule } from 'primeng/confirmpopup'; + +@Component({ + selector: 'dotcms-remove-confirm-dialog', + templateUrl: './remove-confirm-dialog.component.html', + styleUrls: ['./remove-confirm-dialog.component.scss'], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ConfirmPopupModule, ButtonModule], + providers: [ConfirmationService] +}) +export class RemoveConfirmDialogComponent { + @Output() deleteConfirmed: EventEmitter = new EventEmitter(); + @Output() deleteRejected: EventEmitter = new EventEmitter(); + constructor(private confirmationService: ConfirmationService) {} + + openConfirmationDialog(event: Event): void { + this.confirmationService.confirm({ + target: event.target, + message: 'Are you sure you want to proceed deleting this item?', + icon: 'pi pi-info-circle', + accept: () => { + this.deleteConfirmed.emit(); + }, + reject: () => { + this.deleteRejected.emit(); + } + }); + } +} diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.stories.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.stories.ts index a14c2dfeda39..bbe2d1c6a76c 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.stories.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.stories.ts @@ -1,5 +1,6 @@ import { moduleMetadata, Story, Meta } from '@storybook/angular'; +import { ConfirmationService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; import { ScrollPanelModule } from 'primeng/scrollpanel'; @@ -11,7 +12,7 @@ export default { decorators: [ moduleMetadata({ imports: [ButtonModule, ScrollPanelModule], - providers: [] + providers: [ConfirmationService] }) ] } as Meta; diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.html b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.html index 63f3e32caaad..e0e4221104b7 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.html +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.html @@ -18,11 +18,8 @@ icon="pi pi-palette" > - - + >
    diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.scss b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.scss index 97d8ac947004..6f3ddeb2a835 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.scss +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.scss @@ -33,11 +33,3 @@ flex-grow: 1; padding: $spacing-3 0; } - -.row__actions-container { - p-button ::ng-deep { - .pi-trash { - color: $color-palette-primary-400; - } - } -} diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.spec.ts index 035118bca98c..c4ca0aab5f7d 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.spec.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.spec.ts @@ -56,25 +56,11 @@ describe('TemplateBuilderRowComponent', () => { fixture.debugElement.query(By.css('p-button[data-testid="row-style-class-button"]')) ).toBeTruthy(); }); - it('should have a trash button', () => { - expect( - fixture.debugElement.query(By.css('p-button[data-testid="row-trash-button"]')) - ).toBeTruthy(); - }); + it('should render child', () => { expect(fixture.debugElement.query(By.css('p'))).toBeTruthy(); }); - it('should trigger deleteRow when clicking on delete button', () => { - jest.spyOn(fixture.componentInstance, 'deleteRow'); - const button = fixture.debugElement.query( - By.css('p-button[data-testid="row-trash-button"]') - ); - - button.nativeElement.dispatchEvent(new Event('onClick')); - - expect(fixture.componentInstance.deleteRow).toHaveBeenCalled(); - }); it('should trigger editRowStyleClass when clicking on editStyleClass button', () => { jest.spyOn(fixture.componentInstance, 'editRowStyleClass'); const button = fixture.debugElement.query( @@ -85,4 +71,10 @@ describe('TemplateBuilderRowComponent', () => { expect(fixture.componentInstance.editRowStyleClass).toHaveBeenCalled(); }); + + it('should have a remove item button', () => { + expect( + fixture.debugElement.query(By.css('p-button[data-testid="btn-remove-item"]')) + ).toBeTruthy(); + }); }); diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.ts index c86f489a093c..6868afcbc3cb 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.ts @@ -2,13 +2,15 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angul import { ButtonModule } from 'primeng/button'; +import { RemoveConfirmDialogComponent } from '../remove-confirm-dialog/remove-confirm-dialog.component'; + @Component({ selector: 'dotcms-template-builder-row', standalone: true, - imports: [ButtonModule], templateUrl: './template-builder-row.component.html', styleUrls: ['./template-builder-row.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ButtonModule, RemoveConfirmDialogComponent] }) export class TemplateBuilderRowComponent { @Output() diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.html b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.html index d2d18c1e057a..d6138c7f352f 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.html +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.html @@ -25,7 +25,7 @@ [attr.gs-w]="row.w" [attr.gs-h]="row.h" > - +
    { - let component: TemplateBuilderComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [TemplateBuilderComponent], - providers: [DotTemplateBuilderStore], - imports: [AddWidgetComponent, TemplateBuilderRowComponent] - }).compileComponents(); - - fixture = TestBed.createComponent(TemplateBuilderComponent); - component = fixture.componentInstance; - - component.templateLayout = { - body: FULL_DATA_MOCK, - footer: false, - header: false, - sidebar: {}, - title: '', - width: '' - }; + let spectator: Spectator; + const createComponent = createComponentFactory({ + component: TemplateBuilderComponent, + imports: [AddWidgetComponent, TemplateBuilderRowComponent, TemplateBuilderRowComponent], + providers: [DotTemplateBuilderStore] + }); - fixture.detectChanges(); + beforeEach(() => { + spectator = createComponent({ + props: { + templateLayout: { + body: FULL_DATA_MOCK, + footer: false, + header: false, + sidebar: {}, + title: '', + width: '' + } + } + }); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should call deleteRow', () => { + const deleteRowMock = jest.spyOn(spectator.component, 'deleteRow'); + spectator.component.deleteRow('123'); + expect(deleteRowMock).toHaveBeenCalledWith('123'); }); }); diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts index ed5f5caf0313..e1e01126bef0 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts @@ -1,8 +1,10 @@ import { moduleMetadata, Story, Meta } from '@storybook/angular'; import { NgFor, AsyncPipe } from '@angular/common'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { AddWidgetComponent } from './components/add-widget/add-widget.component'; +import { RemoveConfirmDialogComponent } from './components/remove-confirm-dialog/remove-confirm-dialog.component'; import { TemplateBuilderRowComponent } from './components/template-builder-row/template-builder-row.component'; import { DotTemplateBuilderStore } from './store/template-builder.store'; import { TemplateBuilderComponent } from './template-builder.component'; @@ -13,7 +15,14 @@ export default { component: TemplateBuilderComponent, decorators: [ moduleMetadata({ - imports: [NgFor, AsyncPipe, TemplateBuilderRowComponent, AddWidgetComponent], + imports: [ + NgFor, + AsyncPipe, + TemplateBuilderRowComponent, + AddWidgetComponent, + RemoveConfirmDialogComponent, + BrowserAnimationsModule + ], providers: [DotTemplateBuilderStore] }) ] diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.ts b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.ts index 51757c5f6eb1..77af96d85b99 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.ts @@ -146,4 +146,8 @@ export class TemplateBuilderComponent implements OnInit, AfterViewInit, OnDestro identify(_: number, w: GridStackWidget) { return w.id; } + + deleteRow(id: string): void { + this.store.removeRow(id); + } } diff --git a/core-web/libs/template-builder/src/lib/template-builder.module.ts b/core-web/libs/template-builder/src/lib/template-builder.module.ts index ffae8cbea142..4562647616e4 100644 --- a/core-web/libs/template-builder/src/lib/template-builder.module.ts +++ b/core-web/libs/template-builder/src/lib/template-builder.module.ts @@ -2,13 +2,21 @@ import { AsyncPipe, NgFor } from '@angular/common'; import { NgModule } from '@angular/core'; import { AddWidgetComponent } from './components/template-builder/components/add-widget/add-widget.component'; +import { RemoveConfirmDialogComponent } from './components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component'; import { TemplateBuilderBoxComponent } from './components/template-builder/components/template-builder-box/template-builder-box.component'; import { TemplateBuilderRowComponent } from './components/template-builder/components/template-builder-row/template-builder-row.component'; import { DotTemplateBuilderStore } from './components/template-builder/store/template-builder.store'; import { TemplateBuilderComponent } from './components/template-builder/template-builder.component'; @NgModule({ - imports: [NgFor, AsyncPipe, TemplateBuilderRowComponent, AddWidgetComponent, TemplateBuilderBoxComponent], + imports: [ + NgFor, + AsyncPipe, + RemoveConfirmDialogComponent, + TemplateBuilderRowComponent, + AddWidgetComponent, + TemplateBuilderBoxComponent + ], declarations: [TemplateBuilderComponent], providers: [DotTemplateBuilderStore], exports: [TemplateBuilderComponent] From 92be5d2be9bb1b1a06a28b498cefcc3ff0a21d13 Mon Sep 17 00:00:00 2001 From: Daniel Montes de Oca Date: Tue, 6 Jun 2023 07:45:39 -0600 Subject: [PATCH 62/63] Fix #25133 Adding background grid guide (#25150) * adding background grid guide, missing cleanup * cleanup * add unit test, use constants for gap * solved conflicts * extended grid to cover all the grid stack * using constant for columns padding * reduce margin between columns * reduce margin between columns * remove unnecessary variable --- ...-builder-background-columns.component.html | 3 ++ ...-builder-background-columns.component.scss | 17 +++++++ ...ilder-background-columns.component.spec.ts | 30 +++++++++++++ ...te-builder-background-columns.component.ts | 17 +++++++ .../template-builder.component.html | 45 ++++++++++--------- .../template-builder.component.stories.ts | 4 +- .../template-builder.component.ts | 8 +++- .../utils/gridstack-options.ts | 10 ++++- .../src/lib/template-builder.module.ts | 7 ++- 9 files changed, 113 insertions(+), 28 deletions(-) create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.html create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.scss create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.spec.ts create mode 100644 core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.ts diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.html b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.html new file mode 100644 index 000000000000..8d6473182298 --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.html @@ -0,0 +1,3 @@ +
    +
    +
    diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.scss b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.scss new file mode 100644 index 000000000000..2493ef26db70 --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.scss @@ -0,0 +1,17 @@ +@use "variables" as *; + +$columns-padding: 3.2rem; + +.background-columns { + height: 100%; + width: 100%; + grid-template-columns: repeat(12, minmax(0, 1fr)); + display: grid; + padding: 0 $columns-padding 0 $columns-padding; + position: absolute; +} + +.column { + height: 100%; + background-color: $color-palette-gray-200; +} diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.spec.ts new file mode 100644 index 000000000000..b33031c4dda0 --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.spec.ts @@ -0,0 +1,30 @@ +import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; + +import { NgFor, NgStyle } from '@angular/common'; +import { By } from '@angular/platform-browser'; + +import { TemplateBuilderBackgroundColumnsComponent } from './template-builder-background-columns.component'; + +describe('TemplateBuilderBackgroundColumnsComponent', () => { + let spectator: SpectatorHost; + + const createHost = createHostFactory({ + component: TemplateBuilderBackgroundColumnsComponent, + imports: [NgFor, NgStyle] + }); + + beforeEach(() => { + spectator = createHost( + `` + ); + }); + + it('should create the component', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should have 12 columns', () => { + const columns = spectator.debugElement.queryAll(By.css('[data-testclass="column"]')); + expect(columns.length).toEqual(12); + }); +}); diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.ts new file mode 100644 index 000000000000..1744209bca96 --- /dev/null +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component.ts @@ -0,0 +1,17 @@ +import { NgFor, NgStyle } from '@angular/common'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { GRID_STACK_MARGIN, GRID_STACK_UNIT } from '../../utils/gridstack-options'; + +@Component({ + selector: 'dotcms-template-builder-background-columns', + templateUrl: './template-builder-background-columns.component.html', + styleUrls: ['./template-builder-background-columns.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [NgFor, NgStyle] +}) +export class TemplateBuilderBackgroundColumnsComponent { + readonly columnList = [].constructor(12); + readonly gridStackGap = `${GRID_STACK_MARGIN * 2}${GRID_STACK_UNIT}`; +} diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.html b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.html index d6138c7f352f..21af8cf8edd9 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.html +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.html @@ -15,7 +15,8 @@ >
    -
    + - -
    -
    -
    - styleClass: {{ box.styleClass?.join(' ') }} -

    - identifier: {{ container.identifier }} -

    -
    +
    +
    +
    + styleClass: {{ box.styleClass?.join(' ') }} +

    + identifier: {{ container.identifier }} +

    - -
    +
    +
    diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts index e1e01126bef0..51b9d983540a 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts @@ -5,6 +5,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { AddWidgetComponent } from './components/add-widget/add-widget.component'; import { RemoveConfirmDialogComponent } from './components/remove-confirm-dialog/remove-confirm-dialog.component'; +import { TemplateBuilderBackgroundColumnsComponent } from './components/template-builder-background-columns/template-builder-background-columns.component'; import { TemplateBuilderRowComponent } from './components/template-builder-row/template-builder-row.component'; import { DotTemplateBuilderStore } from './store/template-builder.store'; import { TemplateBuilderComponent } from './template-builder.component'; @@ -21,7 +22,8 @@ export default { TemplateBuilderRowComponent, AddWidgetComponent, RemoveConfirmDialogComponent, - BrowserAnimationsModule + BrowserAnimationsModule, + TemplateBuilderBackgroundColumnsComponent ], providers: [DotTemplateBuilderStore] }) diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.ts b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.ts index 77af96d85b99..14328a08d4c4 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.ts @@ -24,7 +24,12 @@ import { DotLayout } from '@dotcms/dotcms-models'; import { colIcon, rowIcon } from './assets/icons'; import { DotGridStackWidget } from './models/models'; import { DotTemplateBuilderStore } from './store/template-builder.store'; -import { gridOptions, subGridOptions } from './utils/gridstack-options'; +import { + GRID_STACK_ROW_HEIGHT, + GRID_STACK_UNIT, + gridOptions, + subGridOptions +} from './utils/gridstack-options'; import { parseFromDotObjectToGridStack } from './utils/gridstack-utils'; @Component({ @@ -53,6 +58,7 @@ export class TemplateBuilderComponent implements OnInit, AfterViewInit, OnDestro public readonly rowIcon = rowIcon; public readonly colIcon = colIcon; + public readonly rowDisplayHeight = `${GRID_STACK_ROW_HEIGHT - 1}${GRID_STACK_UNIT}`; // setting a lower height to have space between rows constructor(private store: DotTemplateBuilderStore) { this.items$ = this.store.items$; diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/utils/gridstack-options.ts b/core-web/libs/template-builder/src/lib/components/template-builder/utils/gridstack-options.ts index 2d033ff17833..5c14e928d8d9 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/utils/gridstack-options.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/utils/gridstack-options.ts @@ -2,6 +2,12 @@ import { GridStackOptions } from 'gridstack'; export const WIDGET_TYPE_ATTRIBUTE = 'data-widget-type'; +export const GRID_STACK_MARGIN = 0.5; + +export const GRID_STACK_UNIT = 'rem'; + +export const GRID_STACK_ROW_HEIGHT = 16.5; + export enum widgetType { ROW = 'row', COLUMN = 'col' @@ -32,7 +38,7 @@ function isARowWidget(el: Element): boolean { export const subGridOptions: GridStackOptions = { cellHeight: 224, column: 'auto', - margin: 16, + margin: `${GRID_STACK_MARGIN}${GRID_STACK_UNIT}`, minRow: 1, maxRow: 1, acceptWidgets: isAColumnWidget @@ -41,7 +47,7 @@ export const subGridOptions: GridStackOptions = { export const gridOptions: GridStackOptions = { disableResize: true, cellHeight: 264, // 8px more so it overflows and we can see the 8px of space between rows - margin: 8, + margin: `${GRID_STACK_ROW_HEIGHT}${GRID_STACK_UNIT}`, minRow: 1, acceptWidgets: isARowWidget, draggable: { diff --git a/core-web/libs/template-builder/src/lib/template-builder.module.ts b/core-web/libs/template-builder/src/lib/template-builder.module.ts index 4562647616e4..ba4ba443e3e8 100644 --- a/core-web/libs/template-builder/src/lib/template-builder.module.ts +++ b/core-web/libs/template-builder/src/lib/template-builder.module.ts @@ -1,8 +1,9 @@ -import { AsyncPipe, NgFor } from '@angular/common'; +import { AsyncPipe, NgFor, NgStyle } from '@angular/common'; import { NgModule } from '@angular/core'; import { AddWidgetComponent } from './components/template-builder/components/add-widget/add-widget.component'; import { RemoveConfirmDialogComponent } from './components/template-builder/components/remove-confirm-dialog/remove-confirm-dialog.component'; +import { TemplateBuilderBackgroundColumnsComponent } from './components/template-builder/components/template-builder-background-columns/template-builder-background-columns.component'; import { TemplateBuilderBoxComponent } from './components/template-builder/components/template-builder-box/template-builder-box.component'; import { TemplateBuilderRowComponent } from './components/template-builder/components/template-builder-row/template-builder-row.component'; import { DotTemplateBuilderStore } from './components/template-builder/store/template-builder.store'; @@ -15,7 +16,9 @@ import { TemplateBuilderComponent } from './components/template-builder/template RemoveConfirmDialogComponent, TemplateBuilderRowComponent, AddWidgetComponent, - TemplateBuilderBoxComponent + TemplateBuilderBoxComponent, + TemplateBuilderBackgroundColumnsComponent, + NgStyle ], declarations: [TemplateBuilderComponent], providers: [DotTemplateBuilderStore], From 3a38d503299acf665a8e73703516917185b77162 Mon Sep 17 00:00:00 2001 From: Freddy Montes <751424+fmontes@users.noreply.github.com> Date: Tue, 6 Jun 2023 14:10:24 -0600 Subject: [PATCH 63/63] Sort Template Builder Stories (#25161) --- .../libs/template-builder/.storybook/preview.ts | 7 +++++++ .../add-widget/add-widget.component.stories.ts | 17 ++++++++++++----- .../template-builder-box.component.stories.ts | 2 +- .../template-builder-row.component.stories.ts | 2 +- .../template-builder.component.stories.ts | 6 +++--- 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/core-web/libs/template-builder/.storybook/preview.ts b/core-web/libs/template-builder/.storybook/preview.ts index e69de29bb2d1..72b57985c24f 100644 --- a/core-web/libs/template-builder/.storybook/preview.ts +++ b/core-web/libs/template-builder/.storybook/preview.ts @@ -0,0 +1,7 @@ +export const parameters = { + options: { + storySort: { + order: ['Template Builder', 'Components'] + } + } +}; diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.stories.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.stories.ts index 81fdc9e5526e..8f37182e0659 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.stories.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/add-widget/add-widget.component.stories.ts @@ -2,10 +2,10 @@ import { moduleMetadata, Story, Meta } from '@storybook/angular'; import { AddWidgetComponent } from './add-widget.component'; -import { rowIcon } from '../../assets/icons'; +import { rowIcon, colIcon } from '../../assets/icons'; export default { - title: 'AddWidgetComponent', + title: 'Components/Add', component: AddWidgetComponent, decorators: [ moduleMetadata({ @@ -18,16 +18,23 @@ const Template: Story = (args: AddWidgetComponent) => ({ props: args }); -export const Primary = Template.bind({}); +export const AddRow = Template.bind({}); + +export const AddBox = Template.bind({}); export const MaterialIcon = Template.bind({}); -Primary.args = { +AddRow.args = { label: 'Add Row', icon: rowIcon }; -MaterialIcon.args = { +AddBox.args = { label: 'Add Box', + icon: colIcon +}; + +MaterialIcon.args = { + label: 'Fallback Material Icon', icon: 'add' }; diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.stories.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.stories.ts index bbe2d1c6a76c..d2790986cb48 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.stories.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.stories.ts @@ -7,7 +7,7 @@ import { ScrollPanelModule } from 'primeng/scrollpanel'; import { TemplateBuilderBoxComponent } from './template-builder-box.component'; export default { - title: 'TemplateBuilderBoxComponent', + title: 'Components/Box', component: TemplateBuilderBoxComponent, decorators: [ moduleMetadata({ diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.stories.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.stories.ts index f4a51b26518c..2ecdda852925 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.stories.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-row/template-builder-row.component.stories.ts @@ -7,7 +7,7 @@ import { DotIconModule } from '@dotcms/ui'; import { TemplateBuilderRowComponent } from './template-builder-row.component'; export default { - title: 'TemplateBuilderRowComponent', + title: 'Components/Row', component: TemplateBuilderRowComponent, decorators: [ moduleMetadata({ diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts index 51b9d983540a..e6ad5c13129e 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.stories.ts @@ -12,7 +12,7 @@ import { TemplateBuilderComponent } from './template-builder.component'; import { FULL_DATA_MOCK } from './utils/mocks'; export default { - title: 'TemplateBuilderComponent', + title: 'Template Builder', component: TemplateBuilderComponent, decorators: [ moduleMetadata({ @@ -34,8 +34,8 @@ const Template: Story = (args: TemplateBuilderComponen props: args }); -export const Primary = Template.bind({}); +export const Base = Template.bind({}); -Primary.args = { +Base.args = { templateLayout: { body: FULL_DATA_MOCK } };