diff --git a/README.md b/README.md index b96a27d..b13a660 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,12 @@ Application Builder is an open-source tool for you to create web applications  ![](https://user-images.githubusercontent.com/38696279/72333172-47cec300-36b3-11ea-9abf-1bb29b490a22.png) -## What's new? +## What's new ? +* **Deploy with Blueprint Forge:** Users can now clone Application Builder apps and deploy it to Blueprint Forge apps. + ![image](https://github.com/user-attachments/assets/a18fad95-93ce-4578-a10a-abedcf9decf9) +* **Widget Catalog:** The Widget catalog has been removed. You manage your widgets/plugins from Administration -> Ecosystem -> Extensions. + +## 2.1.0 * **New default branding:** Introducing our new default branding, Inspired by Delite 2.0 – Software AG's in-house design system. * **Enhanced group template dashboard:** Give an identical dashboard to every device/asset based on its type. @@ -26,7 +31,6 @@ Application Builder is an open-source tool for you to create web applications  * **Home Page:** User can find quick start videos, help and support information on home page. * **Tabs:** Group your dashboards into tabs. * **Dashboard Catalog:** User can select any pre-designed template for dashboard and ability to install dependent runtime widgets. -* **Widget Catalog:** Now user has ability to install/update widgets directly from Widget Catalog. This is single place where user can also find widget details such as documentation, preview, license and author details. * **Branding:** Now user can use color picker to choose millions of colors to customize branding. Header, Action bar and tab bar are also customizable. * **Theme:** Application builder now support one clicks theme selection and custom theme creation. * **Server-Side Simulators:** Application Builder now supports Server-side simulators. User just need to install micro-service from [here](https://github.com/SoftwareAG/cumulocity-app-builder/releases/download/v1.3.1/simulator-app-builder.zip) and you will get option while creating simulator to "Run on Server". diff --git a/builder/app-data.service.ts b/builder/app-data.service.ts index 5e0e333..cf94a5c 100644 --- a/builder/app-data.service.ts +++ b/builder/app-data.service.ts @@ -16,7 +16,7 @@ * limitations under the License. */ -import {from, Observable} from "rxjs"; +import {BehaviorSubject, from, Observable} from "rxjs"; import {Injectable} from "@angular/core"; import { ApplicationService, IApplication } from "@c8y/client"; @@ -29,6 +29,7 @@ export class AppDataService { private appId: string | number = ''; private lastUpdated = 0; forceUpdate = false; + refreshAppForDashboard = new BehaviorSubject(undefined); constructor(private appService: ApplicationService) { } diff --git a/builder/app-list/app-list.component.html b/builder/app-list/app-list.component.html index c3f1426..c2f3a02 100644 --- a/builder/app-list/app-list.component.html +++ b/builder/app-list/app-list.component.html @@ -29,6 +29,11 @@

No application to list.

Remove +
  • + +
  • @@ -36,8 +41,16 @@

    No application to list.

    {{app.name}}

    +
    + + +
    - + Open
    diff --git a/builder/app-list/app-list.component.ts b/builder/app-list/app-list.component.ts index a80d887..af6b457 100644 --- a/builder/app-list/app-list.component.ts +++ b/builder/app-list/app-list.component.ts @@ -33,6 +33,7 @@ import { NewApplicationModalComponent } from "./new-application-modal.component" import { Router } from "@angular/router"; import { contextPathFromURL } from "../utils/contextPathFromURL"; import { AppListService } from "./app-list.service"; +import { NewBlueprintForgeModalComponent } from "./new-blueprint-forge-app-modal.component"; @Component({ templateUrl: './app-list.component.html' @@ -106,6 +107,13 @@ export class AppListComponent { }); } + deployWithBlueprintForge(app: IApplication) { + this.bsModalRef = this.modalService.show(NewBlueprintForgeModalComponent, { + class: 'c8y-wizard', initialState: + { application: app, allApplications: this.allApplications } + }); + } + async deleteApplication(id: number) { await this.appService.delete(id); @@ -121,7 +129,13 @@ export class AppListComponent { this.router.navigateByUrl(`/application/${app.id}${subPath || ''}`); } } - exportApp(app: IApplication) { + + isBlueprintApp(app: IApplication) { + return (app && app.manifest?.package === 'blueprint'); + } + + // TODO: not used. Alternative available in migration tool + /* exportApp(app: IApplication) { const filename = app.name + '.json'; const jsonStr = JSON.stringify(app.applicationBuilder); let element = document.createElement('a'); @@ -131,5 +145,5 @@ export class AppListComponent { document.body.appendChild(element); element.click(); document.body.removeChild(element); - } + } */ } diff --git a/builder/app-list/app-list.module.ts b/builder/app-list/app-list.module.ts index 3c2c7fb..66ca777 100644 --- a/builder/app-list/app-list.module.ts +++ b/builder/app-list/app-list.module.ts @@ -30,6 +30,7 @@ import {IconSelectorModule} from "../../icon-selector/icon-selector.module"; import {ApplicationService, IApplication, UserService} from "@c8y/client"; import { filter, first } from "rxjs/operators"; import {contextPathFromURL} from "../utils/contextPathFromURL"; +import { NewBlueprintForgeModalComponent } from "./new-blueprint-forge-app-modal.component"; /** * Some app-builder applications hide the ability to create new applications, they do this by having a default application that is redirected to if the user tries to access the '/' path. @@ -80,10 +81,12 @@ export class RedirectToDefaultApplicationOrBuilder implements CanActivate { ], declarations: [ AppListComponent, - NewApplicationModalComponent + NewApplicationModalComponent, + NewBlueprintForgeModalComponent ], entryComponents: [ - NewApplicationModalComponent + NewApplicationModalComponent, + NewBlueprintForgeModalComponent ], providers: [ { provide: HOOK_NAVIGATOR_NODES, useClass: AppListNavigation, multi: true} diff --git a/builder/app-list/app-list.navigation.ts b/builder/app-list/app-list.navigation.ts index 5b089c0..611bc22 100644 --- a/builder/app-list/app-list.navigation.ts +++ b/builder/app-list/app-list.navigation.ts @@ -63,14 +63,15 @@ export class AppListNavigation implements NavigatorNodeFactory { path: `/settings-properties`, priority: 0 })); - const widgetCatalogNode = new NavigatorNode({ + // TODO: remove widget catalog + /* const widgetCatalogNode = new NavigatorNode({ label: 'Widget Catalog', icon: 'registry-editor', path: `/widget-catalog/my-widgets`, priority: 2 - }); + }); */ if (this.userService.hasAllRoles(this.appStateService.currentUser.value, ["ROLE_INVENTORY_ADMIN","ROLE_APPLICATION_MANAGEMENT_ADMIN"])) { - appNode.push(widgetCatalogNode); + // appNode.push(widgetCatalogNode); appNode.push(settingsNode); } return appNode; diff --git a/builder/app-list/new-blueprint-forge-app-modal.component.ts b/builder/app-list/new-blueprint-forge-app-modal.component.ts new file mode 100644 index 0000000..ae51077 --- /dev/null +++ b/builder/app-list/new-blueprint-forge-app-modal.component.ts @@ -0,0 +1,265 @@ +/* +* Copyright (c) 2024 Software AG, Darmstadt, Germany and/or its licensors +* +* SPDX-License-Identifier: Apache-2.0 +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. + */ + +import { Component, isDevMode, OnInit } from '@angular/core'; +import { BsModalRef } from 'ngx-bootstrap/modal'; +import { ApplicationService, ApplicationAvailability, ApplicationType, FetchClient, InventoryService, IApplication, IManagedObject, IManifest } from '@c8y/client'; +import { AlertService, AppStateService, PluginsService } from "@c8y/ngx-components"; +import { UpdateableAlert } from "../utils/UpdateableAlert"; +import { contextPathFromURL } from "../utils/contextPathFromURL"; +import { Observable } from 'rxjs'; +import { SettingsService } from '../settings/settings.service'; +import { AppListService } from './app-list.service'; +import { cloneDeep, omit } from 'lodash-es'; + +@Component({ + template: ` + + + + ` +}) + +export class NewBlueprintForgeModalComponent implements OnInit { + appName: string = ''; + appPath: string = ''; + appIcon: string = 'bathtub'; + application: IApplication; + allApplications: IApplication[]; + appList: any = []; + fileData: any; + isImportApp: boolean; + + constructor(public bsModalRef: BsModalRef, private appService: ApplicationService, private appStateService: AppStateService, + private fetchClient: FetchClient, private inventoryService: InventoryService, private alertService: AlertService, + private settingsService: SettingsService, private appListService: AppListService, + private pluginsService: PluginsService) { } + + ngOnInit() { + this.appIcon = this.application?.applicationBuilder?.icon; + } + + validateAppName(newBlueprintForgeAppForm) { + const appFound = this.allApplications.find(app => app.name.toLowerCase() === this.appName.toLowerCase() || + (this.appPath && this.appPath.length > 0 && (app.contextPath && app.contextPath?.toLowerCase() === this.appPath.toLowerCase()))) + if (appFound) { + newBlueprintForgeAppForm.form.setErrors({ 'invalid': true }); + this.alertService.danger(" Application name or context path already exists!"); + return; + } + } + + async deployApplication() { + + // app validation check + const appFound = this.allApplications.find(app => app.name.toLowerCase() === this.appName.toLowerCase() || + (this.appPath && this.appPath.length > 0 && (app.contextPath && app.contextPath?.toLowerCase() === this.appPath.toLowerCase()))) + if (appFound) { + this.alertService.danger("Application name or context path already exists!"); + return; + } + /* if (isDevMode()) { + this.alertService.danger("This functionality is not supported in Development Mode. Please deploy the Application Builder and try again."); + return; + } + const currentHost = window.location.host.split(':')[0]; + if (currentHost === 'localhost' || currentHost === '127.0.0.1') { + this.alertService.warning("This functionality is not supported on localhost. Please deploy Application Builder in your tenant and try again."); + return; + } */ + this.bsModalRef.hide(); + let blueprintFrogePackage = null; + let packageCloneRequired = false; + const compareContextPath = (this.application.contextPath ? this.application.contextPath : this.currentContextPath()); + const currentApp = this.allApplications.find(app => (app.contextPath === compareContextPath && (app.availability === ApplicationAvailability.PRIVATE || + (app.owner && app.owner.tenant && this.settingsService.getTenantName() === app.owner.tenant.id)))); + blueprintFrogePackage = this.allApplications.find(app => (app.contextPath === 'sag-ps-pkg-blueprint-forge' && (app.availability == ApplicationAvailability.PRIVATE || + (app.owner && app.owner.tenant && this.settingsService.getTenantName() === app.owner.tenant.id)))); + if (!blueprintFrogePackage) { + const packageList = await this.pluginsService.listPackages(); + blueprintFrogePackage = packageList.find((pkg: IApplication) => pkg.contextPath === 'sag-ps-pkg-blueprint-forge'); + packageCloneRequired = true; + } + if (blueprintFrogePackage) { + const { id, type, availability } = blueprintFrogePackage; + const manifest = await this.appService.getAppManifest(blueprintFrogePackage); + const newManifest = omit(manifest, ['name', 'contextPath', 'key']); + const config: any = { + id, type, availability, + name: this.appName, + applicationBuilder: this.application.applicationBuilder, + key: `blueprint-forge-${this.appPath}-app-key`, + contextPath: this.appPath + } + config.isSetup = false; + config.manifest = newManifest; + config.availability = ApplicationAvailability.PRIVATE; + config.manifest.isPackage = false; + config.manifest.source = blueprintFrogePackage.id; + config.manifest.package = 'blueprint'; + config.manifest.icon = this.appIcon; + config.applicationBuilder.icon= this.appIcon; + config.icon = { + name: this.appIcon, + "class": `fa fa-${this.appIcon}` + }; + if(currentApp) { + config.config = currentApp.config; + } + + let clonedPackageData = null; + let binaryId = null; + if (packageCloneRequired) { + clonedPackageData = (await this.appService.clone(blueprintFrogePackage)).data; + binaryId = clonedPackageData.activeVersionId; + } else { + binaryId = blueprintFrogePackage.activeVersionId; + } + const { data: binaryData } = await this.inventoryService.detail(binaryId); + + const creationAlert = new UpdateableAlert(this.alertService); + + creationAlert.update('Deploying application...'); + + try { + // Download the binary + creationAlert.update(`Deploying application...\nDownloading...`); + const binary = await this.downloadBinary(clonedPackageData || blueprintFrogePackage, binaryId); + + // Preparing Zip + const blob = new Blob([binary], { type: binaryData.contentType }); + + // Create the app + let app = (await this.appService.create(config)).data; + + // Upload the binary + creationAlert.update(`Deploying application...\nUploading...`); + const fd = new FormData(); + fd.append('file', blob, binaryData.name); + const activeVersionId = (await (await this.fetchClient.fetch(`/application/applications/${app.id}/binaries`, { + method: 'POST', + body: fd, + headers: { + Accept: 'application/json' + } + })).json()).id; + + // Update the app + creationAlert.update(`Deploying application...\nSaving...`); + app = (await this.appService.update({ + id: app.id, + activeVersionId, + })).data; + + const tempCurrentApp = cloneDeep(app); + const removeProperties = ['id', 'owner', 'activeVersionId', 'self', 'type']; + removeProperties.forEach(prop => delete tempCurrentApp[prop]); + let manifest: Partial = (clonedPackageData ? clonedPackageData.manifest : blueprintFrogePackage.manifest); + // update manifest + + tempCurrentApp.manifest = manifest; + tempCurrentApp.manifest.isPackage = false; + tempCurrentApp.manifest.source = (clonedPackageData ? clonedPackageData.id : blueprintFrogePackage.id); + tempCurrentApp.manifest.icon = this.appIcon; + + await this.appService.binary(app.id) + .updateFiles([{ path: 'cumulocity.json', contents: JSON.stringify(tempCurrentApp) as any }]); + + // deleting cloned app + if (packageCloneRequired) { + await this.fetchClient.fetch(`application/applications/${clonedPackageData.id}`, { method: 'DELETE' }) as Response; + } + creationAlert.update(`Application Created!`, "success"); + creationAlert.close(2000); + // Track app creation if gainsight is configured + if (window && window['aptrinsic']) { + window['aptrinsic']('track', 'gp_appbuilder_createapp_clicked', { + "appName": this.appName, + "appId": app.id, + "tenantId": this.settingsService.getTenantName() + }); + } + // Refresh the applications list + this.appStateService.currentUser.next(this.appStateService.currentUser.value); + this.appListService.RefreshAppList(); + } catch (e) { + creationAlert.update('Failed to deploy application.\nCheck the browser console for more information', 'danger'); + throw e; + } + } else { + this.alertService.danger("The Blueprint Forge extension is not installed. Please install it and try again.!"); + return; + } + } + + currentContextPath(): string { + return contextPathFromURL(); + } + + async downloadBinary(app: IApplication, binaryId: string | number): Promise { + let binary; + try { + const res = await this.appService.binary(app).downloadArchive(binaryId); + binary = await res.arrayBuffer(); + } catch (ex) { + this.alertService.danger("Unable to download binary. Please try after sometime. If problem persists, please contact the administrator."); + throw Error('Could not get binary'); + } + return binary; + } +} diff --git a/builder/application-config/dashboard-config.component.ts b/builder/application-config/dashboard-config.component.ts index 1fc297b..2edc883 100644 --- a/builder/application-config/dashboard-config.component.ts +++ b/builder/application-config/dashboard-config.component.ts @@ -86,7 +86,6 @@ export class DashboardConfigComponent implements OnInit, OnDestroy { filterValueForTree = ''; app: Observable; - refreshApp = new BehaviorSubject(undefined);; delayedAppUpdateSubject = new Subject(); delayedAppUpdateSubscription: Subscription; @@ -115,7 +114,7 @@ export class DashboardConfigComponent implements OnInit, OnDestroy { private accessRightsService: AccessRightsService, private userService: UserService, private appDataService: AppDataService, @Inject(DOCUMENT) private document: Document, private renderer: Renderer2, private cd: ChangeDetectorRef, private clipboard: Clipboard ) { - this.app = combineLatest([appIdService.appIdDelayedUntilAfterLogin$, this.refreshApp]).pipe( + this.app = combineLatest([appIdService.appIdDelayedUntilAfterLogin$, this.appDataService.refreshAppForDashboard]).pipe( map(([appId]) => appId), tap(appId => { this.appDataService.forceUpdate = true; @@ -137,7 +136,7 @@ export class DashboardConfigComponent implements OnInit, OnDestroy { this.appDataService.forceUpdate = true; } await this.appService.update(app); - this.refreshApp.next(); + this.appDataService.refreshAppForDashboard.next(); this.navigation.refresh(); // TODO? //this.tabs.refresh(); @@ -347,7 +346,7 @@ export class DashboardConfigComponent implements OnInit, OnDestroy { if (isReloadRequired) { let count = 0; this.autoLockDashboard = true; - this.refreshApp.next(); + this.appDataService.refreshAppForDashboard.next(); this.prepareDashboardHierarchy(this.bsModalRef.content.app); this.filteredDashboardList = [...this.bsModalRef.content.app.applicationBuilder.dashboards]; this.bsModalRef.content.app.applicationBuilder.dashboards.forEach(async (element) => { diff --git a/builder/template-catalog/template-catalog.service.ts b/builder/template-catalog/template-catalog.service.ts index 6f8d441..9e92f3e 100644 --- a/builder/template-catalog/template-catalog.service.ts +++ b/builder/template-catalog/template-catalog.service.ts @@ -30,6 +30,7 @@ import { AppBuilderExternalAssetsService } from 'app-builder-external-assets'; import { DashboardConfig } from "builder/application-config/dashboard-config.component"; import { SettingsService } from "../settings/settings.service"; import { AppIdService } from "../app-id.service"; +import { AppDataService } from "./../../builder/app-data.service"; const packageJson = require('./../../package.json'); @Injectable() @@ -49,7 +50,7 @@ export class TemplateCatalogService { constructor(private http: HttpClient, private inventoryService: InventoryService, private appService: ApplicationService, private navigation: AppBuilderNavigationService, - private binaryService: InventoryBinaryService, private alertService: AlertService, + private binaryService: InventoryBinaryService, private alertService: AlertService,private appDataService: AppDataService, private client: FetchClient, private appIdService: AppIdService, private externalService: AppBuilderExternalAssetsService, private settingsService: SettingsService) { this.GATEWAY_URL_GitHubAPI = this.externalService.getURL('GITHUB','gatewayURL_Github'); @@ -189,7 +190,7 @@ export class TemplateCatalogService { templateDeviceId: "NO_DEVICE_TEMPLATE_ID" } } : {}) - }).then(({ data }) => { + }).then(async ({ data }) => { application.applicationBuilder.dashboards = [ ...application.applicationBuilder.dashboards || [], { @@ -220,13 +221,14 @@ export class TemplateCatalogService { "dashboardName": templateCatalogEntry.title }); } - return this.appService.update({ + await this.appService.update({ id: application.id, applicationBuilder: application.applicationBuilder } as any); - }).then(() => { + this.appDataService.forceUpdate = true; + this.appDataService.refreshAppForDashboard.next(); this.navigation.refresh(); - }); + }) } async updateDashboard(application, dashboardConfig: DashboardConfig, templateDetails: TemplateDetails, index: number, isGroupTemplate: boolean = false) { @@ -273,7 +275,8 @@ export class TemplateCatalogService { id: application.id, applicationBuilder: application.applicationBuilder } as any); - + this.appDataService.forceUpdate = true; + this.appDataService.refreshAppForDashboard.next(); this.navigation.refresh(); } diff --git a/package.json b/package.json index 287dcfb..f957daf 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "app-builder", - "version": "2.1.1", + "version": "2.2.0", "description": "Application builder for Cumulocity (written by Software AG Global Presales)", "main": "index.ts", "scripts": { - "start": "c8ycli server --env.extraWebpackConfig=./extra-webpack.config.js", + "start": "c8ycli server --env.extraWebpackConfig=./extra-webpack.config.js ", "build": "set NODE_OPTIONS=--max_old_space_size=4096 && c8ycli build --env.extraWebpackConfig=./extra-webpack.config.js", "build-dev": "set NODE_OPTIONS=--max_old_space_size=4096 && c8ycli build --env.extraWebpackConfig=./extra-webpack.config.js --env.mode=development", "deploy-ci": "c8ycli deploy -u $npm_config_param1 -T $npm_config_param2 -U $npm_config_param3 -P $npm_config_param4", @@ -83,7 +83,7 @@ "upgrade": true, "rightDrawer": true, "sensorAppOneLink": "http://onelink.to/pca6qe", - "version": "2.1.1", + "version": "2.2.0", "contentSecurityPolicy": "base-uri 'none'; default-src 'self' 'unsafe-inline' http: https: ws: wss: blob:; connect-src 'self' *.webmethodscloud.com *.aptrinsic.com *.billwerk.com http: https: ws: wss: blob:; script-src 'self' open.mapquestapi.com *.twitter.com *.twimg.com *.aptrinsic.com 'unsafe-inline' 'unsafe-eval' data:; style-src * 'unsafe-inline' blob:; img-src * data: blob:; font-src * data:; frame-src *; worker-src 'self' blob:;", "icon": { "class": "fa fa-magic"