diff --git a/core-web/apps/dotcms-ui/project.json b/core-web/apps/dotcms-ui/project.json index 5c1a10c8c18e..b089578dee82 100644 --- a/core-web/apps/dotcms-ui/project.json +++ b/core-web/apps/dotcms-ui/project.json @@ -61,13 +61,20 @@ } ], "styles": [ + "node_modules/prismjs/themes/prism-okaidia.css", "node_modules/primeicons/primeicons.css", "libs/dotcms-scss/angular/styles.scss", "node_modules/primeflex/primeflex.css", "node_modules/primeng/resources/primeng.min.css", "node_modules/gridstack/dist/gridstack.min.css" ], - "scripts": [], + "scripts": [ + "node_modules/prismjs/prism.js", + "node_modules/prismjs/components/prism-css.min.js", + "node_modules/prismjs/components/prism-typescript.min.js", + "node_modules/prismjs/components/prism-bash.min.js", + "node_modules/clipboard/dist/clipboard.min.js" + ], "stylePreprocessorOptions": { "includePaths": ["libs/dotcms-scss/angular"] }, @@ -111,21 +118,31 @@ "outputPath": "../../tomcat9/webapps/ROOT/dotAdmin", "baseHref": "", "optimization": false, - "sourceMap": true, - "namedChunks": true, + "sourceMap": false, + "namedChunks": false, "vendorChunk": true, - "buildOptimizer": false + "buildOptimizer": false, + "extractLicenses": false, + "fileReplacements": [], + "progress": false, + "outputHashing": "none", + "commonChunk": true, + "budgets": [], + "externalDependencies": ["date-fns/locale"] } }, "defaultConfiguration": "production", "dependsOn": ["^build"] }, "serve": { - "dependsOn": ["dotcms-webcomponents:build"], + "dependsOn": [], "executor": "@angular-devkit/build-angular:dev-server", "options": { "proxyConfig": "apps/dotcms-ui/proxy-dev.conf.mjs", - "buildTarget": "dotcms-ui:build:development" + "buildTarget": "dotcms-ui:build:development", + "hmr": true, + "liveReload": false, + "watch": true } }, "extract-i18n": { diff --git a/core-web/apps/dotcms-ui/src/app/app-routing.module.ts b/core-web/apps/dotcms-ui/src/app/app-routing.module.ts index b68fdd8c8685..b17ab990d042 100644 --- a/core-web/apps/dotcms-ui/src/app/app-routing.module.ts +++ b/core-web/apps/dotcms-ui/src/app/app-routing.module.ts @@ -115,7 +115,7 @@ const PORTLETS_ANGULAR: Route[] = [ { path: 'starter', loadChildren: () => - import('@portlets/dot-starter/dot-starter.module').then((m) => m.DotStarterModule) + import('@portlets/dot-starter/dot-starter.routes').then((m) => m.dotStarterRoutes) }, { canActivate: [MenuGuardService], diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.html new file mode 100644 index 000000000000..ab51396ade01 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.html @@ -0,0 +1,285 @@ +@if (userData$ | async; as user) { +
+ +
+
+
+

Create Your First dotCMS Content Experience

+

+
+
+ +
+
+
+

+ {{ 'starter.side.resources.title' | dm }} +

+
+ + + + + + +
+
+} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.scss b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.scss new file mode 100644 index 000000000000..7cb0967d6bf2 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.scss @@ -0,0 +1,246 @@ +@use "variables" as *; + +$number-link-icon-size: 2.28rem; + +:host { + display: block; + font-size: $font-size-md; + height: 100%; + overflow: auto; + background-color: $white; + + hr { + border-top: 1px solid $color-palette-gray-300; + } + + a { + text-decoration: none; + } + + .dot-starter-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + + p-checkbox { + margin-top: $spacing-5; + } + } + + .dot-starter-title { + color: $black; + font-size: $font-size-lg; + margin: $spacing-5 $spacing-3 $spacing-1; + } + + .dot-starter-description { + color: $black; + margin: 0 $spacing-3; + } + + .dot-starter-top-content, + .dot-starter-bottom-content { + display: flex; + margin: 0 auto; + max-width: 77.42rem; + flex-grow: 1; + } + + .dot-starter-top-main__section { + background-color: $white; + counter-reset: css-counter 0; + flex-grow: 1; + margin-left: 0; + margin-bottom: $spacing-4; + margin-right: $spacing-1; + display: flex; + } + + .dot-starter-offset { + margin-left: $spacing-5; + } + + .dot-starter-top-main__block { + border-radius: $border-radius-md; + box-shadow: $shadow-l; + + align-content: space-between; + border-bottom: 1px solid $color-palette-gray-300; + display: flex; + flex-direction: column; + margin: $spacing-3; + padding: $spacing-5 $spacing-3; + + transition: background-color $basic-speed ease-in; + width: 25%; + + &:hover { + background-color: $bg-hover; + } + } + + .dot-starter-top-main__link-number { + align-self: center; + margin: 0 $spacing-4 $spacing-1; + text-align: center; + + span { + align-items: center; + color: $color-palette-primary; + counter-increment: css-counter 1; + display: inline-flex; + font-size: $md-icon-size-extra-big; + height: $number-link-icon-size; + justify-content: center; + width: $number-link-icon-size; + + &::before { + content: counter(css-counter); + } + } + } + + .dot-starter-top-main__link-data { + align-self: center; + flex-grow: 1; + text-align: center; + + h4 { + color: $black; + font-size: $font-size-lmd; + font-weight: $font-weight-semi-bold; + margin: 0; + } + p { + color: $color-palette-gray-700; + margin: 0; + font-size: $font-size-md; + } + } + + .dot-starter-top-main__link-arrow { + align-self: center; + + i { + color: $color-palette-gray-500; + font-size: $font-size-lg; + margin-right: $spacing-3; + } + } + + .dot-starter-top-main__title { + font-size: $font-size-lmd; + font-weight: $font-weight-semi-bold; + margin: 0; + border-bottom: 1px solid $color-palette-gray-300; + padding-bottom: $spacing-3; + } + .dot-starter-top-secondary__block { + display: flex; + padding-bottom: $spacing-8; + + &:last-child { + padding-bottom: 0; + } + } + + .dot-starter-top-secondary__link-icon { + padding-top: 0; + + span { + color: $color-palette-primary; + fill: $color-palette-primary; + display: inline-flex; + width: $number-link-icon-size; + } + + svg { + fill: $color-palette-primary; + height: 1.71rem; + width: $md-icon-size-normal; + } + } + + .dot-starter-top-secondary__link-data { + padding: 0; + + h4 { + color: $black; + font-size: $font-size-lmd; + margin: 0; + } + p { + color: $color-palette-gray-700; + margin: 0; + } + } + + .dot-starter-top-secondary__link-data-apis { + padding: 0; + + h4 { + color: $color-palette-primary; + font-size: $font-size-lmd; + font-weight: 500; + margin: 0; + padding-bottom: $spacing-1; + } + p { + color: $color-palette-gray-700; + margin: 0; + } + } + + .dot-starter-bottom-content { + flex-wrap: wrap; + padding: $spacing-1 0 $spacing-5 $spacing-5; + } + + .dot-starter-bottom__block { + background-color: $white; + border-bottom: 1px solid $color-palette-gray-300; + box-shadow: $shadow-m; + display: flex; + flex-grow: 1; + height: 100%; + overflow: hidden; + transition: background-color $basic-speed ease-in; + border-radius: $border-radius-md; + gap: $spacing-1; + padding: $spacing-3; + + &:hover { + background-color: $bg-hover; + } + } + + .dot-starter-bottom__link-icon { + padding-top: $spacing-0; + span { + color: $color-palette-primary; + display: inline-flex; + } + } + + .dot-starter-bottom__link-data { + margin-right: $spacing-5; + padding: 0; + + h4 { + color: $black; + font-size: $font-size-lmd; + margin: 0; + } + p { + color: $color-palette-gray-700; + margin: 0; + } + } +} +.button-link { + cursor: pointer; + color: #515667; + position: absolute; + top: 20px; + right: 20px; +} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.spec.ts new file mode 100644 index 000000000000..209da16d15a7 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.spec.ts @@ -0,0 +1,325 @@ +import { createComponentFactory, Spectator, byTestId, mockProvider } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; + +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { CheckboxModule, Checkbox } from 'primeng/checkbox'; + +import { DotMessageService, DotRouterService } from '@dotcms/data-access'; +import { CoreWebService } from '@dotcms/dotcms-js'; +import { DotMessagePipe } from '@dotcms/ui'; +import { + CoreWebServiceMock, + MockDotMessageService, + MockDotRouterService +} from '@dotcms/utils-testing'; + +import { DotStarterResolver } from './dot-starter-resolver.service'; +import { DotStarterComponent } from './dot-starter.component'; + +import { DotAccountService } from '../../api/services/dot-account-service'; + +const messages = { + 'starter.title': 'Welcome!', + 'starter.description': + 'You are logged in as {0}. To help you get started building with dotCMS we provided some quick links.', + 'starter.dont.show': `Don't show this again`, + 'starter.main.link.data.model.title': 'Create data model', + 'starter.main.link.data.model.description': 'Create data model description', + 'starter.main.link.add.content.title': 'Add content', + 'starter.main.link.add.content.description': 'Add content description', + 'starter.main.link.design.layout.title': 'Design a layout', + 'starter.main.link.design.layout.description': 'Design a layout description', + 'starter.main.link.create.page.title': 'Create a page', + 'starter.main.link.create.page.description': 'Create a page description', + 'starter.side.title': 'APIs and Services', + 'starter.side.link.graphQl.title': 'GraphQL API', + 'starter.side.link.graphQl.description': 'GraphQL API description', + 'starter.side.link.content.title': 'Content API', + 'starter.side.link.content.description': 'Content API description', + 'starter.side.link.image.processing.title': 'Image Resizing and Processing', + 'starter.side.link.image.processing.description': 'Image Resizing and Processing description', + 'starter.side.link.page.layout.title': 'Page Layout API (Layout as a Service)', + 'starter.side.link.page.layout.description': 'Page Layout API description', + 'starter.side.link.generate.key.title': 'Generate API Token', + 'starter.side.link.generate.key.description': 'Generate API Token description', + 'starter.footer.link.documentation.title': 'Documentation', + 'starter.footer.link.documentation.description': 'Documentation description', + 'starter.footer.link.examples.title': 'Examples', + 'starter.footer.link.examples.description': 'Examples description', + 'starter.footer.link.community.title': 'Community', + 'starter.footer.link.community.description': 'Community description', + 'starter.footer.link.training.title': 'Training Videos', + 'starter.footer.link.training.description': 'Training Videos description', + 'starter.footer.link.review.title': 'Write A Review', + 'starter.footer.link.review.description': 'Write A Review description', + 'starter.footer.link.feedback.title': 'Feedback', + 'starter.footer.link.feedback.description': 'Feedback description' +}; + +const routeDataMock = { + userData: { + user: { + email: 'admin@dotcms.com', + givenName: 'Admin', + roleId: 'e7d23sde-5127-45fc-8123-d424fd510e3', + surname: 'User', + userId: 'testId' + }, + permissions: { + STRUCTURES: { canRead: true, canWrite: true }, + HTMLPAGES: { canRead: true, canWrite: true }, + TEMPLATES: { canRead: true, canWrite: true }, + CONTENTLETS: { canRead: true, canWrite: true } + } + } +}; + +const routeDataWithoutPermissionsMock = { + userData: { + user: { + email: 'admin@dotcms.com', + givenName: 'Admin', + roleId: 'e7d23sde-5127-45fc-8123-d424fd510e3', + surname: 'User', + userId: 'testId' + }, + permissions: { + STRUCTURES: { canRead: true, canWrite: false }, + HTMLPAGES: { canRead: true, canWrite: false }, + TEMPLATES: { canRead: true, canWrite: false }, + CONTENTLETS: { canRead: true, canWrite: false } + } + } +}; + +class ActivatedRouteMock { + get data() { + return of(routeDataMock); + } +} + +describe('DotStarterComponent', () => { + describe('With user permissions', () => { + let spectator: Spectator; + const messageServiceMock = new MockDotMessageService(messages); + + const createComponent = createComponentFactory({ + component: DotStarterComponent, + imports: [DotMessagePipe, CheckboxModule], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: DotMessageService, useValue: messageServiceMock }, + { provide: ActivatedRoute, useClass: ActivatedRouteMock }, + { provide: CoreWebService, useClass: CoreWebServiceMock }, + { provide: DotRouterService, useClass: MockDotRouterService }, + DotStarterResolver, + mockProvider(DotAccountService, { + addStarterPage: jest.fn().mockReturnValue(of(true)), + removeStarterPage: jest.fn().mockReturnValue(of(true)) + }) + ] + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + describe('With user permissions', () => { + it('should set proper labels to the main container', () => { + expect(spectator.query('.dot-starter-description')).toHaveText( + 'You are logged in as Admin. To help you get started building with dotCMS we provided some quick links.' + ); + + expect(spectator.query(byTestId('starter.main.link.data.model'))).toHaveText( + messageServiceMock.get('starter.main.link.data.model.title') + ); + expect(spectator.query(byTestId('starter.main.link.data.model'))).toHaveText( + messageServiceMock.get('starter.main.link.data.model.description') + ); + + expect(spectator.query(byTestId('starter.main.link.content'))).toHaveText( + messageServiceMock.get('starter.main.link.add.content.title') + ); + expect(spectator.query(byTestId('starter.main.link.content'))).toHaveText( + messageServiceMock.get('starter.main.link.add.content.description') + ); + + expect(spectator.query(byTestId('starter.main.link.design.layout'))).toHaveText( + messageServiceMock.get('starter.main.link.design.layout.title') + ); + expect(spectator.query(byTestId('starter.main.link.design.layout'))).toHaveText( + messageServiceMock.get('starter.main.link.design.layout.description') + ); + + expect(spectator.query(byTestId('starter.main.link.create.page'))).toHaveText( + messageServiceMock.get('starter.main.link.create.page.title') + ); + expect(spectator.query(byTestId('starter.main.link.create.page'))).toHaveText( + messageServiceMock.get('starter.main.link.create.page.description') + ); + }); + + it('should set proper labels to the side container', () => { + expect(spectator.query(byTestId('dot-side-title'))).toHaveText( + messageServiceMock.get('starter.side.title') + ); + + expect(spectator.query(byTestId('starter.side.link.graphQl'))).toHaveText( + messageServiceMock.get('starter.side.link.graphQl.title') + ); + expect(spectator.query(byTestId('starter.side.link.graphQl'))).toHaveText( + messageServiceMock.get('starter.side.link.graphQl.description') + ); + + expect(spectator.query(byTestId('starter.side.link.content'))).toHaveText( + messageServiceMock.get('starter.side.link.content.title') + ); + expect(spectator.query(byTestId('starter.side.link.content'))).toHaveText( + messageServiceMock.get('starter.side.link.content.description') + ); + + expect(spectator.query(byTestId('starter.side.link.image.processing'))).toHaveText( + messageServiceMock.get('starter.side.link.image.processing.title') + ); + expect(spectator.query(byTestId('starter.side.link.image.processing'))).toHaveText( + messageServiceMock.get('starter.side.link.image.processing.description') + ); + + expect(spectator.query(byTestId('starter.side.link.page.layout'))).toHaveText( + messageServiceMock.get('starter.side.link.page.layout.title') + ); + expect(spectator.query(byTestId('starter.side.link.page.layout'))).toHaveText( + messageServiceMock.get('starter.side.link.page.layout.description') + ); + + expect(spectator.query(byTestId('starter.side.link.generate.key'))).toHaveText( + messageServiceMock.get('starter.side.link.generate.key.title') + ); + expect(spectator.query(byTestId('starter.side.link.generate.key'))).toHaveText( + messageServiceMock.get('starter.side.link.generate.key.description') + ); + }); + + it('should set proper labels to the footer container', () => { + expect(spectator.query(byTestId('starter.footer.link.documentation'))).toHaveText( + messageServiceMock.get('starter.footer.link.documentation.title') + ); + expect(spectator.query(byTestId('starter.footer.link.documentation'))).toHaveText( + messageServiceMock.get('starter.footer.link.documentation.description') + ); + + expect(spectator.query(byTestId('starter.footer.link.examples'))).toHaveText( + messageServiceMock.get('starter.footer.link.examples.title') + ); + expect(spectator.query(byTestId('starter.footer.link.examples'))).toHaveText( + messageServiceMock.get('starter.footer.link.examples.description') + ); + + expect(spectator.query(byTestId('starter.footer.link.community'))).toHaveText( + messageServiceMock.get('starter.footer.link.community.title') + ); + expect(spectator.query(byTestId('starter.footer.link.community'))).toHaveText( + messageServiceMock.get('starter.footer.link.community.description') + ); + + expect(spectator.query(byTestId('starter.footer.link.training'))).toHaveText( + messageServiceMock.get('starter.footer.link.training.title') + ); + expect(spectator.query(byTestId('starter.footer.link.training'))).toHaveText( + messageServiceMock.get('starter.footer.link.training.description') + ); + + expect(spectator.query(byTestId('starter.footer.link.review'))).toHaveText( + messageServiceMock.get('starter.footer.link.review.title') + ); + expect(spectator.query(byTestId('starter.footer.link.review'))).toHaveText( + messageServiceMock.get('starter.footer.link.review.description') + ); + + expect(spectator.query(byTestId('starter.footer.link.feedback'))).toHaveText( + messageServiceMock.get('starter.footer.link.feedback.title') + ); + expect(spectator.query(byTestId('starter.footer.link.feedback'))).toHaveText( + messageServiceMock.get('starter.footer.link.feedback.description') + ); + }); + + it('should have right links to internal portlets', () => { + expect(spectator.query(byTestId('starter.main.link.data.model'))).toHaveAttribute( + 'routerLink', + '/content-types-angular/create/content' + ); + + expect(spectator.query(byTestId('starter.main.link.content'))).toHaveAttribute( + 'routerLink', + '/c/content/new/webPageContent' + ); + + expect( + spectator.query(byTestId('starter.main.link.design.layout')) + ).toHaveAttribute('routerLink', '/templates/new/designer'); + + expect(spectator.query(byTestId('starter.main.link.create.page'))).toHaveAttribute( + 'routerLink', + '/c/content/new/htmlpageasset' + ); + }); + + it('should call the endpoint to hide/show the portlet', () => { + const dotAccountService = spectator.inject(DotAccountService); + const checkbox = spectator.query(Checkbox); + + expect(checkbox.label).toBe(messageServiceMock.get('starter.dont.show')); + + spectator.component.handleVisibility(true); + expect(dotAccountService.removeStarterPage).toHaveBeenCalledTimes(1); + + spectator.component.handleVisibility(false); + expect(dotAccountService.addStarterPage).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('Without user permissions', () => { + let spectator: Spectator; + const messageServiceMock = new MockDotMessageService(messages); + + const createComponent = createComponentFactory({ + component: DotStarterComponent, + imports: [DotMessagePipe, CheckboxModule], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: DotMessageService, useValue: messageServiceMock }, + { + provide: ActivatedRoute, + useValue: { data: of(routeDataWithoutPermissionsMock) } + }, + { provide: CoreWebService, useClass: CoreWebServiceMock }, + { provide: DotRouterService, useClass: MockDotRouterService }, + DotStarterResolver, + mockProvider(DotAccountService, { + addStarterPage: jest.fn().mockReturnValue(of(true)), + removeStarterPage: jest.fn().mockReturnValue(of(true)) + }) + ] + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should hide links from the main container', () => { + spectator.detectChanges(); + + expect(spectator.query(byTestId('starter.main.link.data.model'))).toBeFalsy(); + expect(spectator.query(byTestId('starter.main.link.content'))).toBeFalsy(); + expect(spectator.query(byTestId('starter.main.link.design.layout'))).toBeFalsy(); + expect(spectator.query(byTestId('starter.main.link.create.page'))).toBeFalsy(); + }); + }); +}); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.ts new file mode 100644 index 000000000000..bb7cf834858e --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.ts @@ -0,0 +1,113 @@ +import { Observable } from 'rxjs'; + +import { AsyncPipe } from '@angular/common'; +import { Component, DestroyRef, EventEmitter, inject, OnInit, Output } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { RouterLink } from '@angular/router'; + +import { CheckboxModule } from 'primeng/checkbox'; + +import { map, mergeMap } from 'rxjs/operators'; + +import { DotCurrentUserService } from '@dotcms/data-access'; +import { + DotCurrentUser, + DotPermissionsType, + PermissionsType, + UserPermissions +} from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotAccountService } from '../../../../api/services/dot-account-service'; + +@Component({ + selector: 'dot-onboarding-author', + templateUrl: './onboarding-author.component.html', + styleUrls: ['./onboarding-author.component.scss'], + providers: [DotAccountService], + standalone: true, + imports: [AsyncPipe, DotMessagePipe, CheckboxModule, RouterLink] +}) +export class DotOnboardingAuthorComponent implements OnInit { + private dotAccountService = inject(DotAccountService); + @Output() eventEmitter = new EventEmitter<'reset-user-profile'>(); + + userData$: Observable<{ + username: string; + showCreateContentLink: boolean; + showCreateDataModelLink: boolean; + showCreatePageLink: boolean; + showCreateTemplateLink: boolean; + }>; + username: string; + showCreateContentLink: boolean; + showCreateDataModelLink: boolean; + showCreatePageLink: boolean; + showCreateTemplateLink: boolean; + + readonly #destroyRef = inject(DestroyRef); + + private dotCurrentUserService = inject(DotCurrentUserService); + + ngOnInit() { + this.userData$ = this.dotCurrentUserService.getCurrentUser().pipe( + mergeMap((user: DotCurrentUser) => { + return this.dotCurrentUserService + .getUserPermissions( + user.userId, + [UserPermissions.WRITE], + [ + PermissionsType.HTMLPAGES, + PermissionsType.STRUCTURES, + PermissionsType.TEMPLATES, + PermissionsType.CONTENTLETS + ] + ) + .pipe( + map((permissionsType: DotPermissionsType) => { + return { user, permissions: permissionsType }; + }), + map( + ({ + user, + permissions + }: { + user: DotCurrentUser; + permissions: DotPermissionsType; + }) => { + return { + username: user.givenName, + showCreateContentLink: + permissions[PermissionsType.CONTENTLETS].canWrite, + showCreateDataModelLink: + permissions[PermissionsType.STRUCTURES].canWrite, + showCreatePageLink: + permissions[PermissionsType.HTMLPAGES].canWrite, + showCreateTemplateLink: + permissions[PermissionsType.TEMPLATES].canWrite + }; + } + ) + ); + }) + ); + } + + /** + * Hit the endpoint to show/hide the tool group in the menu. + * @param {boolean} hide + * @memberof DotStarterComponent + */ + handleVisibility(hide: boolean): void { + const subscription = hide + ? this.dotAccountService.removeStarterPage() + : this.dotAccountService.addStarterPage(); + + subscription.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe(); + } + + public resetUserProfile(): void { + localStorage.removeItem('user_profile'); + this.eventEmitter.emit('reset-user-profile'); + } +} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/content.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/content.ts new file mode 100644 index 000000000000..d962b73b9f2f --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/content.ts @@ -0,0 +1,353 @@ +import { OnboardingContent, SupportedFrameworks } from './models'; + +export const STORAGE_KEY = 'dotcmsDeveloperOnboarding'; + +export const getOnboardingContent = (selectedFramework: SupportedFrameworks): OnboardingContent => { + return { + title: 'Start with dotCMS headless development in minutes', + description: '', + steps: [ + { + number: 1, + title: 'Bootstrap the project using the cli', + description: + 'Use the @dotcms/create-app CLI to scaffold a production-ready project that serves as the foundation for your headless application.', + substeps: [ + { + code: + 'npx @dotcms/create-app@latest my-dotcms-app ' + + ' --framework=' + + selectedFramework + + ' --directory=' + + ' --url=' + + ' --username= ' + + ' --password=', + language: 'bash', + type: 'terminal', + explanation: { + title: 'Create the project using create-dotcms-app cli', + description: ` + The CLI connects to your dotCMS cloud instance ,sets up the UVE config, gets **Authentication Token** and **Site ID** using the below options: + + \n- **--framework** : Specifies the frontend framework to use. + \n- **--directory** : Sets the project directory. + \n- **--url**: dotCMS Cloud instance URL. + \n- **--username**: dotCMS Cloud instance username. + \n- **--password**: dotCMS Cloud instance password. + + \n⚠️ **Save the generated variables — once the terminal is cleared they are lost. Use --help for more options.** + ` + } + }, + { + code: '', + language: 'bash', + type: 'terminal', + explanation: { + title: 'Follow the instructions generated by the CLI to complete the setup.', + description: + 'Once the cli scaffolds the project ,follow the CLI generated instructions to set env variables, install dependencies, and run the project.' + } + } + // { + // code: 'cp .env.example .env', + // language: 'bash', + // type: 'terminal', + // explanation: { + // title: 'Replace the environment variables', + // description: `Replace the generated project’s environment variables with the CLI-provided values. Following variables are required to connect your frontend to dotCMS : + // \n- Authentication token + // \n- Site identifier + // \n- Cloud URL + // ` + // } + // }, + // { + // code: 'npm install', + // language: 'bash', + // type: 'terminal', + // explanation: { + // title: 'Install the dependencies', + // description: `Install the dependencies in the project.` + // } + // }, + // { + // code: 'npm run dev', + // language: 'bash', + // type: 'terminal', + // explanation: { + // title: 'Start the development server', + // description: `The development server will start and the app will be available at http://localhost:${portFrontEnd}. Keep this terminal running while developing.` + // } + // } + ] + }, + // { + // number: 2, + // title: 'Install dotCMS libraries', + // description: + // 'Install the official dotCMS SDK packages that enable your Next.js app to communicate with dotCMS and render content with full TypeScript support.', + // substeps: [ + // { + // code: 'npm install @dotcms/client @dotcms/react @dotcms/types', + // language: 'bash', + // type: 'terminal', + // explanation: { + // title: 'Install dotCMS packages', + // description: `- \`@dotcms/client\` - Handles authentication, API communication, and content fetching from dotCMS + // - \`@dotcms/react\` - Provides React-specific hooks and components for rendering dotCMS content + // - \`@dotcms/types\` - Provides TypeScript type definitions for dotCMS SDK` + // } + // } + // ] + // }, + // { + // number: 3, + // title: 'Authenticate dotCMS (create your API Key)', + // description: + // 'Set up secure authentication between your Next.js app and dotCMS using a read-only API key and environment variables.', + // substeps: [ + // { + // code: 'touch .env.local', + // language: 'bash', + // type: 'terminal', + // explanation: { + // title: 'Create .env.local file in project root', + // description: `To generate your API key: + // - Navigate to [**System** → **Users**](https://minstarter.dotcms.com/c/users) in your dotCMS instance + // - Select your user account (e.g., \`admin@dotcms.com\`) + // - Scroll to the **API Access Key** section + // - Click **Generate** to create a new key (read-only permissions recommended) + // - Copy the generated key - it will look something like: \`abcd1234efgh5678ijkl9012mnop3456\` + // + // **Important:** Save this key safely - you'll need it in the next step! + // + // For detailed instructions, refer to: [dotCMS REST API Authentication](https://dev.dotcms.com/docs/rest-api-authentication#ReadOnlyToken)` + // } + // }, + // { + // code: `NEXT_PUBLIC_DOTCMS_URL=https://minstarter.dotcms.com + // DOTCMS_TOKEN=your-api-key-here`, + // language: 'env', + // type: 'file', + // filePath: '.env.local', + // explanation: { + // title: 'Add your dotCMS credentials', + // description: `Replace \`your-api-key-here\` with the actual API Key you copied from dotCMS. + // + // - \`NEXT_PUBLIC_DOTCMS_URL\` - Available in both server and client components (needed for image URLs) + // + // - \`DOTCMS_TOKEN\` - Server-only (keeps your API key secure, never exposed to browser)` + // } + // } + // ] + // }, + // { + // number: 4, + // title: 'Fetch content from dotCMS', + // description: + // 'Test your dotCMS connection by fetching page data and displaying it as JSON. Seeing the raw data helps you understand the structure of dotCMS content before rendering it visually.', + // substeps: [ + // { + // code: `import { createDotCMSClient } from '@dotcms/client'; + // + // // Create dotCMS client + // const client = createDotCMSClient({ + // dotcmsUrl: process.env.NEXT_PUBLIC_DOTCMS_URL!, + // authToken: process.env.DOTCMS_TOKEN, + // }); + // + // export default async function Home() { + // // Fetch page content from dotCMS + // const { pageAsset } = await client.page.get('/'); + // + // return ( + //
+ //

dotCMS Page Data

+ //
+            //         {JSON.stringify(pageAsset, null, 2)}
+            //       
+ //
+ // ); + // }`, + // language: 'typescript', + // type: 'file', + // filePath: 'src/app/page.tsx', + // explanation: { + // title: 'Replace all content in the page component', + // description: `- \`createDotCMSClient\` - Creates a configured client instance that can communicate with your dotCMS instance + // - \`client.page.get('/')\` - Makes an API call to fetch the home page content (identified by path \`/\`) + // - \`pageAsset\` - The complete page object containing all content, layout, and configuration data + // - \`JSON.stringify\` - Converts the JavaScript object into readable JSON format for inspection` + // } + // } + // ] + // }, + // { + // number: 5, + // title: 'Render content with DotCMSLayoutBody', + // description: + // 'Transform the raw JSON data into a beautiful, interactive page. You will create a client-side component that automatically maps dotCMS content types to React components.', + // substeps: [ + // { + // code: 'mkdir -p src/components && touch src/components/DotCMSPageClient.tsx', + // language: 'bash', + // type: 'terminal', + // explanation: { + // title: 'Create components directory', + // description: `We need a dedicated place for our client-side components.` + // } + // }, + // { + // code: `'use client'; + // + // import { DotCMSLayoutBody } from '@dotcms/react'; + // import type { DotCMSPageAsset } from '@dotcms/types'; + // import Image from 'next/image'; + // + // // 1. Define the Banner Component + // // This component matches the fields defined in your dotCMS "Banner" Content Type + // function Banner({ title, caption, image, link, target }: any) { + // const dotcmsUrl = process.env.NEXT_PUBLIC_DOTCMS_URL; + // const imageUrl = image?.idPath ? \`\${dotcmsUrl}\${image.idPath}\` : null; + // + // return ( + //
+ //
+ //
+ // {title &&

{title}

} + // {caption &&

{caption}

} + // {link && ( + // + // Learn More + // + // )} + //
+ //
+ // {imageUrl && ( + //
+ // {/* 'unoptimized' allows loading images from external dotCMS domains without extra Next.js config */} + // {title + //
+ // )} + //
+ // ); + // } + // + // // 2. Create the Component Map + // // Keys must EXACTLY match the Content Type Variable Name in dotCMS + // const COMPONENTS_MAP = { + // Banner: Banner, + // }; + // + // // 3. Main Client Component + // export function DotCMSPageClient({ pageAsset }: { pageAsset: DotCMSPageAsset }) { + // return ( + // + // ); + // }`, + // language: 'typescript', + // type: 'file', + // filePath: 'src/components/DotCMSPageClient.tsx', + // explanation: { + // title: 'Create the Component Mapper', + // description: `This file handles the logic of turning data into UI: + // + // - **Banner Component**: A standard React component. Note the \`unoptimized\` prop on the Image—this allows Next.js to display images from your specific dotCMS instance URL without complex configuration changes. + // - **COMPONENTS_MAP**: This object tells the SDK "When you see content of type 'Banner', render this React component." + // - **DotCMSLayoutBody**: The magic component from the SDK that iterates over the page layout and renders the correct components automatically.` + // } + // }, + // { + // code: `import { createDotCMSClient } from '@dotcms/client'; + // import { DotCMSPageClient } from '@/components/DotCMSPageClient'; + // + // const client = createDotCMSClient({ + // dotcmsUrl: process.env.NEXT_PUBLIC_DOTCMS_URL!, + // authToken: process.env.DOTCMS_TOKEN, + // }); + // + // export default async function Home() { + // const { pageAsset } = await client.page.get('/'); + // + // return ( + //
+ // + //
+ // ); + // }`, + // language: 'typescript', + // type: 'file', + // filePath: 'src/app/page.tsx', + // explanation: { + // title: 'Update the Home Page', + // description: `We replace the raw JSON dump with our new \`\`. + // + // The server fetches the data securely, and the client component renders it. This "Hybrid" approach gives you the best of both worlds: SEO performance and interactive UI.` + // } + // } + // ] + // }, + // { + // number: 6, + // title: 'Configure Universal Visual Editor', + // description: + // 'Now connect the two worlds. We need to tell dotCMS to load your local development environment (`localhost:3000`) inside its visual editor instead of the production site.', + // substeps: [ + // { + // code: `{ + // "config": [ + // { + // "pattern": ".*", + // "url": "http://localhost:3000" + // } + // ] + // }`, // No code needed, purely visual + // language: 'json', + // type: 'config', // UI placeholder + // explanation: { + // title: 'Open the Visual Editor', + // description: `1. In dotCMS, navigate to **Settings > Apps** and click on **UVE - Universal Visual Editor** + // 2. Click the plus button at the right of the site we're integrating (i.e., \`demo.dotcms.com\`). + // 3. In the Configuration field, add the JSON object.` + // } + // } + // ] + // }, + { + number: 2, + title: 'Edit your page visually', + description: + 'The moment of truth. You will now edit content in dotCMS and see it update live in your Next.js application without touching the code.', + substeps: [ + { + code: '', // No code needed, purely visual + language: 'text', + type: 'terminal', + explanation: { + title: 'Open the Visual Editor', + description: `1. Go to **Site Browser** → **Pages** in the dotCMS sidebar. +2. Click on the **Home** page (index). +3. The screen will split: dotCMS controls on the right, your Next.js app on the left. +4. Click the **Edit** (pencil) icon on the top right. +5. Click directly on the **Banner** component in the preview. +6. Change the **Title** text and press **Save**. + +Watch your Next.js app update instantly!` + } + } + ] + }, + { + number: 3, + title: `You Did It! Now what's next`, + videoPath: `videopath`, + description: + 'Congratulations! You have successfully built a Headless Next.js app with full visual editing capabilities. You now have a workflow where developers build components in React, and editors manage content visually.Now it is time to build our custom component.' + } + ] + }; +}; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/models.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/models.ts new file mode 100644 index 000000000000..387d5519daa9 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/models.ts @@ -0,0 +1,38 @@ +interface OnboardingSubstepExplanation { + title: string; + description: string; +} + +export type SupportedFrameworks = 'nextjs' | 'angular' | 'angular-ssr' | 'astro'; + +type SubstepType = 'file' | 'terminal' | 'config'; + +interface OnboardingStep { + number: number; + title: string; + description: string; + videoPath?: string; + substeps?: OnboardingSubstep[]; +} + +export interface OnboardingSubstep { + code: string; + language: string; + explanation: OnboardingSubstepExplanation; + type: SubstepType; + filePath?: string; +} + +export interface OnboardingContent { + title: string; + description: string; + steps: OnboardingStep[]; +} + +export interface OnboardingFramework { + id: string; + label: string; + logo: string; + disabled?: boolean; + githubUrl?: string; +} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.html new file mode 100644 index 000000000000..63bce6be7693 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.html @@ -0,0 +1,197 @@ +
+ +
+
+

Start with dotCMS headless development in minutes

+
+ + + @for (framework of frameworks; track framework.id) { + + +
+ + + {{ framework.label }} + +
+
+
+
+ + + + +
+
+
+ } +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + Ready to build with {{ selectedFrameworkInfo?.label }}? + +

+ The interactive guide is in development, but you can check out our fully configured + {{ selectedFrameworkInfo?.label }} starter kit today +

+
+ +
+
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.scss b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.scss new file mode 100644 index 000000000000..5bfda73ead27 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.scss @@ -0,0 +1,347 @@ +@use "variables" as *; + +:host { + display: flex; + height: 100%; + position: relative; + flex-direction: column; + + &::ng-deep .p-accordion .p-accordion-content { + padding-block-start: 0; + padding-block-end: $spacing-4; + } +} + +header { + max-width: 70ch; + margin-block-end: $spacing-4; + + h1 { + font-weight: $font-weight-medium-bold; + font-size: $font-size-xl; + margin: 0; + margin-block-end: $spacing-1; + } + + p { + margin: 0; + line-height: 1.5; + } +} +h2 { + margin: 0; + font-size: $font-size-md; + font-weight: $font-weight-medium-bold; +} + +markdown { + font-size: 1em; + line-height: 1.5em; + + &::ng-deep { + & > div:last-child pre, + & > div:last-child code { + margin-bottom: 0; + } + + *:last-child { + margin-bottom: 0; + } + + h1, + .h1 { + font-size: 4.21428571em; + line-height: 1.06779661em; + margin-top: 0.3559322em; + margin-bottom: 0.7118644em; + } + h2, + .h2 { + font-size: 2.64285714em; + line-height: 1.13513514em; + margin-top: 0.56756757em; + margin-bottom: 0.56756757em; + } + h3, + .h3 { + font-size: 1.64285714em; + line-height: 1.82608696em; + margin-top: 0.91304348em; + margin-bottom: 0em; + } + h4, + .h4 { + font-size: 1em; + line-height: 1.5em; + margin-top: 1.5em; + margin-bottom: 0em; + } + h5, + .h5 { + font-size: 1em; + line-height: 1.5em; + margin-top: 1.5em; + margin-bottom: 0em; + } + p, + ul, + ol, + pre, + table, + blockquote { + margin-top: 0em; + margin-bottom: 1.5em; + + // markdown component render a

inside the

  • tags + &::ng-deep markdown p { + margin: 0; + padding: 0; + } + } + ul, + ol { + padding-inline-start: $spacing-4; + } + ul ul, + ol ol, + ul ol, + ol ul { + margin-top: 0em; + margin-bottom: 0em; + } + + /* Let's make sure all's aligned */ + hr, + .hr { + border: 1px solid; + margin: -1px 0; + } + a, + b, + i, + strong, + em, + small, + code { + line-height: 0; + } + sub, + sup { + line-height: 0; + position: relative; + vertical-align: baseline; + } + sup { + top: -0.5em; + } + sub { + bottom: -0.25em; + } + } +} + +.logos { + display: flex; + gap: $spacing-2; + margin-block-end: $spacing-4; +} + +.logo-item { + img { + width: 24px; + height: 24px; + object-fit: contain; + } + + label { + border-radius: $border-radius-sm; + display: flex; + gap: $spacing-1; + align-items: center; + box-shadow: 0 0 0 1px $color-palette-gray-400; + padding: $spacing-2; + cursor: pointer; + + &:hover { + box-shadow: 0 0 0 1px $color-palette-primary-400; + } + } + + &:has(.p-radiobutton-checked) label { + box-shadow: 0 0 0 2px $color-palette-primary-500; + } + + p-radioButton { + display: none; + } +} + +.dot-onboarding { + padding-block-start: $spacing-4; + flex: 1; + overflow: auto; + position: relative; + + &__step-completed { + color: $color-palette-green; + } + + &__content, + &__progress-container { + max-width: 60rem; + margin: 0 auto; + padding: 0 $spacing-4; + } + + &__intro { + display: flex; + flex-direction: column; + gap: $spacing-3; + } + + &__progress { + background-color: $white; + border-top: 1px solid $color-palette-gray-400; + z-index: 5; + flex-shrink: 0; + } + + &__progress-container { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__progress-ring { + display: flex; + gap: $spacing-1; + align-items: center; + } +} + +.dot-step { + counter-reset: step-counter; // Initialize counter for each .dot-step + + h3 { + margin: 0 0 $spacing-1 0; + font-size: $font-size-md; + font-weight: $font-weight-medium-bold; + position: relative; + padding-inline-start: 2.4em; // Reserve space for the counter + counter-increment: step-counter; + padding-block-start: 3px; // Had to hardcode this to align the counter with the text + + &::before { + content: counter(step-counter); + position: absolute; + left: 0; + top: 0; + font-size: $font-size-md; + font-weight: $font-weight-regular-bold; + color: $color-palette-primary-800; + width: 2em; + height: 2em; + text-align: right; + background: $color-palette-primary-200; + border-radius: 0.4em; + box-sizing: border-box; + display: inline-block; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + } + } + + &__description::ng-deep p { + font-size: $font-size-lg; + line-height: 1.5; + padding: 0 $spacing-2; + color: $color-palette-gray-700; + } + + hr { + margin: $spacing-4 0 $spacing-6; + border-color: $color-palette-gray-200; + border-width: 1px; + border-style: solid; + border-radius: 0.4em; + } + + &::ng-deep { + markdown { + pre, + code { + white-space: pre; // prevent wrapping + overflow-x: auto; + max-height: 400px; + } + + pre { + background-color: $black; + border: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + code { + padding: 0; + } + } + } + + &__substep { + display: grid; + grid-template-columns: 3fr 4fr; + gap: $spacing-4; + margin-bottom: $spacing-6; + + // Ensure code/pre inside right grid cell never overflow container + & > div:last-child { + min-width: 0; + } + + &:last-child { + margin-bottom: 0; + } + } + + &__video { + width: 100%; + height: 360px; + background-color: $color-palette-gray-200; + margin-bottom: $spacing-4; + } + + &__substeps { + padding: 0 $spacing-2; + margin-bottom: $spacing-6; + } + + &__substep-description { + display: block; + margin-left: 2.2rem; + color: $color-palette-gray-700; + } + + &__file-path { + font-family: monospace; + font-size: $font-size-sm; + color: $color-palette-gray-600; + background-color: $color-palette-gray-900; + padding: $spacing-1 $spacing-2; + border-radius: 0.25rem 0.25rem 0 0; + } + + &__next-button { + margin-inline-start: $spacing-2; + } +} + +.button-link { + position: absolute; + top: 20px; + right: 20px; + cursor: pointer; + color: #515667; +} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.spec.ts new file mode 100644 index 000000000000..13cb02ffc177 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { OnboardingDevComponent } from './onboarding-dev.component'; + +describe('OnboardingDevComponent', () => { + let component: OnboardingDevComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [OnboardingDevComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(OnboardingDevComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.ts new file mode 100644 index 000000000000..e311f9c4fad4 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.ts @@ -0,0 +1,216 @@ +import { patchState } from '@ngrx/signals'; +import { MarkdownModule } from 'ngx-markdown'; + +import { isPlatformBrowser, CommonModule } from '@angular/common'; +import { + Component, + ElementRef, + OnInit, + PLATFORM_ID, + ViewChild, + inject, + Output, + EventEmitter +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; + +import { AccordionModule } from 'primeng/accordion'; +import { AvatarModule } from 'primeng/avatar'; +import { ButtonModule } from 'primeng/button'; +import { KnobModule } from 'primeng/knob'; +import { OverlayPanel, OverlayPanelModule } from 'primeng/overlaypanel'; +import { ProgressBarModule } from 'primeng/progressbar'; +import { RadioButtonModule } from 'primeng/radiobutton'; +import { TabViewModule } from 'primeng/tabview'; +import { TagModule } from 'primeng/tag'; +import { TooltipModule } from 'primeng/tooltip'; + +import { ButtonCopyComponent } from '@dotcms/ui'; + +import { getOnboardingContent, STORAGE_KEY } from './content'; +import { OnboardingFramework, SupportedFrameworks } from './models'; +import { state } from './store'; + +@Component({ + selector: 'dot-onboarding-dev', + templateUrl: './onboarding-dev.component.html', + styleUrls: ['./onboarding-dev.component.scss'], + imports: [ + AccordionModule, + ButtonCopyComponent, + ButtonModule, + CommonModule, + FormsModule, + KnobModule, + MarkdownModule, + OverlayPanelModule, + ProgressBarModule, + RadioButtonModule, + RouterModule, + TagModule, + TooltipModule, + TabViewModule, + AvatarModule + ] +}) +export class DotOnboardingDevComponent implements OnInit { + readonly state = state; + + @Output() eventEmitter = new EventEmitter<'reset-user-profile'>(); + + selectedFramework: SupportedFrameworks = 'nextjs'; + content = getOnboardingContent(this.selectedFramework); + cliCommand = `npx @dotcms/create-app my-dotcms-app --directory=. --framework=${this.selectedFramework} --url=${window.location.origin} `; + + frameworks: OnboardingFramework[] = [ + { + id: 'nextjs', + label: 'Next.js', + logo: '/dotAdmin/assets/logos/nextjs.svg' + }, + { + id: 'angular', + label: 'Angular', + logo: '/dotAdmin/assets/logos/angular.png', + disabled: false, + githubUrl: 'https://github.com/dotCMS/core/tree/main/examples/angular' + }, + { + id: 'angular-ssr', + label: 'Angular SSR', + logo: '/dotAdmin/assets/logos/angular.png', + disabled: false, + githubUrl: 'https://github.com/dotCMS/core/tree/main/examples/angular-ssr' + }, + { + id: 'astro', + label: 'Astro', + logo: '/dotAdmin/assets/logos/astro.svg', + disabled: false, + githubUrl: 'https://github.com/dotCMS/core/tree/main/examples/astro' + }, + { + id: '.net', + label: '.Net', + logo: '/dotAdmin/assets/logos/dot-net.png', + disabled: true, + githubUrl: 'https://github.com/dotCMS/dotnet-starter-example' + }, + { + id: 'php', + label: 'PHP', + logo: '/dotAdmin/assets/logos/php.png', + disabled: true, + githubUrl: 'https://github.com/dotCMS/dotnet-starter-example' + } + ]; + + @ViewChild('onboardingContainer', { read: ElementRef }) + onboardingContainer?: ElementRef; + @ViewChild('frameworkInfoOverlay') frameworkInfoOverlay?: OverlayPanel; + selectedFrameworkInfo?: OnboardingFramework; + private readonly platformId = inject(PLATFORM_ID); + + ngOnInit(): void { + this.loadProgress(); + } + + formatSubstep(): string { + const command = `\`\`\` +${this.cliCommand} +`; + return command; + } + + activeIndexChange(newIndex: number) { + const totalSteps = this.content.steps.length; + + // newIndex is 0-based, so we need to subtract 1 to get the correct progress + const progress = Math.round((newIndex / (totalSteps - 1)) * 100); + + let title = 'All steps complete'; + + if (!totalSteps) { + title = 'No steps available'; + } + + if (this.content.steps[newIndex]) { + title = this.content.steps[newIndex].title; + } + + patchState(state, (state) => ({ + ...state, + activeAccordionIndex: newIndex, + progress, + currentStateLabel: title + })); + + this.persistProgress(); + + setTimeout(() => { + this.scrollToActiveTab(); + }, 500); + } + + showFrameworkInfo(event: Event, framework: OnboardingFramework): void { + this.selectedFrameworkInfo = framework; + this.frameworkInfoOverlay?.toggle(event); + } + + isStepCompleted(index: number): boolean { + return index <= state.activeAccordionIndex(); + } + + private loadProgress(): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + try { + const saved = localStorage.getItem(STORAGE_KEY); + + if (!saved) { + this.activeIndexChange(0); + return; + } + + this.activeIndexChange(parseInt(saved)); + } catch { + localStorage.removeItem(STORAGE_KEY); + } + } + + private persistProgress(): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + localStorage.setItem(STORAGE_KEY, state.activeAccordionIndex().toString()); + } + + private scrollToActiveTab(): void { + if (!isPlatformBrowser(this.platformId) || !this.onboardingContainer?.nativeElement) { + return; + } + const container = this.onboardingContainer.nativeElement; + const activeTab = container.querySelector( + '.p-accordion-tab-active .p-accordion-header' + ) as HTMLElement; + activeTab.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + } + + public resetUserProfile(): void { + localStorage.removeItem('user_profile'); + this.eventEmitter.emit('reset-user-profile'); + } + + public changeSelectedFramework(index: number): void { + this.content = getOnboardingContent(this.frameworks[index].id as SupportedFrameworks); + this.selectedFramework = this.frameworks[index].id as SupportedFrameworks; + this.cliCommand = `npx @dotcms/create-app my-dotcms-app --directory=. --framework=${this.selectedFramework} --url=${window.location.origin}`; + } +} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/store.ts new file mode 100644 index 000000000000..7bf58013bfbb --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/store.ts @@ -0,0 +1,15 @@ +import { signalState } from '@ngrx/signals'; + +type OnBoardingState = { + progress: number; + activeAccordionIndex: number; + currentStateLabel: string; +}; + +const INITIAL_STATE: OnBoardingState = { + progress: 0, + activeAccordionIndex: 0, + currentStateLabel: '' +}; + +export const state = signalState(INITIAL_STATE); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter-resolver.service.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter-resolver.service.spec.ts deleted file mode 100644 index 9022f0689425..000000000000 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter-resolver.service.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Observable, of } from 'rxjs'; - -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { TestBed, waitForAsync } from '@angular/core/testing'; - -import { DotCurrentUserService } from '@dotcms/data-access'; -import { CoreWebService } from '@dotcms/dotcms-js'; -import { DotPermissionsType, PermissionsType, UserPermissions } from '@dotcms/dotcms-models'; -import { CoreWebServiceMock } from '@dotcms/utils-testing'; - -import { DotStarterResolver } from './dot-starter-resolver.service'; - -export const CurrentUserDataMock = { - admin: true, - email: 'admin@dotcms.com', - givenName: 'TEST', - roleId: 'e7d23sde-5127-45fc-8123-d424fd510e3', - surname: 'User', - userId: 'testId' -}; - -const permissionsData: DotPermissionsType = { - STRUCTURES: { canRead: true, canWrite: true }, - HTMLPAGES: { canRead: true, canWrite: true }, - TEMPLATES: { canRead: true, canWrite: true }, - CONTENTLETS: { canRead: true, canWrite: true } -}; -export class DotCurrentUserServiceMock { - getCurrentUser() { - return of(CurrentUserDataMock); - } - - getUserPermissions( - _userId: string, - _permissions: UserPermissions[], - _permissionsType: PermissionsType[] - ): Observable { - return of(permissionsData); - } -} - -describe('DotStarterResolver', () => { - let dotStarterResolver: DotStarterResolver; - - beforeEach(waitForAsync(() => { - const testbed = TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [ - { provide: CoreWebService, useClass: CoreWebServiceMock }, - { provide: DotCurrentUserService, useClass: DotCurrentUserServiceMock }, - DotStarterResolver - ] - }); - dotStarterResolver = testbed.inject(DotStarterResolver); - })); - - it('should get and return user & permissions data', () => { - dotStarterResolver.resolve().subscribe(({ user, permissions }) => { - expect(user).toEqual(CurrentUserDataMock); - expect(permissions).toEqual(permissionsData); - }); - }); -}); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter-resolver.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter-resolver.service.ts deleted file mode 100644 index b4a492c5eb18..000000000000 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter-resolver.service.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Observable } from 'rxjs'; - -import { Injectable, inject } from '@angular/core'; -import { Resolve } from '@angular/router'; - -import { map, mergeMap } from 'rxjs/operators'; - -import { DotCurrentUserService } from '@dotcms/data-access'; -import { - DotCurrentUser, - DotPermissionsType, - PermissionsType, - UserPermissions -} from '@dotcms/dotcms-models'; - -/** - * Returns user's data and permissions - * - * @export - * @class DotStarterResolver - * @implements {Resolve>} - */ -@Injectable() -export class DotStarterResolver - implements Resolve> -{ - private dotCurrentUserService = inject(DotCurrentUserService); - - resolve(): Observable<{ user: DotCurrentUser; permissions: DotPermissionsType }> { - return this.dotCurrentUserService.getCurrentUser().pipe( - mergeMap((user: DotCurrentUser) => { - return this.dotCurrentUserService - .getUserPermissions( - user.userId, - [UserPermissions.WRITE], - [ - PermissionsType.HTMLPAGES, - PermissionsType.STRUCTURES, - PermissionsType.TEMPLATES, - PermissionsType.CONTENTLETS - ] - ) - .pipe( - map((permissionsType: DotPermissionsType) => { - return { user, permissions: permissionsType }; - }) - ); - }) - ); - } -} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.html index c51657ac5ca1..5ff004383089 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.html @@ -1,277 +1,49 @@ -@if (userData$ | async; as user) { -
    -
    -
    -

    +@if (showProfileSelection) { +
    +

    Welcome to dotCMS

    +

    + Let's build your first dotCMS headless application. +
    + First, tell us about your role so we can personalize your experience. +

    +
    +
    + check + developer +

    + Developer +

    +

    + I'll be building and configuring the +
    + technical aspects. +

    - -
    - - +} + +@if (showDeveloperGuide) { + +} + +@if (showMarketerGuide) { + } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.scss b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.scss index 1da17ee41c34..11b87688de78 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.scss +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.scss @@ -1,239 +1,74 @@ @use "variables" as *; -$number-link-icon-size: 2.28rem; - -:host { - display: block; - font-size: $font-size-md; - height: 100%; - overflow: auto; - background-color: $white; - - hr { - border-top: 1px solid $color-palette-gray-300; - } - - a { - text-decoration: none; - } - - .dot-starter-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - - p-checkbox { - margin-top: $spacing-5; - } - } - - .dot-starter-title { - color: $black; - font-size: $font-size-lg; - margin: $spacing-5 $spacing-3 $spacing-1; - } - - .dot-starter-description { - color: $black; - margin: 0 $spacing-3; - } - - .dot-starter-top-content, - .dot-starter-bottom-content { - display: flex; - margin: 0 auto; - max-width: 77.42rem; - flex-grow: 1; - } - - .dot-starter-top-main__section { - background-color: $white; - counter-reset: css-counter 0; - flex-grow: 1; - margin-left: 0; - margin-bottom: $spacing-4; - margin-right: $spacing-1; - display: flex; - } - - .dot-starter-offset { - margin-left: $spacing-5; - } - - .dot-starter-top-main__block { - border-radius: $border-radius-md; - box-shadow: $shadow-l; - - align-content: space-between; - border-bottom: 1px solid $color-palette-gray-300; - display: flex; - flex-direction: column; - margin: $spacing-3; - padding: $spacing-5 $spacing-3; - - transition: background-color $basic-speed ease-in; - width: 25%; - - &:hover { - background-color: $bg-hover; - } - } - - .dot-starter-top-main__link-number { - align-self: center; - margin: 0 $spacing-4 $spacing-1; - text-align: center; - - span { - align-items: center; - color: $color-palette-primary; - counter-increment: css-counter 1; - display: inline-flex; - font-size: $md-icon-size-extra-big; - height: $number-link-icon-size; - justify-content: center; - width: $number-link-icon-size; - - &::before { - content: counter(css-counter); - } - } - } - - .dot-starter-top-main__link-data { - align-self: center; - flex-grow: 1; - text-align: center; - - h4 { - color: $black; - font-size: $font-size-lmd; - font-weight: $font-weight-semi-bold; - margin: 0; - } - p { - color: $color-palette-gray-700; - margin: 0; - font-size: $font-size-md; - } - } - - .dot-starter-top-main__link-arrow { - align-self: center; - - i { - color: $color-palette-gray-500; - font-size: $font-size-lg; - margin-right: $spacing-3; - } - } - - .dot-starter-top-main__title { - font-size: $font-size-lmd; - font-weight: $font-weight-semi-bold; - margin: 0; - border-bottom: 1px solid $color-palette-gray-300; - padding-bottom: $spacing-3; - } - .dot-starter-top-secondary__block { - display: flex; - padding-bottom: $spacing-8; - - &:last-child { - padding-bottom: 0; - } - } - - .dot-starter-top-secondary__link-icon { - padding-top: 0; - - span { - color: $color-palette-primary; - fill: $color-palette-primary; - display: inline-flex; - width: $number-link-icon-size; - } - - svg { - fill: $color-palette-primary; - height: 1.71rem; - width: $md-icon-size-normal; - } - } +.main-container { + background: linear-gradient(135deg, #eff6ff 0%, #fff 50%, #faf5ff 100%), #fff; +} - .dot-starter-top-secondary__link-data { - padding: 0; +.p-card-body { + padding: 0 !important; +} - h4 { - color: $black; - font-size: $font-size-lmd; - margin: 0; - } - p { - color: $color-palette-gray-700; - margin: 0; - } - } +.card { + width: 324px; + display: flex; + flex-direction: column; + height: 220px; + padding: 34px; + justify-content: center; + align-items: center; + flex-shrink: 0; + position: relative; + cursor: pointer; + + border-radius: 16px; + background: var(--Color-Neutrals-White, #fff); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -4px rgba(0, 0, 0, 0.1); +} - .dot-starter-top-secondary__link-data-apis { - padding: 0; +.check-circle { + position: absolute; + top: 15px; + right: 15px; + visibility: hidden; +} - h4 { - color: $color-palette-primary; - font-size: $font-size-lmd; - font-weight: 500; - margin: 0; - padding-bottom: $spacing-1; - } - p { - color: $color-palette-gray-700; - margin: 0; - } - } +.card:hover { + border: 1px solid #426bf0; + border-radius: 16px; - .dot-starter-bottom-content { - flex-wrap: wrap; - padding: $spacing-1 0 $spacing-5 $spacing-5; + .check-circle { + visibility: visible; } +} - .dot-starter-bottom__block { - background-color: $white; - border-bottom: 1px solid $color-palette-gray-300; - box-shadow: $shadow-m; - display: flex; - flex-grow: 1; - height: 100%; - overflow: hidden; - transition: background-color $basic-speed ease-in; - border-radius: $border-radius-md; - gap: $spacing-1; - padding: $spacing-3; - - &:hover { - background-color: $bg-hover; - } - } +.heading { + font-weight: $font-weight-bold; + font-size: 50px; + line-height: 24px; + letter-spacing: -0.31px; + text-align: center; +} - .dot-starter-bottom__link-icon { - padding-top: $spacing-0; - span { - color: $color-palette-primary; - display: inline-flex; - } - } +.paragraph { + font-weight: 400; + color: $color-palette-gray-800; + font-size: $font-size-slg; + line-height: 28px; + letter-spacing: -0.44px; + text-align: center; +} - .dot-starter-bottom__link-data { - margin-right: $spacing-5; - padding: 0; +.card-heading { + font-weight: $font-weight-semi-bold; + font-size: $font-size-lmd; + line-height: 24px; + letter-spacing: -0.31px; + text-align: center; +} - h4 { - color: $black; - font-size: $font-size-lmd; - margin: 0; - } - p { - color: $color-palette-gray-700; - margin: 0; - } - } +.card-paragraph { + font-size: $font-size-md; + color: $color-palette-gray-800; } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.spec.ts index 209da16d15a7..e69de29bb2d1 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.spec.ts @@ -1,325 +0,0 @@ -import { createComponentFactory, Spectator, byTestId, mockProvider } from '@ngneat/spectator/jest'; -import { of } from 'rxjs'; - -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ActivatedRoute } from '@angular/router'; - -import { CheckboxModule, Checkbox } from 'primeng/checkbox'; - -import { DotMessageService, DotRouterService } from '@dotcms/data-access'; -import { CoreWebService } from '@dotcms/dotcms-js'; -import { DotMessagePipe } from '@dotcms/ui'; -import { - CoreWebServiceMock, - MockDotMessageService, - MockDotRouterService -} from '@dotcms/utils-testing'; - -import { DotStarterResolver } from './dot-starter-resolver.service'; -import { DotStarterComponent } from './dot-starter.component'; - -import { DotAccountService } from '../../api/services/dot-account-service'; - -const messages = { - 'starter.title': 'Welcome!', - 'starter.description': - 'You are logged in as {0}. To help you get started building with dotCMS we provided some quick links.', - 'starter.dont.show': `Don't show this again`, - 'starter.main.link.data.model.title': 'Create data model', - 'starter.main.link.data.model.description': 'Create data model description', - 'starter.main.link.add.content.title': 'Add content', - 'starter.main.link.add.content.description': 'Add content description', - 'starter.main.link.design.layout.title': 'Design a layout', - 'starter.main.link.design.layout.description': 'Design a layout description', - 'starter.main.link.create.page.title': 'Create a page', - 'starter.main.link.create.page.description': 'Create a page description', - 'starter.side.title': 'APIs and Services', - 'starter.side.link.graphQl.title': 'GraphQL API', - 'starter.side.link.graphQl.description': 'GraphQL API description', - 'starter.side.link.content.title': 'Content API', - 'starter.side.link.content.description': 'Content API description', - 'starter.side.link.image.processing.title': 'Image Resizing and Processing', - 'starter.side.link.image.processing.description': 'Image Resizing and Processing description', - 'starter.side.link.page.layout.title': 'Page Layout API (Layout as a Service)', - 'starter.side.link.page.layout.description': 'Page Layout API description', - 'starter.side.link.generate.key.title': 'Generate API Token', - 'starter.side.link.generate.key.description': 'Generate API Token description', - 'starter.footer.link.documentation.title': 'Documentation', - 'starter.footer.link.documentation.description': 'Documentation description', - 'starter.footer.link.examples.title': 'Examples', - 'starter.footer.link.examples.description': 'Examples description', - 'starter.footer.link.community.title': 'Community', - 'starter.footer.link.community.description': 'Community description', - 'starter.footer.link.training.title': 'Training Videos', - 'starter.footer.link.training.description': 'Training Videos description', - 'starter.footer.link.review.title': 'Write A Review', - 'starter.footer.link.review.description': 'Write A Review description', - 'starter.footer.link.feedback.title': 'Feedback', - 'starter.footer.link.feedback.description': 'Feedback description' -}; - -const routeDataMock = { - userData: { - user: { - email: 'admin@dotcms.com', - givenName: 'Admin', - roleId: 'e7d23sde-5127-45fc-8123-d424fd510e3', - surname: 'User', - userId: 'testId' - }, - permissions: { - STRUCTURES: { canRead: true, canWrite: true }, - HTMLPAGES: { canRead: true, canWrite: true }, - TEMPLATES: { canRead: true, canWrite: true }, - CONTENTLETS: { canRead: true, canWrite: true } - } - } -}; - -const routeDataWithoutPermissionsMock = { - userData: { - user: { - email: 'admin@dotcms.com', - givenName: 'Admin', - roleId: 'e7d23sde-5127-45fc-8123-d424fd510e3', - surname: 'User', - userId: 'testId' - }, - permissions: { - STRUCTURES: { canRead: true, canWrite: false }, - HTMLPAGES: { canRead: true, canWrite: false }, - TEMPLATES: { canRead: true, canWrite: false }, - CONTENTLETS: { canRead: true, canWrite: false } - } - } -}; - -class ActivatedRouteMock { - get data() { - return of(routeDataMock); - } -} - -describe('DotStarterComponent', () => { - describe('With user permissions', () => { - let spectator: Spectator; - const messageServiceMock = new MockDotMessageService(messages); - - const createComponent = createComponentFactory({ - component: DotStarterComponent, - imports: [DotMessagePipe, CheckboxModule], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: DotMessageService, useValue: messageServiceMock }, - { provide: ActivatedRoute, useClass: ActivatedRouteMock }, - { provide: CoreWebService, useClass: CoreWebServiceMock }, - { provide: DotRouterService, useClass: MockDotRouterService }, - DotStarterResolver, - mockProvider(DotAccountService, { - addStarterPage: jest.fn().mockReturnValue(of(true)), - removeStarterPage: jest.fn().mockReturnValue(of(true)) - }) - ] - }); - - beforeEach(() => { - spectator = createComponent(); - }); - - describe('With user permissions', () => { - it('should set proper labels to the main container', () => { - expect(spectator.query('.dot-starter-description')).toHaveText( - 'You are logged in as Admin. To help you get started building with dotCMS we provided some quick links.' - ); - - expect(spectator.query(byTestId('starter.main.link.data.model'))).toHaveText( - messageServiceMock.get('starter.main.link.data.model.title') - ); - expect(spectator.query(byTestId('starter.main.link.data.model'))).toHaveText( - messageServiceMock.get('starter.main.link.data.model.description') - ); - - expect(spectator.query(byTestId('starter.main.link.content'))).toHaveText( - messageServiceMock.get('starter.main.link.add.content.title') - ); - expect(spectator.query(byTestId('starter.main.link.content'))).toHaveText( - messageServiceMock.get('starter.main.link.add.content.description') - ); - - expect(spectator.query(byTestId('starter.main.link.design.layout'))).toHaveText( - messageServiceMock.get('starter.main.link.design.layout.title') - ); - expect(spectator.query(byTestId('starter.main.link.design.layout'))).toHaveText( - messageServiceMock.get('starter.main.link.design.layout.description') - ); - - expect(spectator.query(byTestId('starter.main.link.create.page'))).toHaveText( - messageServiceMock.get('starter.main.link.create.page.title') - ); - expect(spectator.query(byTestId('starter.main.link.create.page'))).toHaveText( - messageServiceMock.get('starter.main.link.create.page.description') - ); - }); - - it('should set proper labels to the side container', () => { - expect(spectator.query(byTestId('dot-side-title'))).toHaveText( - messageServiceMock.get('starter.side.title') - ); - - expect(spectator.query(byTestId('starter.side.link.graphQl'))).toHaveText( - messageServiceMock.get('starter.side.link.graphQl.title') - ); - expect(spectator.query(byTestId('starter.side.link.graphQl'))).toHaveText( - messageServiceMock.get('starter.side.link.graphQl.description') - ); - - expect(spectator.query(byTestId('starter.side.link.content'))).toHaveText( - messageServiceMock.get('starter.side.link.content.title') - ); - expect(spectator.query(byTestId('starter.side.link.content'))).toHaveText( - messageServiceMock.get('starter.side.link.content.description') - ); - - expect(spectator.query(byTestId('starter.side.link.image.processing'))).toHaveText( - messageServiceMock.get('starter.side.link.image.processing.title') - ); - expect(spectator.query(byTestId('starter.side.link.image.processing'))).toHaveText( - messageServiceMock.get('starter.side.link.image.processing.description') - ); - - expect(spectator.query(byTestId('starter.side.link.page.layout'))).toHaveText( - messageServiceMock.get('starter.side.link.page.layout.title') - ); - expect(spectator.query(byTestId('starter.side.link.page.layout'))).toHaveText( - messageServiceMock.get('starter.side.link.page.layout.description') - ); - - expect(spectator.query(byTestId('starter.side.link.generate.key'))).toHaveText( - messageServiceMock.get('starter.side.link.generate.key.title') - ); - expect(spectator.query(byTestId('starter.side.link.generate.key'))).toHaveText( - messageServiceMock.get('starter.side.link.generate.key.description') - ); - }); - - it('should set proper labels to the footer container', () => { - expect(spectator.query(byTestId('starter.footer.link.documentation'))).toHaveText( - messageServiceMock.get('starter.footer.link.documentation.title') - ); - expect(spectator.query(byTestId('starter.footer.link.documentation'))).toHaveText( - messageServiceMock.get('starter.footer.link.documentation.description') - ); - - expect(spectator.query(byTestId('starter.footer.link.examples'))).toHaveText( - messageServiceMock.get('starter.footer.link.examples.title') - ); - expect(spectator.query(byTestId('starter.footer.link.examples'))).toHaveText( - messageServiceMock.get('starter.footer.link.examples.description') - ); - - expect(spectator.query(byTestId('starter.footer.link.community'))).toHaveText( - messageServiceMock.get('starter.footer.link.community.title') - ); - expect(spectator.query(byTestId('starter.footer.link.community'))).toHaveText( - messageServiceMock.get('starter.footer.link.community.description') - ); - - expect(spectator.query(byTestId('starter.footer.link.training'))).toHaveText( - messageServiceMock.get('starter.footer.link.training.title') - ); - expect(spectator.query(byTestId('starter.footer.link.training'))).toHaveText( - messageServiceMock.get('starter.footer.link.training.description') - ); - - expect(spectator.query(byTestId('starter.footer.link.review'))).toHaveText( - messageServiceMock.get('starter.footer.link.review.title') - ); - expect(spectator.query(byTestId('starter.footer.link.review'))).toHaveText( - messageServiceMock.get('starter.footer.link.review.description') - ); - - expect(spectator.query(byTestId('starter.footer.link.feedback'))).toHaveText( - messageServiceMock.get('starter.footer.link.feedback.title') - ); - expect(spectator.query(byTestId('starter.footer.link.feedback'))).toHaveText( - messageServiceMock.get('starter.footer.link.feedback.description') - ); - }); - - it('should have right links to internal portlets', () => { - expect(spectator.query(byTestId('starter.main.link.data.model'))).toHaveAttribute( - 'routerLink', - '/content-types-angular/create/content' - ); - - expect(spectator.query(byTestId('starter.main.link.content'))).toHaveAttribute( - 'routerLink', - '/c/content/new/webPageContent' - ); - - expect( - spectator.query(byTestId('starter.main.link.design.layout')) - ).toHaveAttribute('routerLink', '/templates/new/designer'); - - expect(spectator.query(byTestId('starter.main.link.create.page'))).toHaveAttribute( - 'routerLink', - '/c/content/new/htmlpageasset' - ); - }); - - it('should call the endpoint to hide/show the portlet', () => { - const dotAccountService = spectator.inject(DotAccountService); - const checkbox = spectator.query(Checkbox); - - expect(checkbox.label).toBe(messageServiceMock.get('starter.dont.show')); - - spectator.component.handleVisibility(true); - expect(dotAccountService.removeStarterPage).toHaveBeenCalledTimes(1); - - spectator.component.handleVisibility(false); - expect(dotAccountService.addStarterPage).toHaveBeenCalledTimes(1); - }); - }); - }); - - describe('Without user permissions', () => { - let spectator: Spectator; - const messageServiceMock = new MockDotMessageService(messages); - - const createComponent = createComponentFactory({ - component: DotStarterComponent, - imports: [DotMessagePipe, CheckboxModule], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: DotMessageService, useValue: messageServiceMock }, - { - provide: ActivatedRoute, - useValue: { data: of(routeDataWithoutPermissionsMock) } - }, - { provide: CoreWebService, useClass: CoreWebServiceMock }, - { provide: DotRouterService, useClass: MockDotRouterService }, - DotStarterResolver, - mockProvider(DotAccountService, { - addStarterPage: jest.fn().mockReturnValue(of(true)), - removeStarterPage: jest.fn().mockReturnValue(of(true)) - }) - ] - }); - - beforeEach(() => { - spectator = createComponent(); - }); - - it('should hide links from the main container', () => { - spectator.detectChanges(); - - expect(spectator.query(byTestId('starter.main.link.data.model'))).toBeFalsy(); - expect(spectator.query(byTestId('starter.main.link.content'))).toBeFalsy(); - expect(spectator.query(byTestId('starter.main.link.design.layout'))).toBeFalsy(); - expect(spectator.query(byTestId('starter.main.link.create.page'))).toBeFalsy(); - }); - }); -}); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.ts index 8d066a05a432..9a472536476a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.ts @@ -1,74 +1,52 @@ -import { Observable } from 'rxjs'; +import { Component, OnInit } from '@angular/core'; -import { Component, DestroyRef, inject, OnInit } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ActivatedRoute } from '@angular/router'; +import { DotOnboardingAuthorComponent } from './components/onboarding-author/onboarding-author.component'; +import { DotOnboardingDevComponent } from './components/onboarding-dev/onboarding-dev.component'; -import { map, pluck, take } from 'rxjs/operators'; - -import { DotCurrentUser, DotPermissionsType, PermissionsType } from '@dotcms/dotcms-models'; - -import { DotAccountService } from '../../api/services/dot-account-service'; +export type UserProfile = 'developer' | 'marketer'; @Component({ selector: 'dot-starter', templateUrl: './dot-starter.component.html', styleUrls: ['./dot-starter.component.scss'], - standalone: false + imports: [DotOnboardingDevComponent, DotOnboardingAuthorComponent] }) export class DotStarterComponent implements OnInit { - private route = inject(ActivatedRoute); - private dotAccountService = inject(DotAccountService); + public profile: UserProfile = localStorage.getItem('user_profile') as UserProfile; + public showProfileSelection = true; + public showDeveloperGuide = false; + public showMarketerGuide = false; + + ngOnInit(): void { + if (this.profile !== null && this.profile === 'developer') { + this.showDeveloperGuide = true; + this.showProfileSelection = false; + } + + if (this.profile !== null && this.profile === 'marketer') { + this.showMarketerGuide = true; + this.showProfileSelection = false; + } + } - userData$: Observable<{ - username: string; - showCreateContentLink: boolean; - showCreateDataModelLink: boolean; - showCreatePageLink: boolean; - showCreateTemplateLink: boolean; - }>; - username: string; - showCreateContentLink: boolean; - showCreateDataModelLink: boolean; - showCreatePageLink: boolean; - showCreateTemplateLink: boolean; + public setUserProfile(selectedProfile: UserProfile) { + localStorage.setItem('user_profile', selectedProfile); + this.showProfileSelection = false; + this.showMarketerGuide = false; + this.showDeveloperGuide = false; - readonly #destroyRef = inject(DestroyRef); + if (selectedProfile === 'marketer') { + this.showMarketerGuide = true; + } - ngOnInit() { - this.userData$ = this.route.data.pipe( - pluck('userData'), - take(1), - map( - ({ - user, - permissions - }: { - user: DotCurrentUser; - permissions: DotPermissionsType; - }) => { - return { - username: user.givenName, - showCreateContentLink: permissions[PermissionsType.CONTENTLETS].canWrite, - showCreateDataModelLink: permissions[PermissionsType.STRUCTURES].canWrite, - showCreatePageLink: permissions[PermissionsType.HTMLPAGES].canWrite, - showCreateTemplateLink: permissions[PermissionsType.TEMPLATES].canWrite - }; - } - ) - ); + if (selectedProfile === 'developer') { + this.showDeveloperGuide = true; + } } - /** - * Hit the endpoint to show/hide the tool group in the menu. - * @param {boolean} hide - * @memberof DotStarterComponent - */ - handleVisibility(hide: boolean): void { - const subscription = hide - ? this.dotAccountService.removeStarterPage() - : this.dotAccountService.addStarterPage(); - - subscription.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe(); + public onUserProfileReset(): void { + this.showProfileSelection = true; + this.showDeveloperGuide = false; + this.showMarketerGuide = false; } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.module.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.module.ts deleted file mode 100644 index 02dcc61bf9bd..000000000000 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.module.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; - -import { CheckboxModule } from 'primeng/checkbox'; - -import { DotMessagePipe } from '@dotcms/ui'; - -import { DotStarterResolver } from './dot-starter-resolver.service'; -import { DotStarterComponent } from './dot-starter.component'; -import { dotStarterRoutes } from './dot-starter.routes'; - -import { DotToolbarAnnouncementsComponent } from '../../view/components/dot-toolbar/components/dot-toolbar-announcements/dot-toolbar-announcements.component'; - -@NgModule({ - declarations: [DotStarterComponent], - imports: [ - CommonModule, - RouterModule.forChild(dotStarterRoutes), - DotMessagePipe, - CheckboxModule, - DotToolbarAnnouncementsComponent - ], - providers: [DotStarterResolver] -}) -export class DotStarterModule {} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.routes.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.routes.ts index d067842c2d9c..d11d3cad4e16 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.routes.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.routes.ts @@ -1,14 +1,10 @@ import { Routes } from '@angular/router'; -import { DotStarterResolver } from './dot-starter-resolver.service'; import { DotStarterComponent } from './dot-starter.component'; export const dotStarterRoutes: Routes = [ { - component: DotStarterComponent, path: '', - resolve: { - userData: DotStarterResolver - } + component: DotStarterComponent } ]; diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-item/dot-nav-item.component.scss b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-item/dot-nav-item.component.scss index 4f5502750eaa..0433aad52acb 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-item/dot-nav-item.component.scss +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-nav-item/dot-nav-item.component.scss @@ -81,7 +81,9 @@ align-items: center; cursor: pointer; display: flex; - padding: $spacing-3 0 $spacing-2 $spacing-4; + gap: $spacing-2; + align-items: center; + margin-inline-start: $spacing-4; } .dot-nav__item-toggle { @@ -94,8 +96,6 @@ } .dot-nav__item-label { - flex: 1; - margin-left: $spacing-3; word-break: break-word; transition: opacity $basic-speed ease; } @@ -110,6 +110,5 @@ dot-icon { } dot-nav-icon { - flex-shrink: 0; pointer-events: none; } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/main-legacy/main-legacy.component.html b/core-web/apps/dotcms-ui/src/app/view/components/main-legacy/main-legacy.component.html index aa8868491dd0..4ae66f2f79dd 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/main-legacy/main-legacy.component.html +++ b/core-web/apps/dotcms-ui/src/app/view/components/main-legacy/main-legacy.component.html @@ -10,18 +10,16 @@ aria-label="Main navigation"> -
    - -
    - -
    -
    + +
    + +
    diff --git a/core-web/apps/dotcms-ui/src/app/view/components/main-legacy/main-legacy.component.scss b/core-web/apps/dotcms-ui/src/app/view/components/main-legacy/main-legacy.component.scss index bed4093a08fc..ccc8e91289a5 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/main-legacy/main-legacy.component.scss +++ b/core-web/apps/dotcms-ui/src/app/view/components/main-legacy/main-legacy.component.scss @@ -3,33 +3,27 @@ @import "dotcms-theme/utils/_mixins.scss"; .layout { - display: flex; + display: grid; + grid-template-areas: + "navigation branding-bar" + "navigation content-viewport"; + grid-template-columns: auto 1fr; + grid-template-rows: auto 1fr; height: 100vh; width: 100vw; overflow: hidden; } .layout__navigation { + grid-area: navigation; background-color: $brand-background; - flex-shrink: 0; -} - -.layout__main { - height: 100%; - width: 100%; - overflow: hidden; - display: flex; - flex-direction: column; - flex: 1; } .layout__branding-bar { - flex-shrink: 0; + grid-area: branding-bar; } .layout__viewport { - overflow: hidden; - height: 100%; - width: 100%; - flex: 1; + grid-area: content-viewport; + overflow-y: auto; } diff --git a/core-web/apps/dotcms-ui/src/assets/logos/angular.png b/core-web/apps/dotcms-ui/src/assets/logos/angular.png new file mode 100644 index 000000000000..2f1732360527 Binary files /dev/null and b/core-web/apps/dotcms-ui/src/assets/logos/angular.png differ diff --git a/core-web/apps/dotcms-ui/src/assets/logos/astro.svg b/core-web/apps/dotcms-ui/src/assets/logos/astro.svg new file mode 100644 index 000000000000..e52118199d56 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/assets/logos/astro.svg @@ -0,0 +1,4 @@ + + + + diff --git a/core-web/apps/dotcms-ui/src/assets/logos/check-circle.svg b/core-web/apps/dotcms-ui/src/assets/logos/check-circle.svg new file mode 100644 index 000000000000..db234856a870 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/assets/logos/check-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/core-web/apps/dotcms-ui/src/assets/logos/code.svg b/core-web/apps/dotcms-ui/src/assets/logos/code.svg new file mode 100644 index 000000000000..91ec556d9ec2 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/assets/logos/code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/core-web/apps/dotcms-ui/src/assets/logos/dot-net.png b/core-web/apps/dotcms-ui/src/assets/logos/dot-net.png new file mode 100644 index 000000000000..0f4c35594f66 Binary files /dev/null and b/core-web/apps/dotcms-ui/src/assets/logos/dot-net.png differ diff --git a/core-web/apps/dotcms-ui/src/assets/logos/marketer.svg b/core-web/apps/dotcms-ui/src/assets/logos/marketer.svg new file mode 100644 index 000000000000..9dfb2dfad72c --- /dev/null +++ b/core-web/apps/dotcms-ui/src/assets/logos/marketer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/core-web/apps/dotcms-ui/src/assets/logos/nextjs.svg b/core-web/apps/dotcms-ui/src/assets/logos/nextjs.svg new file mode 100644 index 000000000000..72ab6e050cb1 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/assets/logos/nextjs.svg @@ -0,0 +1,33 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + + + + + + diff --git a/core-web/apps/dotcms-ui/src/assets/logos/php.png b/core-web/apps/dotcms-ui/src/assets/logos/php.png new file mode 100644 index 000000000000..cc5906437108 Binary files /dev/null and b/core-web/apps/dotcms-ui/src/assets/logos/php.png differ diff --git a/core-web/apps/dotcms-ui/src/assets/logos/reactjs.svg b/core-web/apps/dotcms-ui/src/assets/logos/reactjs.svg new file mode 100644 index 000000000000..ea77a618d948 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/assets/logos/reactjs.svg @@ -0,0 +1,9 @@ + + React Logo + + + + + + + diff --git a/core-web/apps/dotcms-ui/src/assets/logos/sync.svg b/core-web/apps/dotcms-ui/src/assets/logos/sync.svg new file mode 100644 index 000000000000..fd34c3452d1f --- /dev/null +++ b/core-web/apps/dotcms-ui/src/assets/logos/sync.svg @@ -0,0 +1,4 @@ + + + + diff --git a/core-web/apps/dotcms-ui/src/main.ts b/core-web/apps/dotcms-ui/src/main.ts index f1436078b43c..add16616ecbc 100644 --- a/core-web/apps/dotcms-ui/src/main.ts +++ b/core-web/apps/dotcms-ui/src/main.ts @@ -2,8 +2,8 @@ import { enableProdMode, importProvidersFrom } from '@angular/core'; import { bootstrapApplication } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { defineCustomElements } from '@dotcms/dotcms-webcomponents/loader'; - +// import { defineCustomElements } from '@dotcms/dotcms-webcomponents/loader'; +// import { AppComponent } from './app/app.component'; import { AppModule } from './app/app.module'; import { environment } from './environments/environment'; @@ -15,4 +15,5 @@ if (environment.production) { bootstrapApplication(AppComponent, { providers: [importProvidersFrom(AppModule, BrowserAnimationsModule)] }); -defineCustomElements(); + +// defineCustomElements(); diff --git a/core-web/apps/dotcms-ui/src/tsconfig.app.json b/core-web/apps/dotcms-ui/src/tsconfig.app.json deleted file mode 100644 index a786e019f475..000000000000 --- a/core-web/apps/dotcms-ui/src/tsconfig.app.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../out-tsc/app", - "types": [] - }, - "files": ["main.ts", "polyfills.ts"], - "include": ["src/**/*.d.ts"], - "exclude": ["**/*.stories.*"] -} diff --git a/core-web/apps/dotcms-ui/src/tsconfig.spec.json b/core-web/apps/dotcms-ui/src/tsconfig.spec.json deleted file mode 100644 index 1e95880956cd..000000000000 --- a/core-web/apps/dotcms-ui/src/tsconfig.spec.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "module": "commonjs", - "outDir": "../out-tsc/spec", - "types": ["jasmine", "node"] - }, - "files": ["test.ts", "polyfills.ts"], - "include": ["**/*.spec.ts", "**/*.d.ts"] -} diff --git a/core-web/apps/dotcms-ui/tsconfig.app.json b/core-web/apps/dotcms-ui/tsconfig.app.json index 1446b8b4635a..2faf865d8b45 100644 --- a/core-web/apps/dotcms-ui/tsconfig.app.json +++ b/core-web/apps/dotcms-ui/tsconfig.app.json @@ -4,7 +4,9 @@ "outDir": "../../dist/out-tsc", "types": [], "target": "ES2022", - "useDefineForClassFields": false + "useDefineForClassFields": false, + "incremental": true, + "tsBuildInfoFile": "../../dist/out-tsc/.tsbuildinfo" }, "files": ["src/main.ts", "src/polyfills.ts"], "exclude": ["**/*.stories.ts", "**/*.stories.js"] diff --git a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_overlaypanel.scss b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_overlaypanel.scss index cd711d017716..5b2fa6f90526 100644 --- a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_overlaypanel.scss +++ b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_overlaypanel.scss @@ -6,6 +6,7 @@ border: 0 none; border-radius: $border-radius-md; box-shadow: $shadow-m; + border: 1px solid $color-palette-gray-300; } .p-overlaypanel .p-overlaypanel-content { diff --git a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_tag.scss b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_tag.scss index 1485a1ac5e06..7d4980e8e3d4 100644 --- a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_tag.scss +++ b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_tag.scss @@ -35,7 +35,7 @@ p-tag { } &.p-tag-info .p-tag { - background-color: $color-accessible-text-blue-bg; + background-color: $color-alert-blue-light; color: $color-accessible-text-blue; } diff --git a/core-web/libs/ui/src/index.ts b/core-web/libs/ui/src/index.ts index a77a9adb7717..42a6bdddc2fb 100644 --- a/core-web/libs/ui/src/index.ts +++ b/core-web/libs/ui/src/index.ts @@ -32,6 +32,7 @@ export * from './lib/dot-icon/dot-icon.component'; export * from './lib/dot-spinner/dot-spinner.component'; export * from './lib/dot-tab-buttons/dot-tab-buttons.component'; export * from './lib/modules/dot-dialog/dot-dialog.component'; +export * from './lib/button-copy/button-copy.component'; // Directives export * from './lib/directives/dot-autofocus/dot-autofocus.directive'; diff --git a/core-web/libs/ui/src/lib/button-copy/button-copy.component.html b/core-web/libs/ui/src/lib/button-copy/button-copy.component.html new file mode 100644 index 000000000000..e891d13e8a4f --- /dev/null +++ b/core-web/libs/ui/src/lib/button-copy/button-copy.component.html @@ -0,0 +1,13 @@ + diff --git a/core-web/libs/ui/src/lib/button-copy/button-copy.component.scss b/core-web/libs/ui/src/lib/button-copy/button-copy.component.scss new file mode 100644 index 000000000000..ef3a6ca2cdc1 --- /dev/null +++ b/core-web/libs/ui/src/lib/button-copy/button-copy.component.scss @@ -0,0 +1,31 @@ +@use "variables" as *; + +:host { + display: inline-flex; +} + +button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + margin: 0; + border: none; + background: transparent; + color: $white; + cursor: pointer; + transition: transform 120ms ease; +} + +button:hover { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; +} + +button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px currentColor; + border-radius: 4px; +} diff --git a/core-web/libs/ui/src/lib/button-copy/button-copy.component.spec.ts b/core-web/libs/ui/src/lib/button-copy/button-copy.component.spec.ts new file mode 100644 index 000000000000..e4ed6b602431 --- /dev/null +++ b/core-web/libs/ui/src/lib/button-copy/button-copy.component.spec.ts @@ -0,0 +1,37 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; + +import { ButtonCopyComponent } from './button-copy.component'; + +describe('ButtonCopyComponent', () => { + let component: ButtonCopyComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ButtonCopyComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(ButtonCopyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should switch icons when clicked and revert after 1s', fakeAsync(() => { + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + + button.click(); + fixture.detectChanges(); + + const icon: HTMLElement = button.querySelector('.pi') as HTMLElement; + expect(icon.classList).toContain('pi-check'); + + tick(1000); + fixture.detectChanges(); + + expect(icon.classList).toContain('pi-copy'); + })); +}); diff --git a/core-web/libs/ui/src/lib/button-copy/button-copy.component.ts b/core-web/libs/ui/src/lib/button-copy/button-copy.component.ts new file mode 100644 index 000000000000..bd283a7dc275 --- /dev/null +++ b/core-web/libs/ui/src/lib/button-copy/button-copy.component.ts @@ -0,0 +1,43 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + OnDestroy, + inject +} from '@angular/core'; + +import { TooltipModule } from 'primeng/tooltip'; + +@Component({ + selector: 'dot-button-copy', + imports: [CommonModule, TooltipModule], + templateUrl: './button-copy.component.html', + styleUrl: './button-copy.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true +}) +export class ButtonCopyComponent implements OnDestroy { + copied = false; + private resetTimeoutId?: ReturnType; + private readonly cdr = inject(ChangeDetectorRef); + + handleClick(): void { + if (this.copied) { + return; + } + + this.copied = true; + this.cdr.markForCheck(); + this.resetTimeoutId = setTimeout(() => { + this.copied = false; + this.cdr.markForCheck(); + }, 800); + } + + ngOnDestroy(): void { + if (this.resetTimeoutId) { + clearTimeout(this.resetTimeoutId); + } + } +} diff --git a/core-web/nx.json b/core-web/nx.json index 58f001664305..d9c0f89ff93d 100644 --- a/core-web/nx.json +++ b/core-web/nx.json @@ -4,7 +4,7 @@ "runner": "nx/tasks-runners/default", "options": { "cacheableOperations": ["build", "lint", "test", "e2e", "build-storybook"], - "parallel": 1 + "parallel": 3 } } }, @@ -50,6 +50,9 @@ "dependsOn": ["^build"], "inputs": ["production", "^production"] }, + "serve": { + "dependsOn": [] + }, "test": { "inputs": ["default", "^production", "{workspaceRoot}/karma.conf.js"] }, @@ -182,6 +185,5 @@ "version": { "preVersionCommand": "yarn nx run-many -t build" } - }, - "useLegacyCache": true + } } diff --git a/core-web/package.json b/core-web/package.json index c2884b12738f..68c8e2ab3bfa 100644 --- a/core-web/package.json +++ b/core-web/package.json @@ -102,6 +102,7 @@ "@tiptap/suggestion": "^2.14.0", "@webcomponents/webcomponentsjs": "^2.6.0", "chart.js": "^4.3.0", + "clipboard": "^2.0.11", "consola": "^3.4.2", "core-js": "3.36.1", "cross-fetch": "^3.1.4", @@ -118,7 +119,7 @@ "next": "^14.0.4", "ng-packagr": "19.2.2", "ng2-dragula": "5.0.1", - "ngx-markdown": "18.1.0", + "ngx-markdown": "^20.1.0", "ngx-tiptap": "^12.0.0", "node-fetch": "^2.6.1", "primeflex": "3.3.1", diff --git a/core-web/yarn.lock b/core-web/yarn.lock index d651c60331c9..fd383b1c7f19 100644 --- a/core-web/yarn.lock +++ b/core-web/yarn.lock @@ -18702,10 +18702,10 @@ ng2-dragula@5.0.1: dependencies: tslib "^2.3.0" -ngx-markdown@18.1.0: - version "18.1.0" - resolved "https://registry.npmjs.org/ngx-markdown/-/ngx-markdown-18.1.0.tgz#940dde1bf1a5bd9450343aed2387006329b5e27d" - integrity sha512-n4HFSm5oqVMXFuD+WXIVkI6NyxD8Oubr4B3c9U1J7Ptr6t9DVnkNBax3yxWc+8Wli+FXTuGEnDXzB3sp7E9paA== +ngx-markdown@^20.1.0: + version "20.1.0" + resolved "https://registry.npmjs.org/ngx-markdown/-/ngx-markdown-20.1.0.tgz#e48b043e7ea00c63db2c3f48c5f45dbc7198d500" + integrity sha512-BLn6CTMO27cU0zeaJYoC1g5c1hAkrpE5oqVSQFGW0J5gq+gEuvTt4vrtNLc8Z+HYXtuuWmuhUWiXL/bYoiDJ+A== dependencies: tslib "^2.3.0" optionalDependencies: @@ -18713,7 +18713,7 @@ ngx-markdown@18.1.0: emoji-toolkit ">= 8.0.0 < 10.0.0" katex "^0.16.0" mermaid ">= 10.6.0 < 12.0.0" - prismjs "^1.28.0" + prismjs "^1.30.0" ngx-tiptap@^12.0.0: version "12.0.0" @@ -21154,7 +21154,7 @@ primeng@17.18.11: dependencies: tslib "^2.3.0" -prismjs@^1.28.0, prismjs@^1.30.0: +prismjs@^1.30.0: version "1.30.0" resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz#d9709969d9d4e16403f6f348c63553b19f0975a9" integrity sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw== @@ -23454,7 +23454,7 @@ string-length@^4.0.1, string-length@^4.0.2: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -23472,6 +23472,15 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^2.0.0, string-width@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" @@ -23591,7 +23600,7 @@ stringify-package@^1.0.0, stringify-package@^1.0.1: resolved "https://registry.npmjs.org/stringify-package/-/stringify-package-1.0.1.tgz#e5aa3643e7f74d0f28628b72f3dad5cecfc3ba85" integrity sha512-sa4DUQsYciMP1xhKWGuFM04fB0LG/9DlluZoSVywUMRNvzid6XucHK0/90xGxRoHrAaROrcHK1aPKaijCtSrhg== -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -23619,6 +23628,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.2" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -25736,7 +25752,7 @@ worker-farm@^1.6.0, worker-farm@^1.7.0: dependencies: errno "~0.1.7" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -25763,6 +25779,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"