NIFIREG-395 - Implemented the ability to import and export versioned flows through the UI. (#5107)

- Added REST endpoints in BucketFlowResource for importVersionedFlow and exportVersionedFlow.
- Added import and export dialogs.
- Set the initial value for the Bucket dropdown menu in the Import dialogs.
- Added frontend and backend integration tests.
- Created ExportedVersionedFlowSnapshot object.
- Added test resource file and updated rat configuration to skip the file during contrib-check.
- Refactored frontend and server side code.
- Updated Flow Actions menu hover and focus styles.
This commit is contained in:
M Tien 2021-06-09 08:26:04 -07:00 committed by GitHub
parent 8641b54a49
commit 69c10f5a69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 2356 additions and 81 deletions

View File

@ -247,6 +247,15 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.rat</groupId>
<artifactId>apache-rat-plugin</artifactId>
<configuration>
<excludes combine.children="append">
<exclude>src/test/resources/test-versioned-flow-snapshot.json</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>

View File

@ -24,11 +24,15 @@ import io.swagger.annotations.ApiResponses;
import io.swagger.annotations.Authorization;
import io.swagger.annotations.Extension;
import io.swagger.annotations.ExtensionProperty;
import java.net.URI;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.core.HttpHeaders;
import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.registry.bucket.BucketItem;
import org.apache.nifi.registry.diff.VersionedFlowDifference;
import org.apache.nifi.registry.event.EventFactory;
import org.apache.nifi.registry.event.EventService;
import org.apache.nifi.registry.web.service.ExportedVersionedFlowSnapshot;
import org.apache.nifi.registry.flow.VersionedFlow;
import org.apache.nifi.registry.flow.VersionedFlowSnapshot;
import org.apache.nifi.registry.flow.VersionedFlowSnapshotMetadata;
@ -291,6 +295,44 @@ public class BucketFlowResource extends ApplicationResource {
return Response.status(Response.Status.OK).entity(createdSnapshot).build();
}
@POST
@Path("{flowId}/versions/import")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@ApiOperation(
value = "Import flow version",
notes = "Import the next version of a flow. The version number of the object being created will be the " +
"next available version integer. Flow versions are immutable after they are created.",
response = VersionedFlowSnapshot.class,
extensions = {
@Extension(name = "access-policy", properties = {
@ExtensionProperty(name = "action", value = "write"),
@ExtensionProperty(name = "resource", value = "/buckets/{bucketId}") })
}
)
@ApiResponses({
@ApiResponse(code = 201, message = HttpStatusMessages.MESSAGE_201),
@ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
@ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
@ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
@ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
@ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) })
public Response importVersionedFlow(
@PathParam("bucketId")
@ApiParam("The bucket identifier")
final String bucketId,
@PathParam("flowId")
@ApiParam(value = "The flow identifier")
final String flowId,
@ApiParam("file") final VersionedFlowSnapshot versionedFlowSnapshot,
@HeaderParam("Comments") final String comments) {
final VersionedFlowSnapshot createdSnapshot = serviceFacade.importVersionedFlowSnapshot(versionedFlowSnapshot, bucketId, flowId, comments);
publish(EventFactory.flowVersionCreated(createdSnapshot));
String locationUri = createdSnapshot.getSnapshotMetadata().getLink().getUri().getPath();
return generateCreatedResponse(URI.create(locationUri), createdSnapshot).build();
}
@GET
@Path("{flowId}/versions")
@Consumes(MediaType.WILDCARD)
@ -385,6 +427,47 @@ public class BucketFlowResource extends ApplicationResource {
return Response.status(Response.Status.OK).entity(latest).build();
}
@GET
@Path("{flowId}/versions/{versionNumber: \\d+}/export")
@Consumes(MediaType.WILDCARD)
@Produces(MediaType.APPLICATION_JSON)
@ApiOperation(
value = "Exports specified bucket flow version content",
notes = "Exports the specified version of a flow, including the metadata and content of the flow.",
response = VersionedFlowSnapshot.class,
extensions = {
@Extension(name = "access-policy", properties = {
@ExtensionProperty(name = "action", value = "read"),
@ExtensionProperty(name = "resource", value = "/buckets/{bucketId}")})
}
)
@ApiResponses({
@ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
@ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
@ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
@ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409)})
public Response exportVersionedFlow(
@PathParam("bucketId")
@ApiParam("The bucket identifier") final String bucketId,
@PathParam("flowId")
@ApiParam("The flow identifier") final String flowId,
@PathParam("versionNumber")
@ApiParam("The version number") final Integer versionNumber) {
final ExportedVersionedFlowSnapshot exportedVersionedFlowSnapshot = serviceFacade.exportFlowSnapshot(bucketId, flowId, versionNumber);
final VersionedFlowSnapshot versionedFlowSnapshot = exportedVersionedFlowSnapshot.getVersionedFlowSnapshot();
final String contentDisposition = String.format(
"attachment; filename=\"%s\"",
exportedVersionedFlowSnapshot.getFilename());
return generateOkResponse(versionedFlowSnapshot)
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
.header("Filename", exportedVersionedFlowSnapshot.getFilename())
.build();
}
@GET
@Path("{flowId}/versions/{versionNumber: \\d+}")
@Consumes(MediaType.WILDCARD)

View File

@ -18,6 +18,9 @@ package org.apache.nifi.registry.web.api;
class HttpStatusMessages {
/* 2xx messages */
static final String MESSAGE_201 = "The resource has been successfully created.";
/* 4xx messages */
static final String MESSAGE_400 = "NiFi Registry was unable to complete the request because it was invalid. The request should not be retried without modification.";
static final String MESSAGE_401 = "Client could not be authenticated.";

View File

@ -0,0 +1,65 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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.
*/
package org.apache.nifi.registry.web.service;
import io.swagger.annotations.ApiModel;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import org.apache.nifi.registry.flow.VersionedFlowSnapshot;
/**
* <p>
* Represents a snapshot of a versioned flow and a filename for exporting the flow. A versioned flow may change many times
* over the course of its life. This flow is saved to the registry with information such as its name, a description,
* and each version of the flow.
* </p>
*
* @see VersionedFlowSnapshot
*/
@ApiModel
public class ExportedVersionedFlowSnapshot {
@Valid
@NotNull
private VersionedFlowSnapshot versionedFlowSnapshot;
@Valid
@NotNull
private String filename;
public ExportedVersionedFlowSnapshot(final VersionedFlowSnapshot versionedFlowSnapshot, final String filename) {
this.versionedFlowSnapshot = versionedFlowSnapshot;
this.filename = filename;
}
public void setVersionedFlowSnapshot(VersionedFlowSnapshot versionedFlowSnapshot) {
this.versionedFlowSnapshot = versionedFlowSnapshot;
}
public void setFilename(String filename) {
this.filename = filename;
}
public VersionedFlowSnapshot getVersionedFlowSnapshot() {
return versionedFlowSnapshot;
}
public String getFilename() {
return filename;
}
}

View File

@ -102,6 +102,10 @@ public interface ServiceFacade {
VersionedFlowSnapshot getLatestFlowSnapshot(String flowIdentifier);
VersionedFlowSnapshot importVersionedFlowSnapshot(VersionedFlowSnapshot versionedFlowSnapshot, String bucketIdentifier, String flowIdentifier, String comments);
ExportedVersionedFlowSnapshot exportFlowSnapshot(String bucketIdentifier, String flowIdentifier, Integer versionNumber);
SortedSet<VersionedFlowSnapshotMetadata> getFlowSnapshots(String bucketIdentifier, String flowIdentifier);
SortedSet<VersionedFlowSnapshotMetadata> getFlowSnapshots(String flowIdentifier);

View File

@ -109,6 +109,8 @@ public class StandardServiceFacade implements ServiceFacade {
private final PermissionsService permissionsService;
private final LinkService linkService;
private static final int LATEST_VERSION = -1;
@Autowired
public StandardServiceFacade(final RegistryService registryService,
final ExtensionService extensionService,
@ -360,6 +362,48 @@ public class StandardServiceFacade implements ServiceFacade {
return lastSnapshot;
}
@Override
public VersionedFlowSnapshot importVersionedFlowSnapshot(VersionedFlowSnapshot versionedFlowSnapshot, String bucketIdentifier,
String flowIdentifier, String comments) {
// set new snapshotMetadata
final VersionedFlowSnapshotMetadata metadata = new VersionedFlowSnapshotMetadata();
metadata.setBucketIdentifier(bucketIdentifier);
metadata.setFlowIdentifier(flowIdentifier);
metadata.setVersion(LATEST_VERSION);
// if there are new comments, then set it
// otherwise, keep the original comments
if (StringUtils.isNotBlank(comments)) {
metadata.setComments(comments);
} else if (versionedFlowSnapshot.getSnapshotMetadata() != null && StringUtils.isNotBlank(versionedFlowSnapshot.getSnapshotMetadata().getComments())) {
metadata.setComments(versionedFlowSnapshot.getSnapshotMetadata().getComments());
}
versionedFlowSnapshot.setSnapshotMetadata(metadata);
final String userIdentity = NiFiUserUtils.getNiFiUserIdentity();
versionedFlowSnapshot.getSnapshotMetadata().setAuthor(userIdentity);
return createFlowSnapshot(versionedFlowSnapshot);
}
@Override
public ExportedVersionedFlowSnapshot exportFlowSnapshot(String bucketIdentifier, String flowIdentifier, Integer versionNumber) {
final VersionedFlowSnapshot versionedFlowSnapshot = getFlowSnapshot(bucketIdentifier, flowIdentifier, versionNumber);
String flowName = versionedFlowSnapshot.getFlow().getName();
final String dashFlowName = flowName.replaceAll("\\s", "-");
final String filename = String.format("%s-version-%d.json", dashFlowName, versionedFlowSnapshot.getSnapshotMetadata().getVersion());
versionedFlowSnapshot.setFlow(null);
versionedFlowSnapshot.setBucket(null);
versionedFlowSnapshot.getSnapshotMetadata().setBucketIdentifier(null);
versionedFlowSnapshot.getSnapshotMetadata().setFlowIdentifier(null);
versionedFlowSnapshot.getSnapshotMetadata().setLink(null);
return new ExportedVersionedFlowSnapshot(versionedFlowSnapshot, filename);
}
@Override
public SortedSet<VersionedFlowSnapshotMetadata> getFlowSnapshots(final String bucketIdentifier, final String flowIdentifier) {
authorizeBucketAccess(RequestAction.READ, bucketIdentifier);

View File

@ -16,6 +16,7 @@
*/
package org.apache.nifi.registry.web.api;
import java.io.File;
import org.apache.nifi.registry.bucket.BucketItemType;
import org.apache.nifi.registry.flow.VersionedFlow;
import org.apache.nifi.registry.flow.VersionedFlowSnapshot;
@ -34,16 +35,20 @@ import javax.ws.rs.client.WebTarget;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import static org.apache.nifi.registry.web.api.IntegrationTestUtils.assertBucketsEqual;
import static org.apache.nifi.registry.web.api.IntegrationTestUtils.assertFlowSnapshotMetadataEqual;
import static org.apache.nifi.registry.web.api.IntegrationTestUtils.assertFlowSnapshotsEqual;
import static org.apache.nifi.registry.web.api.IntegrationTestUtils.assertFlowsEqual;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
@Sql(executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, scripts = {"classpath:db/clearDB.sql", "classpath:db/FlowsIT.sql"})
public class FlowsIT extends UnsecuredITBase {
private static final int LATEST_VERSION = -1;
@Test
public void testGetFlowsEmpty() throws Exception {
@ -541,4 +546,171 @@ public class FlowsIT extends UnsecuredITBase {
}
@Test
public void testImportVersionedFlowSnapshot() {
final RevisionInfo initialRevision = new RevisionInfo("FlowsIT", 0L);
// Create a versionedFlowSnapshot to export
// Given: an empty Bucket "3" (see FlowsIT.sql) with a newly created flow
final String bucketId = "3";
final VersionedFlow flow = new VersionedFlow();
flow.setBucketIdentifier(bucketId);
flow.setName("Test Flow for creating snapshots");
flow.setDescription("This is a randomly named flow created by an integration test for the purpose of holding snapshots.");
flow.setRevision(initialRevision);
final VersionedFlow createdFlow = client
.target(createURL("buckets/{bucketId}/flows"))
.resolveTemplate("bucketId", bucketId)
.request()
.post(Entity.entity(flow, MediaType.APPLICATION_JSON), VersionedFlow.class);
final String flowId = createdFlow.getIdentifier();
// Create snapshotMetadata
final VersionedFlowSnapshotMetadata flowSnapshotMetadata = new VersionedFlowSnapshotMetadata();
flowSnapshotMetadata.setBucketIdentifier(bucketId);
flowSnapshotMetadata.setFlowIdentifier(flowId);
flowSnapshotMetadata.setComments("This is a snapshot created by an integration test.");
// Create a VersionedFlowSnapshot
final VersionedFlowSnapshot flowSnapshot = new VersionedFlowSnapshot();
flowSnapshot.setSnapshotMetadata(flowSnapshotMetadata);
flowSnapshot.setFlowContents(new VersionedProcessGroup()); // an empty root process group
flowSnapshot.getFlowContents().setName("Test Flow name");
flowSnapshot.getSnapshotMetadata().setVersion(LATEST_VERSION);
final VersionedFlowSnapshot createdFlowSnapshot = client
.target(createURL("buckets/{bucketId}/flows/{flowId}/versions"))
.resolveTemplate("bucketId", bucketId)
.resolveTemplate("flowId", flowId)
.request()
.post(Entity.entity(flowSnapshot, MediaType.APPLICATION_JSON), VersionedFlowSnapshot.class);
assertNotNull(createdFlowSnapshot.getFlow());
final VersionedFlowSnapshot importedFlowSnapshot = client
.target(createURL("buckets/{bucketId}/flows/{flowId}/versions/import"))
.resolveTemplate("bucketId", bucketId)
.resolveTemplate("flowId", flowId)
.request()
.post(Entity.entity(createdFlowSnapshot, MediaType.APPLICATION_JSON), VersionedFlowSnapshot.class);
assertNotNull(importedFlowSnapshot);
assertEquals(bucketId, importedFlowSnapshot.getSnapshotMetadata().getBucketIdentifier());
assertEquals(flowId, importedFlowSnapshot.getSnapshotMetadata().getFlowIdentifier());
assertEquals(2, importedFlowSnapshot.getSnapshotMetadata().getVersion());
// =========== Import a Versioned Flow Snapshot ===========
// GET the versioned Flow that was just imported
final VersionedFlowSnapshotMetadata[] versionedFlowSnapshots = client
.target(createURL("buckets/{bucketId}/flows/{flowId}/versions"))
.resolveTemplate("bucketId", bucketId)
.resolveTemplate("flowId", flowId)
.request().get(VersionedFlowSnapshotMetadata[].class);
assertNotNull(versionedFlowSnapshots);
assertEquals(2, versionedFlowSnapshots.length);
assertFlowSnapshotMetadataEqual(importedFlowSnapshot.getSnapshotMetadata(), versionedFlowSnapshots[0], true);
// GET the imported versionedFlowSnapshot by link
final VersionedFlowSnapshot importedFlowSnapshotByLink = client
.target(createURL(versionedFlowSnapshots[0].getLink().getUri().toString()))
.request()
.get(VersionedFlowSnapshot.class);
assertFlowSnapshotsEqual(importedFlowSnapshot, importedFlowSnapshotByLink, true);
// =========== Import another version ===========
final File testSnapshotFile = new File("src/test/resources/test-versioned-flow-snapshot.json");
// Imported Flow id = 2
final String importedFlowId = importedFlowSnapshot.getSnapshotMetadata().getFlowIdentifier();
// Imported Bucket id = 3
final String importedBucketId = importedFlowSnapshot.getSnapshotMetadata().getBucketIdentifier();
WebTarget clientRequestTarget = client
.target(createURL("buckets/{bucketId}/flows/{flowId}/versions/import"))
.resolveTemplate("bucketId", importedBucketId)
.resolveTemplate("flowId", importedFlowId);
final VersionedFlowSnapshot nextImportedFlowSnapshot = clientRequestTarget
.request(MediaType.APPLICATION_JSON)
.header("content-type", MediaType.APPLICATION_JSON)
.header("comments", "This is a test version")
.post(Entity.entity(testSnapshotFile, MediaType.APPLICATION_JSON), VersionedFlowSnapshot.class);
assertNotNull(nextImportedFlowSnapshot);
assertBucketsEqual(importedFlowSnapshot.getBucket(), nextImportedFlowSnapshot.getBucket(), true);
assertEquals(importedFlowId, nextImportedFlowSnapshot.getSnapshotMetadata().getFlowIdentifier());
assertEquals(3, nextImportedFlowSnapshot.getSnapshotMetadata().getVersion());
}
@Test
public void testExportVersionedFlowSnapshot() {
final RevisionInfo initialRevision = new RevisionInfo("FlowsIT", 0L);
// Create a versionedFlowSnapshot to export
// Given: an empty Bucket "2" (see FlowsIT.sql) with a newly created flow
final String bucketId = "2";
final VersionedFlow flow = new VersionedFlow();
flow.setBucketIdentifier(bucketId);
flow.setName("Test Flow for creating snapshots");
flow.setDescription("This is a randomly named flow created by an integration test for the purpose of holding snapshots.");
flow.setRevision(initialRevision);
final VersionedFlow createdFlow = client
.target(createURL("buckets/{bucketId}/flows"))
.resolveTemplate("bucketId", bucketId)
.request()
.post(Entity.entity(flow, MediaType.APPLICATION_JSON), VersionedFlow.class);
final String flowId = createdFlow.getIdentifier();
// Create snapshotMetadata
final VersionedFlowSnapshotMetadata flowSnapshotMetadata = new VersionedFlowSnapshotMetadata();
flowSnapshotMetadata.setBucketIdentifier(bucketId);
flowSnapshotMetadata.setFlowIdentifier(flowId);
flowSnapshotMetadata.setComments("This is a snapshot created by an integration test.");
// Create a VersionedFlowSnapshot
final VersionedFlowSnapshot flowSnapshot = new VersionedFlowSnapshot();
flowSnapshot.setSnapshotMetadata(flowSnapshotMetadata);
flowSnapshot.setFlowContents(new VersionedProcessGroup()); // an empty root process group
flowSnapshot.getFlowContents().setName("Test Flow name");
flowSnapshot.getSnapshotMetadata().setVersion(LATEST_VERSION);
final VersionedFlowSnapshot createdFlowSnapshot = client
.target(createURL("buckets/{bucketId}/flows/{flowId}/versions"))
.resolveTemplate("bucketId", bucketId)
.resolveTemplate("flowId", flowId)
.request()
.post(Entity.entity(flowSnapshot, MediaType.APPLICATION_JSON), VersionedFlowSnapshot.class);
assertNotNull(createdFlowSnapshot.getFlow());
assertEquals(1, createdFlowSnapshot.getFlow().getVersionCount());
// Get the version number
final Integer testVersionNumber = createdFlowSnapshot.getSnapshotMetadata().getVersion();
// Test the exportVersionedFlow method with the version that was just created
final VersionedFlowSnapshot exportedVersionedFlowSnapshot = client
.target(createURL("buckets/{bucketId}/flows/{flowId}/versions/{versionNumber: \\d+}/export"))
.resolveTemplate("bucketId", bucketId)
.resolveTemplate("flowId", flowId)
.resolveTemplate("versionNumber", testVersionNumber)
.request()
.get(VersionedFlowSnapshot.class);
assertNotNull(exportedVersionedFlowSnapshot);
assertEquals(createdFlowSnapshot.getSnapshotMetadata().getVersion(),
exportedVersionedFlowSnapshot.getSnapshotMetadata().getVersion());
assertNull(exportedVersionedFlowSnapshot.getBucket());
assertNull(exportedVersionedFlowSnapshot.getFlow());
assertNull(exportedVersionedFlowSnapshot.getSnapshotMetadata().getFlowIdentifier());
assertNull(exportedVersionedFlowSnapshot.getSnapshotMetadata().getBucketIdentifier());
assertNull(exportedVersionedFlowSnapshot.getSnapshotMetadata().getLink());
}
}

View File

@ -0,0 +1,23 @@
{
"flowContents": {
"componentType": "PROCESS_GROUP",
"connections": [],
"controllerServices": [],
"funnels": [],
"identifier": "123",
"inputPorts": [],
"labels": [],
"name": "Test snapshot",
"outputPorts": [],
"processGroups": [],
"processors": [],
"remoteProcessGroups": [],
"variables": {}
},
"snapshotMetadata": {
"author": "anonymous",
"comments": "This is snapshot #5",
"timestamp": 1618078687616,
"version": 5
}
}

View File

@ -0,0 +1,53 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You 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.
-->
<div id="nifi-registry-export-versioned-flow-dialog">
<div class="pad-bottom-md" fxLayout="row" fxLayoutAlign="space-between center">
<mat-card-title>
Export Version
</mat-card-title>
<button mat-icon-button (click)="cancel()">
<mat-icon color="primary">close</mat-icon>
</button>
</div>
<div fxLayout="column" fxLayoutAlign="space-between start" class="pad-bottom-sm">
<div class="pad-bottom-sm label-name">
<label>Choose Version</label>
</div>
<div class="fill-available-width bucket-dropdown-field">
<mat-form-field appearance="fill" fxFlex>
<mat-select panelClass="bucket-dropdown-select" [(value)]="selectedVersion">
<mat-option *ngFor="let snapshotMeta of droplet.snapshotMetadata" [value]="snapshotMeta.version">
<span *ngIf="snapshotMeta === droplet.snapshotMetadata[0]">Latest (Version {{snapshotMeta.version}})</span>
<span *ngIf="snapshotMeta != droplet.snapshotMetadata[0]">Version {{snapshotMeta.version}}</span>
</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
<div fxLayout="row">
<span fxFlex></span>
<button (click)="cancel()" color="fds-regular" mat-raised-button
i18n="Cancel export of versioned flow selection|A button for cancelling the versioned flow selection to export in the registry.">
Cancel
</button>
<button class="push-left-sm" data-automation-id="export-versioned-flow-button" (click)="exportVersion()"
color="fds-primary" mat-raised-button i18n="Cancel export of versioned flow selection|A button for cancelling the versioned flow selection to export in the registry.">
Export
</button>
</div>
</div>

View File

@ -0,0 +1,96 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 } from '@angular/core';
import NfRegistryApi from 'services/nf-registry.api';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
import { FdsSnackBarService } from '@nifi-fds/core';
/**
* NfRegistryExportVersionedFlow constructor.
*
* @param nfRegistryApi The api service.
* @param fdsSnackBarService The FDS snack bar service module.
* @param matDialogRef The angular material dialog ref.
* @param data The data passed into this component.
* @constructor
*/
function NfRegistryExportVersionedFlow(nfRegistryApi, fdsSnackBarService, matDialogRef, data) {
// Services
this.snackBarService = fdsSnackBarService;
this.nfRegistryApi = nfRegistryApi;
this.dialogRef = matDialogRef;
// local state
this.keepDialogOpen = false;
this.protocol = location.protocol;
this.droplet = data.droplet;
this.selectedVersion = this.droplet.snapshotMetadata[0].version;
}
NfRegistryExportVersionedFlow.prototype = {
constructor: NfRegistryExportVersionedFlow,
/**
* Export specified versioned flow snapshot.
*/
exportVersion: function () {
var self = this;
var version = this.selectedVersion;
this.nfRegistryApi.exportDropletVersionedSnapshot(this.droplet.link.href, version).subscribe(function (response) {
if (!response.status || response.status === 200) {
self.snackBarService.openCoaster({
title: 'Success',
message: 'Exported flow.',
verticalPosition: 'bottom',
horizontalPosition: 'right',
icon: 'fa fa-check-circle-o',
color: '#1EB475',
duration: 3000
});
if (self.keepDialogOpen !== true) {
self.dialogRef.close();
}
} else {
self.dialogRef.close();
}
});
},
/**
* Cancel an export of a version and close dialog.
*/
cancel: function () {
this.dialogRef.close();
}
};
NfRegistryExportVersionedFlow.annotations = [
new Component({
templateUrl: './nf-registry-export-version.html'
})
];
NfRegistryExportVersionedFlow.parameters = [
NfRegistryApi,
FdsSnackBarService,
MatDialogRef,
MAT_DIALOG_DATA
];
export default NfRegistryExportVersionedFlow;

View File

@ -0,0 +1,141 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 NfRegistryApi from 'services/nf-registry.api';
import { of } from 'rxjs';
import NfRegistryExportVersionedFlow from 'components/explorer/grid-list/dialogs/export-versioned-flow/nf-registry-export-versioned-flow';
describe('NfRegistryExportVersionedFlow Component unit tests', function () {
var nfRegistryApi;
var comp;
beforeEach(function () {
nfRegistryApi = new NfRegistryApi();
var data = {
droplet: {
bucketIdentifier: '123',
bucketName: 'Bucket 1',
createdTimestamp: 1620177743648,
description: '',
identifier: '555',
link: {
href: 'buckets/123/flows/555',
params: {}
},
modifiedTimestamp: 1620177743687,
name: 'Test Flow',
permissions: {canDelete: true, canRead: true, canWrite: true},
revision: {version: 0},
snapshotMetadata: [
{
author: 'anonymous',
bucketIdentifier: '123',
comments: 'Test comments',
flowIdentifier: '555',
link: {
href: 'buckets/123/flows/555/versions/1',
params: {}
}
},
{
author: 'anonymous',
bucketIdentifier: '123',
comments: 'Test comments',
flowIdentifier: '999',
link: {
href: 'buckets/123/flows/999/versions/2',
params: {}
}
}
],
type: 'Flow',
versionCount: 2
}
};
comp = new NfRegistryExportVersionedFlow(nfRegistryApi, { openCoaster: function () {} }, { close: function () {} }, data);
var response = {
body: {
flowContents: {
componentType: 'PROCESS_GROUP',
connections: [],
controllerServices: [],
funnels: [],
identifier: '555',
inputPorts: [],
labels: [],
name: 'Test snapshot',
outputPorts: [],
processGroups: []
},
snapshotMetadata: {
author: 'anonymous',
bucketIdentifier: '123',
comments: 'Test comments',
flowIdentifier: '555',
link: {
href: 'buckets/123/flows/555/versions/2',
params: {}
},
version: 2
}
},
headers: {
headers: [
{'filename': ['Test-flow-version-1']}
]
},
ok: true,
status: 200,
statusText: 'OK',
type: 4,
url: 'testUrl'
};
// Spy
spyOn(nfRegistryApi, 'exportDropletVersionedSnapshot').and.callFake(function () {
}).and.returnValue(of(response));
spyOn(comp.dialogRef, 'close');
});
it('should create component', function () {
expect(comp).toBeDefined();
});
it('should export a versioned flow snapshot and close the dialog', function () {
spyOn(comp, 'exportVersion').and.callThrough();
// The function to test
comp.exportVersion();
//assertions
expect(comp).toBeDefined();
expect(comp.exportVersion).toHaveBeenCalled();
expect(comp.dialogRef.close).toHaveBeenCalled();
});
it('should cancel the export of a flow snapshot', function () {
// the function to test
comp.cancel();
//assertions
expect(comp.dialogRef.close).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,135 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You 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.
-->
<div id="nifi-registry-import-new-flow-dialog"
xmlns:width="http://www.w3.org/1999/xhtml">
<div class="pad-bottom-md" fxLayout="row" fxLayoutAlign="space-between center">
<mat-card-title class="ellipsis">
Import New Flow
</mat-card-title>
<button mat-icon-button (click)="cancel()">
<mat-icon color="primary">close</mat-icon>
</button>
</div>
<div id="new-flow-container">
<div class="fill-available-width">
<div class="pad-bottom-sm label-name">
<label>Flow Name</label>
</div>
<div id="flow-name" fxLayout="row" fxLayoutAlign="space-between center" class="push-bottom-md">
<div id="new-flow-name-input" class="fill-available-width">
<input
#flowName
id="new-flow-name-input-field"
class="pad-bottom-md fill-available-width"
matInput
type="text"
placeholder="Create a unique name"
[(ngModel)]="name"/>
</div>
<div class="version-count">
<span>v.1</span>
</div>
</div>
</div>
<div class="pad-bottom-sm label-name">
<label class="pad-bottom-sm">Flow Description</label>
</div>
<div id="new-flow-description" fxLayout="row" class="pad-bottom-xs fill-available-width">
<textarea class="push-bottom-md" [(ngModel)]="description"></textarea>
</div>
<div class="pad-bottom-sm label-name">
<label>Bucket</label>
</div>
<div class="fill-available-width bucket-dropdown-field pad-bottom-xs">
<mat-form-field appearance="fill" floatLabel="always" fxFlex>
<mat-select #bucketSelect panelClass="bucket-dropdown-select" placeholder="Choose a location" [(value)]="activeBucket">
<mat-option *ngFor="let bucket of writableBuckets" [value]="bucket.identifier">
{{bucket.name}}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div id="new-flow-file-upload-message-container" fxLayout="row" fxLayoutAlign="space-between center">
<div class="label-name">
<label>Flow Definition</label>
</div>
<ng-container *ngIf="fileToUpload != null && !hoverValidity || hoverValidity === 'valid'">
<span class="file-upload-message">
Looks good!
<i class="fa fa-check-circle-o" aria-hidden="true"
style="color: #1eb475;"></i>
</span>
</ng-container>
<ng-container *ngIf="hoverValidity === 'invalid'">
<span class="file-upload-message">
File format is not valid
<i class="fa fa-times-circle" aria-hidden="true"
style="color: #ef6162;"></i></span>
</ng-container>
</div>
<div id="new-flow-definition"
class="fill-available-width pad-bottom-sm"
fxLayout="row"
fxLayoutAlign="space-between center"
(click)="selectFile()"
(dragenter)="fileDragHandler($event, extensions)"
(dragover)="fileDragHandler($event, extensions)"
(dragend)="fileDragEndHandler()"
(dragleave)="fileDragEndHandler()"
(drop)="fileDropHandler($event)">
<mat-form-field floatLabel="never" flex>
<input matInput
id="new-flow-definition-input"
class="ellipsis"
type="text"
autocomplete="off"
placeholder="Drop file or select..."
[value]="fileName"
[ngClass]="{'file-hover-valid': (hoverValidity === 'valid'),
'file-hover-error': (hoverValidity === 'invalid'),
'file-selected': (fileToUpload != null), 'multiple': multiple}"/>
<div class="icon" id="select-flow-file-button">
<i class="fa fa-upload" aria-hidden="true"></i>
<span>Select file</span>
</div>
<div id="new-flow-file-upload-form-container">
<form id="new-flow-file-upload-form" enctype="multipart/form-data" method="post">
<input id="upload-flow-file-field"
type="file"
name="file"
[accept]="extensions"
(change)="handleFileInput($event.target.files)"/>
</form>
</div>
</mat-form-field>
</div>
</div>
</div>
<div fxLayout="row">
<span fxFlex></span>
<button (click)="cancel()" color="fds-regular" mat-raised-button
i18n="Cancel new flow definition import|A button for cancelling the new flow definition to import in the registry.">
Cancel
</button>
<button [disabled]="!fileToUpload || !flowName.value || !bucketSelect.value" class="push-left-sm" data-automation-id="import-new-flow-button"
(click)="importNewFlow()"
color="fds-primary" mat-raised-button
i18n="Cancel new flow definition import|A button for cancelling the new flow definition to import in the registry.">
Import
</button>
</div>

View File

@ -0,0 +1,191 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 } from '@angular/core';
import NfRegistryApi from 'services/nf-registry.api';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
import { FdsSnackBarService } from '@nifi-fds/core';
/**
* NfRegistryImportNewFlow constructor.
*
* @param nfRegistryApi The api service.
* @param fdsSnackBarService The FDS snack bar service module.
* @param matDialogRef The angular material dialog ref.
* @param data The data passed into this component.
* @constructor
*/
function NfRegistryImportNewFlow(nfRegistryApi, fdsSnackBarService, matDialogRef, data) {
// Services
this.snackBarService = fdsSnackBarService;
this.nfRegistryApi = nfRegistryApi;
this.dialogRef = matDialogRef;
// local state
this.keepDialogOpen = false;
this.buckets = data.buckets;
this.activeBucket = data.activeBucket.identifier;
this.writableBuckets = [];
this.fileToUpload = null;
this.fileName = null;
this.name = '';
this.description = '';
this.selectedBucket = {};
this.hoverValidity = '';
this.extensions = 'application/json';
this.multiple = false;
}
NfRegistryImportNewFlow.prototype = {
constructor: NfRegistryImportNewFlow,
ngOnInit: function () {
this.writableBuckets = this.filterWritableBuckets(this.buckets);
// if there's only 1 writable bucket, always set as the initial value in the bucket dropdown
// if opening the dialog from the explorer/grid-list, there is no active bucket
if (this.activeBucket === undefined) {
if (this.writableBuckets.length === 1) {
// set the active bucket
this.activeBucket = this.writableBuckets[0].identifier;
}
}
},
filterWritableBuckets: function (buckets) {
var self = this;
self.writableBuckets = this.writableBuckets;
buckets.forEach(function (b) {
if (b.permissions.canWrite) {
self.writableBuckets.push(b);
}
});
return self.writableBuckets;
},
fileDragHandler: function (event, extensions) {
event.preventDefault();
event.stopPropagation();
this.extensions = extensions;
var {items} = event.dataTransfer;
this.hoverValidity = this.isFileInvalid(items)
? 'invalid'
: 'valid';
},
fileDragEndHandler: function () {
this.hoverValidity = '';
},
fileDropHandler: function (event) {
event.preventDefault();
event.stopPropagation();
var { files } = event.dataTransfer;
if (!this.isFileInvalid(Array.from(files))) {
this.handleFileInput(files);
}
this.hoverValidity = '';
},
/**
* Handle the file input on change.
*/
handleFileInput: function (files) {
// get the file
this.fileToUpload = files[0];
// get the filename
var fileName = this.fileToUpload.name;
// trim off the file extension
this.fileName = fileName.replace(/\..*/, '');
},
/**
* Open the file selector.
*/
selectFile: function () {
document.getElementById('upload-flow-file-field').click();
},
/**
* Upload new data flow snapshot.
*/
importNewFlow: function () {
var self = this;
self.name = this.name;
self.description = this.description;
self.activeBucket = this.activeBucket;
self.selectedBucket = this.writableBuckets.find(function (b) {
return b.identifier === self.activeBucket;
});
this.nfRegistryApi.uploadFlow(self.selectedBucket.link.href, self.fileToUpload, self.name, self.description).subscribe(function (response) {
if (!response.status || response.status === 201) {
self.snackBarService.openCoaster({
title: 'Success',
message: 'Successfully imported ' + response.flow.name + ' to the ' + response.bucket.name + ' bucket.',
verticalPosition: 'bottom',
horizontalPosition: 'right',
icon: 'fa fa-check-circle-o',
color: '#1EB475',
duration: 3000
});
if (self.keepDialogOpen !== true) {
var uploadedFlowHref = response.flow.link.href;
self.dialogRef.close(uploadedFlowHref);
}
} else {
self.dialogRef.close();
}
});
},
isFileInvalid: function (items) {
return ((items.length > 1) || (this.extensions !== '' && (items[0].type === '')) || ((this.extensions.indexOf(items[0].type) === -1)));
},
/**
* Cancel uploading a new version and close dialog.
*/
cancel: function () {
this.dialogRef.close();
}
};
NfRegistryImportNewFlow.annotations = [
new Component({
templateUrl: './nf-registry-import-new-flow.html'
})
];
NfRegistryImportNewFlow.parameters = [
NfRegistryApi,
FdsSnackBarService,
MatDialogRef,
MAT_DIALOG_DATA
];
export default NfRegistryImportNewFlow;

View File

@ -0,0 +1,96 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 NfRegistryApi from 'services/nf-registry.api';
import NfRegistryImportNewFlow from 'components/explorer/grid-list/dialogs/import-new-flow/nf-registry-import-new-flow';
describe('NfRegistryImportNewFlow Component unit tests', function () {
var nfRegistryApi;
var data;
var comp;
var testFile;
beforeEach(function () {
nfRegistryApi = new NfRegistryApi();
testFile = new File([], 'filename.json');
data = {
activeBucket: {},
buckets: [
{
allowBundleRedeploy: false,
allowPublicRead: false,
createdTimestamp: 1620168925108,
identifier: '123',
link: {
href: 'buckets/123',
params: {}
},
name: 'Bucket 1',
permissions: {canDelete: true, canRead: true, canWrite: true},
revision: {version: 0}
},
{
allowBundleRedeploy: false,
allowPublicRead: false,
createdTimestamp: 1620168925108,
identifier: '456',
link: {
href: 'buckets/456',
params: {}
},
name: 'Bucket 2',
permissions: {canDelete: true, canRead: true, canWrite: false},
revision: {version: 0}
},
]
};
comp = new NfRegistryImportNewFlow(nfRegistryApi, { openCoaster: function () {} }, { close: function () {} }, data);
//Spy
spyOn(comp.dialogRef, 'close');
});
it('should create component', function () {
expect(comp).toBeDefined();
});
it('should cancel the import of a new version', function () {
// the function to test
comp.cancel();
//assertions
expect(comp.dialogRef.close).toHaveBeenCalled();
});
it('should assign writable and active buckets on init', function () {
comp.ngOnInit();
expect(comp.writableBuckets.length).toBe(1);
expect(comp.activeBucket).toBe(data.buckets[0].identifier);
});
it('should handle file input', function () {
var jsonFilename = 'filename.json';
// The function to test
comp.handleFileInput([testFile]);
//assertions
expect(comp.fileToUpload.name).toEqual(jsonFilename);
expect(comp.fileName).toEqual('filename');
});
});

View File

@ -0,0 +1,121 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You 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.
-->
<div id="nifi-registry-import-versioned-flow-dialog"
xmlns:width="http://www.w3.org/1999/xhtml">
<div class="pad-bottom-md" fxLayout="row" fxLayoutAlign="space-between center">
<mat-card-title>
Import New Version
</mat-card-title>
<button mat-icon-button (click)="cancel()">
<mat-icon color="primary">close</mat-icon>
</button>
</div>
<div id="flow-version-container">
<div class="fill-available-width">
<div class="pad-bottom-sm label-name">
<label>Flow Name</label>
</div>
<div id="flow-version-name" fxLayout="row" fxLayoutAlign="space-between center" class="push-bottom-md">
<div id="version-flow-name-input" class="fill-available-width">
<input
id="version-flow-name-input-field"
class="pad-bottom-md fill-available-width"
matInput
[disabled]="true"
type="text"
value="{{droplet.name}}"/>
</div>
<div class="version-count">
<span>v.{{droplet.snapshotMetadata.length + 1}}</span>
</div>
</div>
</div>
<div id="versioned-flow-file-upload-message-container" fxLayout="row" fxLayoutAlign="space-between center">
<div class="pad-bottom-xs label-name">
<label>Flow Definition</label>
</div>
<ng-container *ngIf="fileToUpload != null && !hoverValidity || hoverValidity === 'valid'">
<span class="file-upload-message">
Looks good!
<i class="fa fa-check-circle-o" aria-hidden="true"
style="color: #1eb475;"></i>
</span>
</ng-container>
<ng-container *ngIf="hoverValidity === 'invalid'">
<span class="file-upload-message">
File format is not valid
<i class="fa fa-times-circle" aria-hidden="true"
style="color: #ef6162;"></i></span>
</ng-container>
</div>
<div id="flow-version-definition"
class="fill-available-width"
fxLayout="row"
fxLayoutAlign="space-between center"
(click)="selectFile()"
(dragenter)="fileDragHandler($event, extensions)"
(dragover)="fileDragHandler($event, extensions)"
(dragend)="fileDragEndHandler()"
(dragleave)="fileDragEndHandler()"
(drop)="fileDropHandler($event)">
<mat-form-field floatLabel="never" flex>
<input matInput
id="flow-version-definition-input"
type="text"
[value]="fileName"
placeholder="Drop file or select..."
autocomplete="off"
[ngClass]="{'file-hover-valid': (hoverValidity === 'valid'),
'file-hover-error': (hoverValidity === 'invalid'),
'file-selected': (fileToUpload != null), 'multiple': multiple}"/>
<div id="select-flow-version-file-button" title="Browse">
<i class="fa fa-upload" aria-hidden="true"></i>
<span>Select file</span>
</div>
<div id="versioned-flow-file-upload-form-container">
<form id="versioned-flow-file-upload-form" enctype="multipart/form-data" method="post">
<input id="upload-versioned-flow-file-field"
type="file"
name="file"
[accept]="extensions"
(change)="handleFileInput($event.target.files)"/>
</form>
</div>
</mat-form-field>
</div>
<div class="pad-bottom-sm">
<label class="pad-bottom-sm label-name">Version Comments</label>
</div>
<div id="flow-version-comments" fxLayout="row" class="fill-available-width">
<textarea [(ngModel)]="comments"></textarea>
</div>
</div>
</div>
<div fxLayout="row">
<span fxFlex></span>
<button (click)="cancel()" color="fds-regular" mat-raised-button
i18n="Cancel new flow version import|A button for cancelling the new flow version to import in the registry.">
Cancel
</button>
<button [disabled]="!fileToUpload" class="push-left-sm" data-automation-id="import-new-versioned-flow-button"
(click)="importNewVersion()"
color="fds-primary" mat-raised-button
i18n="Cancel new flow version import|A button for cancelling the new flow version to import in the registry.">
Import
</button>
</div>

View File

@ -0,0 +1,153 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 } from '@angular/core';
import NfRegistryApi from 'services/nf-registry.api';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
import { FdsSnackBarService } from '@nifi-fds/core';
/**
* NfRegistryImportVersionedFlow constructor.
*
* @param nfRegistryApi The api service.
* @param fdsSnackBarService The FDS snack bar service module.
* @param matDialogRef The angular material dialog ref.
* @param data The data passed into this component.
* @constructor
*/
function NfRegistryImportVersionedFlow(nfRegistryApi, fdsSnackBarService, matDialogRef, data) {
// Services
this.snackBarService = fdsSnackBarService;
this.nfRegistryApi = nfRegistryApi;
this.dialogRef = matDialogRef;
// local state
this.keepDialogOpen = false;
this.droplet = data.droplet;
this.fileToUpload = null;
this.fileName = null;
this.comments = '';
this.hoverValidity = '';
this.extensions = 'application/json';
this.multiple = false;
}
NfRegistryImportVersionedFlow.prototype = {
constructor: NfRegistryImportVersionedFlow,
fileDragHandler: function (event, extensions) {
event.preventDefault();
event.stopPropagation();
this.extensions = extensions;
var { items } = event.dataTransfer;
this.hoverValidity = this.isFileInvalid(items)
? 'invalid'
: 'valid';
},
fileDragEndHandler: function () {
this.hoverValidity = '';
},
fileDropHandler: function (event) {
event.preventDefault();
event.stopPropagation();
var { files } = event.dataTransfer;
if (!this.isFileInvalid(Array.from(files))) {
this.handleFileInput(files);
}
this.hoverValidity = '';
},
/**
* Handle the file input on change.
*/
handleFileInput: function (files) {
// get the file
this.fileToUpload = files[0];
// get the filename
var fileName = this.fileToUpload.name;
// trim off the file extension
this.fileName = fileName.replace(/\..*/, '');
},
/**
* Open the file selector.
*/
selectFile: function () {
document.getElementById('upload-versioned-flow-file-field').click();
},
/**
* Upload new versioned flow snapshot.
*/
importNewVersion: function () {
var self = this;
var comments = this.comments;
this.nfRegistryApi.uploadVersionedFlowSnapshot(this.droplet.link.href, this.fileToUpload, comments).subscribe(function (response) {
if (!response.status || response.status === 201) {
self.snackBarService.openCoaster({
title: 'Success',
message: 'Successfully imported version ' + response.snapshotMetadata.version + ' of ' + response.flow.name + '.',
verticalPosition: 'bottom',
horizontalPosition: 'right',
icon: 'fa fa-check-circle-o',
color: '#1EB475',
duration: 3000
});
if (self.keepDialogOpen !== true) {
self.dialogRef.close();
}
} else {
self.dialogRef.close();
}
});
},
isFileInvalid: function (items) {
return (items.length > 1) || (this.extensions !== '' && items[0].type === '') || (this.extensions.indexOf(items[0].type) === -1);
},
/**
* Cancel uploading a new version and close dialog.
*/
cancel: function () {
this.dialogRef.close();
}
};
NfRegistryImportVersionedFlow.annotations = [
new Component({
templateUrl: './nf-registry-import-versioned-flow.html'
})
];
NfRegistryImportVersionedFlow.parameters = [
NfRegistryApi,
FdsSnackBarService,
MatDialogRef,
MAT_DIALOG_DATA
];
export default NfRegistryImportVersionedFlow;

View File

@ -0,0 +1,87 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 NfRegistryApi from 'services/nf-registry.api';
import NfRegistryImportVersionedFlow from 'components/explorer/grid-list/dialogs/import-versioned-flow/nf-registry-import-versioned-flow';
describe('NfRegistryImportVersionedFlow Component unit tests', function () {
var nfRegistryApi;
var data;
var comp;
beforeEach(function () {
nfRegistryApi = new NfRegistryApi();
data = {
droplet: {
bucketIdentifier: '123',
bucketName: 'Bucket 2',
createdTimestamp: 1620177743648,
description: '',
identifier: '555',
link: {
href: 'buckets/123/flows/555',
params: {}
},
modifiedTimestamp: 1620177743687,
name: 'Test Flow 2',
permissions: {canDelete: true, canRead: true, canWrite: true},
revision: {version: 0},
snapshotMetadata: [
{
author: 'anonymous',
bucketIdentifier: '123',
comments: 'Test comments',
flowIdentifier: '555',
link: {}
}
],
type: 'Flow',
versionCount: 1
}
};
comp = new NfRegistryImportVersionedFlow(nfRegistryApi, { openCoaster: function () {} }, { close: function () {} }, data);
// Spy
spyOn(comp.dialogRef, 'close');
});
it('should create component', function () {
expect(comp).toBeDefined();
});
it('should cancel the import of a new version', function () {
// the function to test
comp.cancel();
//assertions
expect(comp.dialogRef.close).toHaveBeenCalled();
});
it('should handle file input', function () {
var jsonFilename = 'filename.json';
var testFile = new File([], jsonFilename);
// The function to test
comp.handleFileInput([testFile]);
//assertions
expect(comp.fileToUpload.name).toEqual(jsonFilename);
expect(comp.fileName).toEqual('filename');
});
});

View File

@ -67,12 +67,14 @@ describe('NfRegistryBucketGridListViewer Component', function () {
spyOn(nfRegistryApi, 'getBuckets').and.callFake(function () {
}).and.returnValue(of([{
identifier: '2f7f9e54-dc09-4ceb-aa58-9fe581319cdc',
name: 'Bucket #1'
name: 'Bucket #1',
permissions: {'canDelete': true, 'canRead': true, 'canWrite': true}
}]));
spyOn(nfRegistryApi, 'getBucket').and.callFake(function () {
}).and.returnValue(of({
identifier: '2f7f9e54-dc09-4ceb-aa58-9fe581319cdc',
name: 'Bucket #1'
name: 'Bucket #1',
permissions: {'canDelete': true, 'canRead': true, 'canWrite': true}
}));
spyOn(nfRegistryApi, 'getDroplets').and.callFake(function () {
}).and.returnValue(of([{
@ -89,7 +91,8 @@ describe('NfRegistryBucketGridListViewer Component', function () {
'rel': 'self'
},
'href': 'flows/2e04b4fb-9513-47bb-aa74-1ae34616bfdc'
}
},
'permissions': {'canDelete': true, 'canRead': true, 'canWrite': true}
}]));
// 1st change detection triggers ngOnInit which makes getBuckets, getBucket, and getDroplets calls
fixture.detectChanges();
@ -150,12 +153,14 @@ describe('NfRegistryBucketGridListViewer Component', function () {
spyOn(nfRegistryApi, 'getBuckets').and.callFake(function () {
}).and.returnValue(of([{
identifier: '2f7f9e54-dc09-4ceb-aa58-9fe581319cdc',
name: 'Bucket #1'
name: 'Bucket #1',
permissions: {'canDelete': true, 'canRead': true, 'canWrite': true}
}]));
spyOn(nfRegistryApi, 'getBucket').and.callFake(function () {
}).and.returnValue(of({
identifier: '2f7f9e54-dc09-4ceb-aa58-9fe581319cdc',
name: 'Bucket #1'
name: 'Bucket #1',
permissions: {'canDelete': true, 'canRead': true, 'canWrite': true}
}));
spyOn(nfRegistryApi, 'getDroplets').and.callFake(function () {
}).and.returnValue(of([{
@ -172,7 +177,8 @@ describe('NfRegistryBucketGridListViewer Component', function () {
'rel': 'self'
},
'href': 'flows/2e04b4fb-9513-47bb-aa74-1ae34616bfdc'
}
},
'permissions': {'canDelete': true, 'canRead': true, 'canWrite': true}
}]));
// 1st change detection triggers ngOnInit which makes getBuckets, getBucket, and getDroplets calls
fixture.detectChanges();

View File

@ -84,17 +84,20 @@ describe('NfRegistryDropletGridListViewer Component', function () {
'rel': 'self'
},
'href': 'flows/2e04b4fb-9513-47bb-aa74-1ae34616bfdc'
}
},
'permissions': {'canDelete': true, 'canRead': true, 'canWrite': true}
}));
spyOn(nfRegistryApi, 'getBuckets').and.callFake(function () {
}).and.returnValue(of([{
identifier: '2f7f9e54-dc09-4ceb-aa58-9fe581319cdc',
name: 'Bucket #1'
name: 'Bucket #1',
permissions: {'canDelete': true, 'canRead': true, 'canWrite': true}
}]));
spyOn(nfRegistryApi, 'getBucket').and.callFake(function () {
}).and.returnValue(of({
identifier: '2f7f9e54-dc09-4ceb-aa58-9fe581319cdc',
name: 'Bucket #1'
name: 'Bucket #1',
permissions: {'canDelete': true, 'canRead': true, 'canWrite': true}
}));
spyOn(nfRegistryApi, 'getDroplets').and.callFake(function () {
}).and.returnValue(of([{
@ -111,7 +114,8 @@ describe('NfRegistryDropletGridListViewer Component', function () {
'rel': 'self'
},
'href': 'flows/2e04b4fb-9513-47bb-aa74-1ae34616bfdc'
}
},
'permissions': {'canDelete': true, 'canRead': true, 'canWrite': true}
}]));
// 1st change detection triggers ngOnInit which makes getBuckets, getBucket, getDroplet, and getDroplets calls
fixture.detectChanges();
@ -194,17 +198,20 @@ describe('NfRegistryDropletGridListViewer Component', function () {
'rel': 'self'
},
'href': 'flows/2e04b4fb-9513-47bb-aa74-1ae34616bfdc'
}
},
'permissions': {'canDelete': true, 'canRead': true, 'canWrite': true}
}));
spyOn(nfRegistryApi, 'getBuckets').and.callFake(function () {
}).and.returnValue(of([{
identifier: '2f7f9e54-dc09-4ceb-aa58-9fe581319cdc',
name: 'Bucket #1'
name: 'Bucket #1',
permissions: {'canDelete': true, 'canRead': true, 'canWrite': true}
}]));
spyOn(nfRegistryApi, 'getBucket').and.callFake(function () {
}).and.returnValue(of({
identifier: '2f7f9e54-dc09-4ceb-aa58-9fe581319cdc',
name: 'Bucket #1'
name: 'Bucket #1',
permissions: {'canDelete': true, 'canRead': true, 'canWrite': true}
}));
spyOn(nfRegistryApi, 'getDroplets').and.callFake(function () {
}).and.returnValue(of([{
@ -221,7 +228,8 @@ describe('NfRegistryDropletGridListViewer Component', function () {
'rel': 'self'
},
'href': 'flows/2e04b4fb-9513-47bb-aa74-1ae34616bfdc'
}
},
permissions: {'canDelete': true, 'canRead': true, 'canWrite': true}
}]));
// 1st change detection triggers ngOnInit which makes getBuckets, getBucket, getDroplet, and getDroplets calls
fixture.detectChanges();

View File

@ -17,17 +17,19 @@ limitations under the License.
<div class="pad-top-sm pad-bottom-sm pad-right-xxl pad-left-xxl">
<div layout="row" layout-align="space-between center">
<div flex fxLayout="row" fxLayoutAlign="end center">
<td-chips [(ngModel)]="nfRegistryService.dropletsSearchTerms"
[items]="nfRegistryService.autoCompleteDroplets"
(add)="nfRegistryService.filterDroplets(nfRegistryService.activeDropletColumn.name, nfRegistryService.activeDropletColumn.sortOrder);"
(remove)="nfRegistryService.filterDroplets(nfRegistryService.activeDropletColumn.name, nfRegistryService.activeDropletColumn.sortOrder);"class="push-right-sm"></td-chips>
<div flex fxLayout="row" fxLayoutAlign="start center">
<span class="push-top-sm pad-right-sm">Sort by:</span>
<div fxLayout="row" fxLayoutAlign="end center" [matMenuTriggerFor]="dropletGridSortMenu">
<div class="push-top-sm" id="droplet-sort-by-field">{{nfRegistryService.getSortByLabel()}}</div>
<i class="push-top-sm fa fa-caret-down pad-left-sm" aria-hidden="true"></i>
</div>
</div>
<div flex fxLayout="row" fxLayoutAlign="end center">
<td-chips [(ngModel)]="nfRegistryService.dropletsSearchTerms"
[items]="nfRegistryService.autoCompleteDroplets"
(add)="nfRegistryService.filterDroplets(nfRegistryService.activeDropletColumn.name, nfRegistryService.activeDropletColumn.sortOrder);"
(remove)="nfRegistryService.filterDroplets(nfRegistryService.activeDropletColumn.name, nfRegistryService.activeDropletColumn.sortOrder);"class="push-right-sm"></td-chips>
</div>
<mat-menu #dropletGridSortMenu="matMenu" [overlapTrigger]="false">
<div *ngFor="let column of nfRegistryService.dropletColumns">
<button mat-menu-item *ngIf="column.sortable" (click)="nfRegistryService.sortDroplets(column);">
@ -35,8 +37,17 @@ limitations under the License.
</button>
</div>
</mat-menu>
<button [disabled]="nfRegistryService.buckets.length === 0 || (nfRegistryService.filterWritableBuckets(nfRegistryService.buckets)).length === 0"
(click)="nfRegistryService.openImportNewFlowDialog(nfRegistryService.buckets, nfRegistryService.bucket)"
class="push-left-sm push-top-sm" data-automation-id="import-new-flow-button"
color="fds-primary" mat-raised-button i18n="Import new flow button|A button for importing a new flow in the registry.">
Import New Flow
</button>
</div>
</div>
<div id="import-new-flow-disabled-message" flex fxLayout="row" fxLayoutAlign="end center" class="pad-right-xxl pad-bottom-sm" *ngIf="(nfRegistryService.filterWritableBuckets(nfRegistryService.buckets)).length === 0 && nfRegistryService.buckets.length != 0">
<span>You are not authorized to import flows.</span>
</div>
<div id="nifi-registry-explorer-grid-list-viewer-droplet-container" class="pad-right-xxl pad-left-xxl"
*ngIf="nfRegistryService.filteredDroplets.length > 0">
<div *ngFor="let droplet of nfRegistryService.filteredDroplets" [@flyInOut]>
@ -64,9 +75,9 @@ limitations under the License.
<mat-menu class="fds-primary-dropdown-button-menu" #primaryButtonDropdownMenu="matMenu"
[overlapTrigger]="false">
<button mat-menu-item *ngFor="let action of nfRegistryService.dropletActions"
[disabled]="!droplet.permissions.canDelete"
[disabled]="action.disabled(droplet)"
(click)="nfRegistryService.executeDropletAction(action, droplet)">
<span>{{action.name}}</span>
{{action.name}}
</button>
</mat-menu>
</div>
@ -118,7 +129,10 @@ limitations under the License.
<div class="pad-bottom-sm"></div>
</div>
</div>
<div class="pad-right-xxl pad-left-xxl" *ngIf="nfRegistryService.filteredDroplets.length === 0 && !nfRegistryService.inProgress">
<div class="pad-right-xxl pad-left-xxl" *ngIf="nfRegistryService.filteredDroplets.length === 0 && nfRegistryService.buckets.length != 0 && !nfRegistryService.inProgress">
<p class="text-center">No results match this query.</p>
</div>
<div class="pad-right-xxl pad-left-xxl" *ngIf="nfRegistryService.filteredDroplets.length === 0 && nfRegistryService.buckets.length === 0 && !nfRegistryService.inProgress">
<p class="text-center">There are no buckets to display.</p>
</div>
<router-outlet></router-outlet>

View File

@ -60,7 +60,8 @@ describe('NfRegistryGridListViewer Component', function () {
spyOn(nfRegistryApi, 'getBuckets').and.callFake(function () {
}).and.returnValue(of([{
identifier: '2f7f9e54-dc09-4ceb-aa58-9fe581319cdc',
name: 'Bucket #1'
name: 'Bucket #1',
permissions: {'canDelete': true, 'canRead': true, 'canWrite': true}
}]));
spyOn(nfRegistryService, 'filterDroplets');
@ -84,7 +85,8 @@ describe('NfRegistryGridListViewer Component', function () {
'rel': 'self'
},
'href': 'flows/2e04b4fb-9513-47bb-aa74-1ae34616bfdc'
}
},
'permissions': {'canDelete': true, 'canRead': true, 'canWrite': true}
}]));
// 1st change detection triggers ngOnInit which makes getBuckets and getDroplets calls
fixture.detectChanges();
@ -125,7 +127,8 @@ describe('NfRegistryGridListViewer Component', function () {
'rel': 'self'
},
'href': 'flows/2e04b4fb-9513-47bb-aa74-1ae34616bfdc'
}
},
'permissions': {'canDelete': true, 'canRead': true, 'canWrite': true}
}]));
// 1st change detection triggers ngOnInit which makes getBuckets and getDroplets calls
fixture.detectChanges();

View File

@ -52,6 +52,9 @@ import {
NfRegistryUsersAdministrationAuthGuard,
NfRegistryWorkflowsAdministrationAuthGuard
} from 'services/nf-registry.auth-guard.service';
import NfRegistryImportVersionedFlow from './components/explorer/grid-list/dialogs/import-versioned-flow/nf-registry-import-versioned-flow';
import NfRegistryImportNewFlow from './components/explorer/grid-list/dialogs/import-new-flow/nf-registry-import-new-flow';
import NfRegistryExportVersionedFlow from './components/explorer/grid-list/dialogs/export-versioned-flow/nf-registry-export-versioned-flow';
function NfRegistryModule() {
}
@ -89,7 +92,10 @@ NfRegistryModule.annotations = [
NfRegistryDropletGridListViewer,
NfPageNotFoundComponent,
NfLoginComponent,
NfUserLoginComponent
NfUserLoginComponent,
NfRegistryExportVersionedFlow,
NfRegistryImportVersionedFlow,
NfRegistryImportNewFlow
],
entryComponents: [
NfRegistryAddUser,
@ -99,7 +105,10 @@ NfRegistryModule.annotations = [
NfRegistryAddUsersToGroup,
NfRegistryAddPolicyToBucket,
NfRegistryEditBucketPolicy,
NfUserLoginComponent
NfUserLoginComponent,
NfRegistryExportVersionedFlow,
NfRegistryImportVersionedFlow,
NfRegistryImportNewFlow
],
providers: [
NfRegistryService,

View File

@ -19,7 +19,7 @@ import NfStorage from 'services/nf-storage.service';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { FdsDialogService } from '@nifi-fds/core';
import { of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { map, catchError, take, switchMap } from 'rxjs/operators';
var MILLIS_PER_SECOND = 1000;
var headers = new Headers({'Content-Type': 'application/json'});
@ -75,6 +75,138 @@ NfRegistryApi.prototype = {
);
},
/**
* Retrieves the specified versioned flow snapshot for an existing droplet the registry has stored.
*
* @param {string} dropletUri The uri of the droplet to request.
* @param {number} versionNumber The version of the flow to request.
* @returns {*}
*/
exportDropletVersionedSnapshot: function (dropletUri, versionNumber) {
var self = this;
var url = '../nifi-registry-api/' + dropletUri + '/versions/' + versionNumber + '/export';
var options = {
headers: headers,
observe: 'response',
responseType: 'text'
};
return self.http.get(url, options).pipe(
map(function (response) {
// export the VersionedFlowSnapshot by creating a hidden anchor element
var stringSnapshot = encodeURIComponent(response.body);
var filename = response.headers.get('Filename');
var anchorElement = document.createElement('a');
anchorElement.href = 'data:application/json;charset=utf-8,' + stringSnapshot;
anchorElement.download = filename;
anchorElement.style = 'display: none;';
document.body.appendChild(anchorElement);
anchorElement.click();
document.body.removeChild(anchorElement);
return response;
}),
catchError(function (error) {
self.dialogService.openConfirm({
title: 'Error',
message: error.error,
acceptButton: 'Ok',
acceptButtonColor: 'fds-warn'
});
return of(error);
})
);
},
/**
* Uploads a new versioned flow snapshot to the existing droplet the registry has stored.
*
* @param {string} dropletUri The uri of the droplet to request.
* @param file The file to be uploaded.
* @param {string} comments The optional comments.
* @returns {*}
*/
uploadVersionedFlowSnapshot: function (dropletUri, file, comments) {
var self = this;
var url = '../nifi-registry-api/' + dropletUri + '/versions/import';
var versionHeaders = new HttpHeaders()
.set('Content-Type', 'application/json')
.set('Comments', comments);
return self.http.post(url, file, { 'headers': versionHeaders }).pipe(
map(function (response) {
return response;
}),
catchError(function (error) {
self.dialogService.openConfirm({
title: 'Error',
message: error.error,
acceptButton: 'Ok',
acceptButtonColor: 'fds-warn'
});
return of(error);
})
);
},
/**
* Uploads a new flow to the existing droplet the registry has stored.
*
* @param {string} bucketUri The uri of the droplet to request.
* @param file The file to be uploaded.
* @param {string} name The flow name.
* @param {string} description The optional description.
* @returns {*}
*/
uploadFlow: function (bucketUri, file, name, description) {
var self = this;
var url = '../nifi-registry-api/' + bucketUri + '/flows';
var flow = { 'name': name, 'description': description };
// first, create Flow version 0
return self.http.post(url, flow, headers).pipe(
take(1),
switchMap(function (response) {
var flowUri = response.link.href;
var importVersionUrl = '../nifi-registry-api/' + flowUri + '/versions/import';
// then, import file as Flow version 1
return self.http.post(importVersionUrl, file, headers).pipe(
map(function (snapshot) {
return snapshot;
}),
catchError(function (error) {
// delete Flow version 0
var deleteUri = flowUri + '?versions=0';
self.deleteDroplet(deleteUri).subscribe(function (response) {
return response;
});
self.dialogService.openConfirm({
title: 'Error',
message: error.error,
acceptButton: 'Ok',
acceptButtonColor: 'fds-warn'
});
return of(error);
})
);
}),
catchError(function (error) {
self.dialogService.openConfirm({
title: 'Error',
message: error.error,
acceptButton: 'Ok',
acceptButtonColor: 'fds-warn'
});
return of(error);
})
);
},
/**
* Retrieves the given droplet with or without snapshot metadata.
*

View File

@ -1400,4 +1400,166 @@ describe('NfRegistry API w/ Angular testing utils', function () {
// Finally, assert that there are no outstanding requests.
httpMock.verify();
}));
it('should GET to export versioned snapshot.', inject([HttpTestingController], function (httpMock) {
var url = 'testUrl';
var versionNumber = 1;
var reqUrl = '../nifi-registry-api/' + url + '/versions/' + versionNumber + '/export';
var response = '{'
+ 'body: {'
+ 'flowContents: {'
+ 'componentType: \'PROCESS_GROUP\','
+ 'connections: [],'
+ 'controllerServices: [],'
+ 'funnels: [],'
+ 'identifier: \'123\','
+ 'inputPorts: [],'
+ 'labels: [],'
+ 'name: \'Test snapshot\','
+ 'outputPorts: [],'
+ 'processGroups: []'
+ '},'
+ 'snapshotMetadata: {'
+ 'author: \'anonymous\','
+ 'bucketIdentifier: \'123\','
+ 'comments: \'Test comments\','
+ 'flowIdentifier: \'555\','
+ 'link: {'
+ 'href: \'buckets/123/flows/555/versions/2\','
+ 'params: {}'
+ '},'
+ 'version: 2'
+ '}'
+ '},'
+ 'headers: {'
+ 'headers: ['
+ '{\'filename\': [\'Test-flow-version-1\']}'
+ '],'
+ 'normalizedNames: {\'filename\': \'filename\'}'
+ '},'
+ 'ok: true,'
+ 'status: 200,'
+ 'statusText: \'OK\','
+ 'type: 4,'
+ 'url: \'testUrl\''
+ '}';
var stringResponse = encodeURIComponent(response);
var anchor = document.createElement('a');
anchor.href = 'data:application/json;charset=utf-8,' + stringResponse;
anchor.download = 'Test-flow-version-3.json';
anchor.style = 'display: none;';
spyOn(document.body, 'appendChild');
spyOn(document.body, 'removeChild');
// api call
nfRegistryApi.exportDropletVersionedSnapshot(url, versionNumber).subscribe(function (res) {
expect(res.body).toEqual(response);
expect(res.status).toEqual(200);
});
// the request it made
req = httpMock.expectOne(reqUrl);
expect(req.request.method).toEqual('GET');
// Next, fulfill the request by transmitting a response.
req.flush(response);
// Finally, assert that there are no outstanding requests.
httpMock.verify();
expect(document.body.appendChild).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
}));
it('should POST to upload versioned flow snapshot.', inject([HttpTestingController], function (httpMock) {
var url = 'testUrl';
var reqUrl = '../nifi-registry-api/' + url + '/versions/import';
var response = {
flowContents: {
componentType: 'PROCESS_GROUP',
connections: [],
controllerServices: [],
funnels: [],
name: 'Test name',
identifier: '123'
},
snapshotMetadata: {
author: 'anonymous',
comments: 'This is snapshot #5',
timestamp: 1619806926583,
version: 3
}
};
var testFile = new File([], 'filename');
// api call
nfRegistryApi.uploadVersionedFlowSnapshot(url, testFile, '').subscribe(function (res) {
expect(res).toEqual(response);
expect(res.flowContents.name).toEqual('Test name');
expect(res.snapshotMetadata.comments).toEqual('This is snapshot #5');
});
// the request it made
req = httpMock.expectOne(reqUrl);
expect(req.request.method).toEqual('POST');
// Next, fulfill the request by transmitting a response.
req.flush(response);
// Finally, assert that there are no outstanding requests.
httpMock.verify();
}));
it('should POST to upload new flow snapshot.', inject([HttpTestingController], function (httpMock) {
var bucketUri = 'buckets/123';
var flowUri = 'buckets/123/flows/456';
var createFlowReqUrl = '../nifi-registry-api/' + bucketUri + '/flows';
var importFlowReqUrl = '../nifi-registry-api/' + flowUri + '/versions/import';
var headers = new Headers({'Content-Type': 'application/json'});
var response = {
bucketIdentifier: '123',
bucketName: 'Bucket 1',
createdTimestamp: 1620168949158,
description: 'Test description',
identifier: '456',
link: {
href: 'buckets/123/flows/456',
params: {}
},
modifiedTimestamp: 1620175586179,
name: 'Test Flow name',
permissions: {canDelete: true, canRead: true, canWrite: true},
type: 'Flow',
versionCount: 0
};
var testFile = new File([], 'filename.json');
// api call
nfRegistryApi.uploadFlow(bucketUri, testFile, headers).subscribe(function (res) {
expect(res).toEqual(response);
});
// the request it made
req = httpMock.expectOne(createFlowReqUrl);
expect(req.request.method).toEqual('POST');
// Next, fulfill the request by transmitting a response.
req.flush(response);
// the inner request it made
req = httpMock.expectOne(importFlowReqUrl);
expect(req.request.method).toEqual('POST');
// Finally, assert that there are no outstanding requests.
httpMock.verify();
}));
});

View File

@ -17,9 +17,13 @@
import { TdDataTableService } from '@covalent/core/data-table';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material';
import { FdsDialogService, FdsSnackBarService } from '@nifi-fds/core';
import NfRegistryApi from 'services/nf-registry.api.js';
import NfStorage from 'services/nf-storage.service.js';
import NfRegistryExportVersionedFlow from '../components/explorer/grid-list/dialogs/export-versioned-flow/nf-registry-export-versioned-flow';
import NfRegistryImportVersionedFlow from '../components/explorer/grid-list/dialogs/import-versioned-flow/nf-registry-import-versioned-flow';
import NfRegistryImportNewFlow from '../components/explorer/grid-list/dialogs/import-new-flow/nf-registry-import-new-flow';
/**
* NfRegistryService constructor.
@ -30,9 +34,10 @@ import NfStorage from 'services/nf-storage.service.js';
* @param router The angular router module.
* @param fdsDialogService The FDS dialog service.
* @param fdsSnackBarService The FDS snack bar service module.
* @param matDialog The angular material dialog module.
* @constructor
*/
function NfRegistryService(nfRegistryApi, nfStorage, tdDataTableService, router, fdsDialogService, fdsSnackBarService) {
function NfRegistryService(nfRegistryApi, nfStorage, tdDataTableService, router, fdsDialogService, fdsSnackBarService, matDialog) {
var self = this;
this.registry = {
name: 'NiFi Registry',
@ -52,6 +57,7 @@ function NfRegistryService(nfRegistryApi, nfStorage, tdDataTableService, router,
this.dialogService = fdsDialogService;
this.snackBarService = fdsSnackBarService;
this.dataTableService = tdDataTableService;
this.matDialog = matDialog;
// data table column definitions
this.userColumns = [
@ -133,9 +139,28 @@ function NfRegistryService(nfRegistryApi, nfStorage, tdDataTableService, router,
];
this.dropletActions = [
{
name: 'Delete',
name: 'Import new version',
icon: 'fa fa-upload',
tooltip: 'Import new flow version',
disabled: function (droplet) {
return !droplet.permissions.canWrite;
}
},
{
name: 'Export version',
icon: 'fa fa-download',
tooltip: 'Export flow version',
disabled: function (droplet) {
return !droplet.permissions.canRead;
}
},
{
name: 'Delete flow',
icon: 'fa fa-trash',
tooltip: 'Delete'
tooltip: 'Delete',
disabled: function (droplet) {
return !droplet.permissions.canDelete;
}
}
];
this.disableMultiDeleteAction = false;
@ -393,6 +418,105 @@ NfRegistryService.prototype = {
return label;
},
/**
* Delete the latest flow snapshot.
*
* @param droplet The droplet object.
*/
deleteDroplet: function (droplet) {
var self = this;
this.dialogService.openConfirm({
title: 'Delete Flow',
message: 'All versions of this ' + droplet.type.toLowerCase() + ' will be deleted.',
cancelButton: 'Cancel',
acceptButton: 'Delete',
acceptButtonColor: 'fds-warn'
}).afterClosed().subscribe(
function (accept) {
if (accept) {
var deleteUrl = droplet.link.href;
if (droplet.type === 'Flow') {
deleteUrl = deleteUrl + '?version=' + droplet.revision.version;
}
self.api.deleteDroplet(deleteUrl).subscribe(function (response) {
if (!response.status || response.status === 200) {
self.droplets = self.droplets.filter(function (d) {
return (d.identifier !== droplet.identifier);
});
self.snackBarService.openCoaster({
title: 'Success',
message: 'All versions of this ' + droplet.type.toLowerCase() + ' have been deleted.',
verticalPosition: 'bottom',
horizontalPosition: 'right',
icon: 'fa fa-check-circle-o',
color: '#1EB475',
duration: 3000
});
self.droplet = {};
self.filterDroplets();
}
});
}
}
);
},
/**
* Opens the export version dialog.
*
* @param droplet The droplet object.
*/
openExportVersionedFlowDialog: function (droplet) {
this.matDialog.open(NfRegistryExportVersionedFlow, {
disableClose: true,
width: '400px',
data: {
droplet: droplet
}
});
},
/**
* Opens the import new flow dialog.
*
* @param buckets The buckets object.
* @param activeBucket The active bucket object.
*/
openImportNewFlowDialog: function (buckets, activeBucket) {
var self = this;
this.matDialog.open(NfRegistryImportNewFlow, {
disableClose: true,
width: '550px',
data: {
buckets: buckets,
activeBucket: activeBucket
}
}).afterClosed().subscribe(function (flowUri) {
if (flowUri != null) {
self.router.navigateByUrl('explorer/grid-list/' + flowUri);
}
});
},
/**
* Opens the import new version dialog.
*
* @param droplet The droplet object.
*/
openImportVersionedFlowDialog: function (droplet) {
var self = this;
this.matDialog.open(NfRegistryImportVersionedFlow, {
disableClose: true,
width: '550px',
data: {
droplet: droplet
}
}).afterClosed().subscribe(function () {
self.getDropletSnapshotMetadata(droplet);
});
},
/**
* Execute the given droplet action.
*
@ -400,42 +524,20 @@ NfRegistryService.prototype = {
* @param droplet The droplet object the `action` will act upon.
*/
executeDropletAction: function (action, droplet) {
var self = this;
if (action.name.toLowerCase() === 'delete') {
this.dialogService.openConfirm({
title: 'Delete ' + droplet.type.toLowerCase(),
message: 'All versions of this ' + droplet.type.toLowerCase() + ' will be deleted.',
cancelButton: 'Cancel',
acceptButton: 'Delete',
acceptButtonColor: 'fds-warn'
}).afterClosed().subscribe(
function (accept) {
if (accept) {
var deleteUrl = droplet.link.href;
if (droplet.type === 'Flow') {
deleteUrl = deleteUrl + '?version=' + droplet.revision.version;
}
self.api.deleteDroplet(deleteUrl).subscribe(function (response) {
if (!response.status || response.status === 200) {
self.droplets = self.droplets.filter(function (d) {
return (d.identifier !== droplet.identifier);
});
self.snackBarService.openCoaster({
title: 'Success',
message: 'All versions of this ' + droplet.type.toLowerCase() + ' have been deleted.',
verticalPosition: 'bottom',
horizontalPosition: 'right',
icon: 'fa fa-check-circle-o',
color: '#1EB475',
duration: 3000
});
self.droplet = {};
self.filterDroplets();
}
});
}
}
);
switch (action.name.toLowerCase()) {
case 'import new version':
// Opens the import versioned flow dialog
this.openImportVersionedFlowDialog(droplet);
break;
case 'export version':
// Opens the export flow version dialog
this.openExportVersionedFlowDialog(droplet);
break;
case 'delete flow':
// Deletes the entire data flow
this.deleteDroplet(droplet);
break;
default: // do nothing
}
},
@ -633,6 +735,22 @@ NfRegistryService.prototype = {
this.getAutoCompleteBuckets();
},
/**
* Gets the buckets the user has permissions to write.
*
* @param buckets The buckets object.
*/
filterWritableBuckets: function (buckets) {
var writableBuckets = [];
buckets.forEach(function (b) {
if (b.permissions.canWrite) {
writableBuckets.push(b);
}
});
return writableBuckets;
},
/**
* Generates the `autoCompleteBuckets` options for the bucket filter.
*/
@ -1213,7 +1331,8 @@ NfRegistryService.parameters = [
TdDataTableService,
Router,
FdsDialogService,
FdsSnackBarService
FdsSnackBarService,
MatDialog
];
export default NfRegistryService;

View File

@ -22,6 +22,12 @@ import NfRegistryApi from 'services/nf-registry.api';
import NfRegistryService from 'services/nf-registry.service';
import { Router } from '@angular/router';
import { FdsDialogService } from '@nifi-fds/core';
import NfRegistryExportVersionedFlow
from '../components/explorer/grid-list/dialogs/export-versioned-flow/nf-registry-export-versioned-flow';
import NfRegistryImportVersionedFlow
from '../components/explorer/grid-list/dialogs/import-versioned-flow/nf-registry-import-versioned-flow';
import NfRegistryImportNewFlow
from '../components/explorer/grid-list/dialogs/import-new-flow/nf-registry-import-new-flow';
describe('NfRegistry Service isolated unit tests', function () {
@ -702,7 +708,12 @@ describe('NfRegistry Service w/ Angular testing utils', function () {
it('should execute the `delete` droplet action.', function () {
//Setup the nfRegistryService state for this test
nfRegistryService.droplets = [{identifier: '2e04b4fb-9513-47bb-aa74-1ae34616bfdc'}];
nfRegistryService.droplets = [
{
identifier: '2e04b4fb-9513-47bb-aa74-1ae34616bfdc',
permissions: {canDelete: true, canRead: true, canWrite: true}
}
];
//Spy
spyOn(nfRegistryService.dialogService, 'openConfirm').and.returnValue({
@ -716,17 +727,18 @@ describe('NfRegistry Service w/ Angular testing utils', function () {
});
// The function to test
nfRegistryService.executeDropletAction({name: 'delete'}, {
nfRegistryService.executeDropletAction({name: 'delete flow'}, {
identifier: '2e04b4fb-9513-47bb-aa74-1ae34616bfdc',
type: 'testTYPE',
link: {href: 'testhref'}
link: {href: 'testhref'},
permissions: {canDelete: true, canRead: true, canWrite: true}
});
//assertions
expect(nfRegistryService.droplets.length).toBe(0);
expect(nfRegistryService.filterDroplets).toHaveBeenCalled();
const openConfirmCall = nfRegistryService.dialogService.openConfirm.calls.first();
expect(openConfirmCall.args[0].title).toBe('Delete testtype');
expect(openConfirmCall.args[0].title).toBe('Delete Flow');
const deleteDropletCall = nfRegistryApi.deleteDroplet.calls.first();
expect(deleteDropletCall.args[0]).toBe('testhref');
});
@ -795,10 +807,10 @@ describe('NfRegistry Service w/ Angular testing utils', function () {
}).and.returnValue(of({identifier: '2e04b4fb-9513-47bb-aa74-1ae34616bfdc', link: null}));
// object to be updated by the test
const bucket = {identifier: '999', revision: { version: 0}};
const bucket = {identifier: '999', revision: {version: 0}};
// set up the bucket to be deleted
nfRegistryService.buckets = [bucket, {identifier: 1, revision: { version: 0}}];
nfRegistryService.buckets = [bucket, {identifier: 1, revision: {version: 0}}];
// The function to test
nfRegistryService.executeBucketAction({name: 'delete'}, bucket);
@ -850,7 +862,7 @@ describe('NfRegistry Service w/ Angular testing utils', function () {
const user = {identifier: '999', revision: {version: 0}};
// set up the user to be deleted
nfRegistryService.users = [user, {identifier: 1, revision: { version: 0}}];
nfRegistryService.users = [user, {identifier: 1, revision: {version: 0}}];
// The function to test
nfRegistryService.executeUserAction({name: 'delete'}, user);
@ -1023,10 +1035,10 @@ describe('NfRegistry Service w/ Angular testing utils', function () {
}).and.returnValue(of({identifier: 999, link: null}));
// object to be updated by the test
const bucket = {identifier: 999, checked: true, revision: { version: 0}};
const bucket = {identifier: 999, checked: true, revision: {version: 0}};
// set up the bucket to be deleted
nfRegistryService.buckets = [bucket, {identifier: 1, revision: { version: 0}}];
nfRegistryService.buckets = [bucket, {identifier: 1, revision: {version: 0}}];
nfRegistryService.filteredBuckets = nfRegistryService.buckets;
// The function to test
@ -1065,13 +1077,13 @@ describe('NfRegistry Service w/ Angular testing utils', function () {
}).and.returnValue(of({identifier: 99, link: null}));
// object to be updated by the test
const group = {identifier: 999, checked: true, revision: { version: 0}};
const user = {identifier: 999, checked: true, revision: { version: 0}};
const group = {identifier: 999, checked: true, revision: {version: 0}};
const user = {identifier: 999, checked: true, revision: {version: 0}};
// set up the group to be deleted
nfRegistryService.groups = [group, {identifier: 1, revision: { version: 0}}];
nfRegistryService.groups = [group, {identifier: 1, revision: {version: 0}}];
nfRegistryService.filteredUserGroups = nfRegistryService.groups;
nfRegistryService.users = [user, {identifier: 12, revision: { version: 0}}];
nfRegistryService.users = [user, {identifier: 12, revision: {version: 0}}];
nfRegistryService.filteredUsers = nfRegistryService.users;
// The function to test
@ -1091,4 +1103,112 @@ describe('NfRegistry Service w/ Angular testing utils', function () {
expect(nfRegistryService.users.length).toBe(1);
expect(nfRegistryService.users[0].identifier).toBe(12);
});
it('should open the Export Version dialog.', function () {
//Setup the nfRegistryService state for this test
var droplet = {
identifier: '2e04b4fb-9513-47bb-aa74-1ae34616bfdc',
type: 'testTYPE',
link: {href: 'testhref'},
permissions: {canDelete: true, canRead: true, canWrite: true}
};
//Spy
spyOn(nfRegistryService.matDialog, 'open');
nfRegistryService.executeDropletAction({name: 'export version'}, droplet);
expect(nfRegistryService.matDialog.open).toHaveBeenCalledWith(NfRegistryExportVersionedFlow, {
disableClose: true,
width: '400px',
data: {
droplet: droplet
}
});
});
it('should open the Import Versioned Flow dialog.', function () {
//Setup the nfRegistryService state for this test
var droplet = {
identifier: '2e04b4fb-9513-47bb-aa74-1ae34616bfdc',
type: 'testTYPE',
link: {href: 'testhref'},
permissions: {canDelete: true, canRead: true, canWrite: true}
};
//Spy
spyOn(nfRegistryService.matDialog, 'open').and.returnValue({
afterClosed: function () {
return of(true);
}
});
nfRegistryService.executeDropletAction({name: 'import new version'}, droplet);
expect(nfRegistryService.matDialog.open).toHaveBeenCalledWith(NfRegistryImportVersionedFlow, {
disableClose: true,
width: '550px',
data: {
droplet: droplet
}
});
});
it('should open the Import New Flow dialog.', function () {
//Setup the nfRegistryService state for this test
nfRegistryService.buckets = [{
identifier: '2e04b4fb-9513-47bb-aa74-1ae34616bfdc',
name: 'Bucket #1',
checked: true,
permissions: {canDelete: true, canRead: true, canWrite: false}
}, {
identifier: '5c04b4fb-9513-47bb-aa74-1ae34616bfdc',
name: 'Bucket #2',
checked: true,
permissions: {canDelete: true, canRead: true, canWrite: true}
}];
nfRegistryService.bucket = {
identifier: '5c04b4fb-9513-47bb-aa74-1ae34616bfdc',
name: 'Bucket #2',
checked: true,
permissions: {canDelete: true, canRead: true, canWrite: true}
};
//Spy
spyOn(nfRegistryService.matDialog, 'open').and.returnValue({
afterClosed: function () {
return of(true);
}
});
nfRegistryService.openImportNewFlowDialog(nfRegistryService.buckets, nfRegistryService.bucket);
expect(nfRegistryService.matDialog.open).toHaveBeenCalledWith(NfRegistryImportNewFlow, {
disableClose: true,
width: '550px',
data: {
buckets: nfRegistryService.buckets,
activeBucket: nfRegistryService.bucket
}
});
});
it('should filter writable buckets.', function () {
nfRegistryService.buckets = [{
identifier: '2e04b4fb-9513-47bb-aa74-1ae34616bfdc',
name: 'Bucket #1',
checked: true,
permissions: {canDelete: true, canRead: true, canWrite: false}
}, {
identifier: '5c04b4fb-9513-47bb-aa74-1ae34616bfdc',
name: 'Bucket #2',
checked: true,
permissions: {canDelete: true, canRead: true, canWrite: true}
}];
// The function to test
const writableBuckets = nfRegistryService.filterWritableBuckets(nfRegistryService.buckets);
// assertions
expect(writableBuckets.length).toBe(1);
expect(writableBuckets[0].name).toBe('Bucket #2');
});
});

View File

@ -0,0 +1,220 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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.
*/
$gray: #666;
$light-gray: #999;
$dark-gray: #ced3d7;
$teal-gray: #6b8791;
.label-name {
font-weight: 500;
color: $gray;
}
.version-count {
font-weight: 500;
padding: 9px 18px;
margin-left: 5px;
border-radius: 17px;
background-color: #eee;
color: $gray;
}
#flow-version-name {
border: none;
height: 34px;
outline: 0;
font-family: Roboto, sans-serif;
font-size: 14px;
color: $gray;
span {
overflow: hidden;
}
}
#flow-name {
border: none;
height: 34px;
outline: 0;
font-family: Roboto, sans-serif;
font-size: 14px;
color: $gray;
span {
overflow: hidden;
}
}
#new-data-flow-version-placeholder {
border: none;
outline: 0;
color: $light-gray;
font-family: Roboto, sans-serif;
font-size: 14px;
font-weight: 300;
}
#select-flow-file-button,
#select-flow-version-file-button {
position: absolute;
right: 16px;
top: 8px;
border: none;
background-color: transparent;
cursor: pointer;
i {
color: $teal-gray;
padding: 9px 5px;
outline: none;
}
span {
text-transform: uppercase;
color: $teal-gray;
}
}
input#upload-flow-file-field,
input#upload-versioned-flow-file-field {
display: none;
}
#flow-version-comments {
padding-bottom: 26px;
textarea {
width: 515px;
height: 48px;
border: solid 1px #cfd3d7;
border-radius: 2px;
padding: 9px 16px 9px 12px;
resize: none;
color: $gray;
}
}
#new-flow-description {
textarea {
width: 515px;
height: 48px;
border: solid 1px #cfd3d7;
border-radius: 2px;
padding: 9px 16px 9px 12px;
color: $gray;
}
}
#flow-version-comments,
#new-flow-description {
textarea:focus {
outline: 0;
border-color: $teal-gray;
}
}
#versioned-flow-file-upload-message-container,
#new-flow-file-upload-message-container {
& span.file-upload-message {
font-size: 12px;
color: $light-gray;
}
i {
font-size: 15px;
}
}
#nifi-registry-export-versioned-flow-dialog .bucket-dropdown-field .mat-select-value {
color: $gray;
}
.bucket-dropdown-field .mat-form-field-appearance-fill {
.mat-form-field-infix {
border: 0;
padding: 0.68em 0;
.mat-select-value-text {
color: $gray;
}
.mat-select-placeholder {
font-weight: 300;
}
}
.mat-select-arrow-wrapper {
transform: none;
}
.mat-form-field-flex {
border: 1px solid $dark-gray;
background-color: transparent;
border-radius: 2px;
padding-top: 0;
}
}
.bucket-dropdown-select {
.mat-select-panel {
color: $gray;
}
}
#new-flow-definition,
#flow-version-definition {
mat-form-field {
line-height: 28px;
}
.mat-form-field-infix {
border-top: 0;
}
input.mat-input-element {
&.file-hover-error {
border: solid 1px #ef6162;
}
&.file-hover-valid {
border: solid 1px #2ab377;
}
}
}
#new-flow-container .mat-form-field-appearance-fill .mat-form-field-infix {
color: $light-gray;
}
input[type=text]#flow-version-definition-input,
input[type=text]#new-flow-definition-input {
padding-right: 125px;
width: 373px;
text-overflow: ellipsis;
cursor: pointer;
}
body[fds] .fds-primary-dropdown-button-menu.fds-primary-dropdown-button-menu .mat-menu-item:focus:not([disabled]) {
color: rgba(0, 0, 0, 0.87);
background-color: rgba(0, 0, 0, 0.04);
}
body[fds] .fds-primary-dropdown-button-menu.fds-primary-dropdown-button-menu .mat-menu-item:hover:not([disabled]) {
color: #fff;
background-color: #915d69;
}

View File

@ -54,3 +54,8 @@ button.nf-registry-change-log-refresh.mat-icon-button {
overflow: auto;
margin-bottom: 0;
}
#import-new-flow-disabled-message {
font-size: 12px;
color: #5a656d;
}

View File

@ -33,6 +33,7 @@ $fdsFontPath: './node_modules/roboto-fontface/fonts';
@import 'components/administration/users/structureElements';
@import 'components/administration/workflow/structureElements';
@import 'components/explorer/grid-list/structureElements';
@import 'components/explorer/dialogs/structureElements';
$primaryColor: $rose1; //$green2
$primaryColorHover: $rose2; //$green3