Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NIFI-3785: Added feature to move controller services between process … #8965

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

Freedom9339
Copy link
Contributor

@Freedom9339 Freedom9339 commented Jun 13, 2024

…groups with the new UI

Summary

NIFI-3785: Added an option on the controller services grid that moves a controller service to a specified process group. The controller service can be moved to the parent process group or a child process group. However, it can only be moved one level at a time. If there are any scope conflicts with referencing components, the move will fail displaying the reason.

Tracking

Please complete the following tracking steps prior to pull request creation.

Issue Tracking

Pull Request Tracking

  • Pull Request title starts with Apache NiFi Jira issue number, such as NIFI-00000
  • Pull Request commit message starts with Apache NiFi Jira issue number, as such NIFI-00000

Pull Request Formatting

  • Pull Request based on current revision of the main branch
  • Pull Request refers to a feature branch with one commit containing changes

Verification

Please indicate the verification steps performed prior to pull request creation.

Build

  • Build completed using mvn clean install -P contrib-check
    • JDK 21

Licensing

  • New dependencies are compatible with the Apache License 2.0 according to the License Policy
  • New dependencies are documented in applicable LICENSE and NOTICE files

Documentation

  • Documentation formatting appears as expected in rendered files

@Freedom9339
Copy link
Contributor Author

@markap14 @exceptionfactory I've resubmitted this PR with the changes done to the new UI. The backend is the same as it was in the closed PR(#7734), but the UI changes were done against the new UI.

@ChrisSamo632 ChrisSamo632 changed the title NIFI-3785.1: Added feature to move controller services between process … NIFI-3785: Added feature to move controller services between process … Jun 13, 2024
@Freedom9339
Copy link
Contributor Author

@markobean

@exceptionfactory
Copy link
Contributor

Thanks for putting in the work to refactor this for the new UI @Freedom9339.

Tagging @mcgilman and @rfellows for potential review.

@mcgilman
Copy link
Contributor

Thanks for the mention @exceptionfactory! I'll have a look...

@mcgilman mcgilman self-requested a review June 14, 2024 21:54
Copy link
Contributor

@mcgilman mcgilman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR @Freedom9339! I've left a number of comments below that mostly appear to be from using the Enable/Disable Service dialog has a starting point. Since that dialog needs to be used for both Services in the Flow and in the Controller Settings there area a number of considerations there that are not needed here.

In addition to the items below, please ensure you run prettier and that this PR isn't introducing any new lint issues (I think there are currently 31 left that we're slowly trying to work through). From nifi/nifi-frontend/src/main/frontend please run:

$ npx nx prettier:format
$ npx nx lint

I will defer to @exceptionfactory or @markap14 for a more thorough review of the back end changes.

const serviceId: string = request.id;
request.processGroupFlow = this.flowService.getFlow(currentProcessGroupId);
const moveDialogReference = this.dialog.open(MoveControllerService, {
...XL_DIALOG,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is an XL Dialog needed here? It seems like the a smaller dialog may be more appropriate.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed this to a LARGE_DIALOG.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dialog size looks great. Can we update it so the content shares the available space like other dialogs (for instance Edit Controller Service)?

Screenshot 2024-07-02 at 5 18 11 PM

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fixed this. It now extends all the way.

Comment on lines 176 to 181
@if (canConfigure(item)) {
<button mat-menu-item (click)="moveClicked(item)">
<i class="fa fa-arrows primary-color mr-2"></i>
Move
</button>
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This table is used for both services in the Flow as well as services in the Controller Settings. This is why the ControllerServiceTable is in the common components. Since Services in the Controller Settings do not exist within a Process Group, this action is not be available.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made the move option not show up in management controller services.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update here. The new Input() supports function that returns whether the service can be moved. However, the implements are not service specific and always just return true or false. Can this Input() be simplified into a boolean?

@Input() canMove: boolean = false;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed this to just be a boolean.

concatLatestFrom(() => this.store.select(selectCurrentProcessGroupId)),
tap(([request, currentProcessGroupId]) => {
const serviceId: string = request.id;
request.processGroupFlow = this.flowService.getFlow(currentProcessGroupId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The state for the flow-designer page is already loaded with the current process group. We should be able to simply use that instead of making another request here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current state did not store the entire flow. I did change this and I was able to use it here without an api call. However, with the implementation of disabled drop down menu process groups, I did need to 2 other api calls here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah sorry. Yes you are correct. I thought we had stashed the entire processGroupFlow in the service state as well but I see that only saved the breadcrumbs and parameter context details. Now we're saving the entire processGroupFlow next to the breadcrumbs and parameter context details.

Do we only need the processGroupFlow in order to get the child Process Groups for the Move dialog? If so, what are your thoughts only saving them in the store. Often times the flow can be very large and in this page we won't need most of those details.

Let me know if I missed something and we do need additional details.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the idea, I hadn't thought of that. I removed the storing of the whole process group flow, and just saved an array with names and IDs.

@mcgilman mcgilman added the new ui Pull requests for work relating to the new user interface being developed. label Jun 20, 2024
@Freedom9339
Copy link
Contributor Author

@mcgilman Just checking in. Any updates on this?

@mcgilman
Copy link
Contributor

@mcgilman Just checking in. Any updates on this?

Thanks for the ping. Sorry for the delay, I'll try to get some more eyes on this PR this week.

@Freedom9339
Copy link
Contributor Author

@mcgilman Sorry the pinging again. Just want to make sure this doesn't fall through the cracks.

@mcgilman
Copy link
Contributor

@mcgilman Sorry the pinging again. Just want to make sure this doesn't fall through the cracks.

I just pulled down the latest. After rebasing against current main there were build failures due to changes that have landed after your PR was created. So I tried to run the PR as-is without rebasing and I'm still seeing issues. When running through the dev server it fails to serve with

Application bundle generation failed. [13.461 seconds]

✘ [ERROR] Undefined function.

109 │ $surface-light-palette: mat.define-palette($surface-palette, 600, 100, 900);
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

libs/shared/src/assets/themes/supplemental.scss 109:25 @import
apps/nifi/src/styles.scss 62:9 root stylesheet [plugin angular-sass]

angular:styles/global:styles:1:8:
  1 │ @import 'apps/nifi/src/styles.scss';

I then tried running with the built application but I'm seeing this error in dev tools when attempting to open the MoveControllerService Component.

TypeError: g is not iterable
    at chunk-VIZEOFFB.js:70:66450
    at Array.forEach (<anonymous>)
    at o.loadChildOptions (chunk-VIZEOFFB.js:70:66333)
    at new o (chunk-VIZEOFFB.js:70:65423)
    at o.ɵfac [as factory] (chunk-VIZEOFFB.js:70:67428)
    at hn (chunk-ZKVQE3GD.js:7:23746)
    at TC (chunk-ZKVQE3GD.js:7:63202)
    at mn.create (chunk-ZKVQE3GD.js:7:61795)
    at Vg.createComponent (chunk-ZKVQE3GD.js:7:64954)
    at t.attachComponentPortal (chunk-6XYXU2WR.js:1:110675)

Once the PR is updated I should be able to get some more eyes on it quickly.

@Freedom9339
Copy link
Contributor Author

@mcgilman I fixed the issue caused by rebasing and pushed. Thank You!

Copy link
Contributor

@mcgilman mcgilman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the rebase! I've left some additional feedback below.

value: child.value
};

const root = this.getProcessGroupById(currentProcessGroupEntity.component, child.value ?? '');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getProcessGroupById will return null for a couple different scenarios. When this happens calling processGroupContainsComponent will error with the following. The unhandled error leaves the UI in a weird state because the overlay is active but the dialog isn't shown.

TypeError: Cannot read properties of null (reading 'contents')
    at _MoveControllerService.processGroupContainsComponent (move-controller-service.component.ts:184:26)
    at move-controller-service.component.ts:166:27
    at Array.forEach (<anonymous>)
    at _MoveControllerService.loadChildOptions (move-controller-service.component.ts:157:34)
    at new _MoveControllerService (move-controller-service.component.ts:99:14)
    at NodeInjectorFactory.MoveControllerService_Factory [as factory] (move-controller-service.component.ts:231:5)
    at getNodeInjectable (core.mjs:6060:44)
    at createRootComponent (core.mjs:17231:35)
    at ComponentFactory.create (core.mjs:17091:29)
    at ViewContainerRef2.createComponent (core.mjs:17498:47) 

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It appears this was happening when childProcessGroupOptions contained more than one option. The recursive logic in getProcessGroupById (and other methods with similar structure) isn't correct. When there are multiple children the following code

                    for (const pg of root.contents.processGroups) {
                        return this.getProcessGroupById(pg, processGroupId);
                    }

will return the result from the first child and not attempt to process any subsequent children.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've fixed the recursion logic.

Copy link
Contributor

@mcgilman mcgilman Oct 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for fixing the recursion issue. However getProcessGroupById returns null in a couple scenarios. That response value is used as though it's guaranteed non-null. We should protect against this.

@@ -63,7 +66,8 @@ import {
} from '../../../../state/property-verification/property-verification.selectors';
import { VerifyPropertiesRequestContext } from '../../../../state/property-verification';
import { BackNavigation } from '../../../../state/navigation';
import { NiFiCommon, Storage } from '@nifi/shared';
import { NiFiCommon, Storage, SelectOption, ComponentType, LARGE_DIALOG, SMALL_DIALOG, XL_DIALOG } from '@nifi/shared';
import { ComponentEntity } from './../flow/index';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import { ComponentEntity } from './../flow/index';
import { ComponentEntity } from '../flow';

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

import { ControllerServiceEntity, ParameterContextReferenceEntity } from '../../../../state/shared';
import { BreadcrumbEntity } from '../shared';
import { Revision } from './../../../../state/shared/index';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import { Revision } from './../../../../state/shared/index';
import { Revision } from '../../../../state/shared';

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

);

const processGroups: SelectOption[] = [];
if (request.breadcrumb != undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under what conditions is request.breadcrumb undefined? I think the store has an initial value but even if that isn't the case we could update the concatLatestFrom with the following:

this.store.select(selectBreadcrumb).pipe(isDefinedAndNotNull())

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to only use a single request model, but I've created 2 and removed the need to declare it as breadcrumb?

Comment on lines 696 to 700
const clone = Object.assign({}, request.request);
clone.processGroupEntity = request.processGroupEntity;
clone.childProcessGroupOptions = request.childProcessGroupOptions;
clone.parentControllerServices = request.controllerServices;
clone.breadcrumb = request.breadcrumb;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is a clone being created here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above. Removed need to clone.

@Freedom9339
Copy link
Contributor Author

@mcgilman I've completed the change/fixes requested. Thank You!

@Freedom9339
Copy link
Contributor Author

@mcgilman Sorry, I just realized my latest commit didn't go through. It's there now.

@Freedom9339
Copy link
Contributor Author

@mcgilman Any update on this? I just did a fresh rebase.

Copy link
Contributor

@mcgilman mcgilman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates @Freedom9339! I've left a few more comments below.

}

getProcessGroupById(root: any, processGroupId: string): any {
console.log('a');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this was left in inadvertently.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was. I've removed it.

value: child.value
};

const root = this.getProcessGroupById(currentProcessGroupEntity.component, child.value ?? '');
Copy link
Contributor

@mcgilman mcgilman Oct 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for fixing the recursion issue. However getProcessGroupById returns null in a couple scenarios. That response value is used as though it's guaranteed non-null. We should protect against this.

</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Cancel</button>
<button type="button" color="primary" (click)="submitForm()" mat-button>Move</button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This button should be disabled when the form is not valid. For instance, when only disabled options are present and the user cannot make a selection.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The button is now disabled when there no valid options.

}

submitForm() {
if (this.moveControllerServiceForm.get('processGroups')?.value != 'Process Group') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once the Submit button is disabled, I think we should be able to remove this check here as the form would never be submitted.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

<div>
<div>Service</div>
<div
class="accent-color font-medium overflow-ellipsis overflow-hidden whitespace-nowrap"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
class="accent-color font-medium overflow-ellipsis overflow-hidden whitespace-nowrap"
class="tertiary-color font-medium truncate"

<form class="controller-service-move-form" [formGroup]="moveControllerServiceForm">
<mat-dialog-content>
<div class="py-4 flex gap-x-3">
<div class="flex basis-2/3 flex-col gap-y-4 pr-4 overflow-hidden">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<div class="flex basis-2/3 flex-col gap-y-4 pr-4 overflow-hidden">
<div class="flex basis-1/2 flex-col gap-y-4 pr-4 overflow-hidden">

Thoughts on splitting the dialog space, half for the Group selection and half for the References?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it looks better. Done.

</mat-form-field>
</div>
</div>
<div class="flex basis-1/3 flex-col">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<div class="flex basis-1/3 flex-col">
<div class="flex basis-1/2 flex-col">

@Freedom9339
Copy link
Contributor Author

@mcgilman Thank you for the review. I've made the requested changes. Thank You.

Copy link
Contributor

@mcgilman mcgilman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Freedom9339 Thanks again for the updates! It's looking good. I've left a couple comments and I've noted a number of nit-picky coding style things to better follow some of the conventions currently practiced.

],
styleUrls: ['./move-controller-service.component.scss']
})
export class MoveControllerService extends CloseOnEscapeDialog {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move the move-controller-service folder under src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've moved this to ui/controller-service

</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Cancel</button>
<button type="button" color="primary" (click)="submitForm()" [disabled]="disableSubmit" mat-button>Move</button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use the state of the form instead of introducing a new field? I didn't try this out but maybe something like the following:

Suggested change
<button type="button" color="primary" (click)="submitForm()" [disabled]="disableSubmit" mat-button>Move</button>
<button type="button" color="primary" (click)="submitForm()" [disabled]="moveControllerServiceForm.invalid" mat-button>Move</button>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed this to use the state of the form.


// build the form
this.moveControllerServiceForm = this.formBuilder.group({
processGroups: new FormControl('Process Group', Validators.required)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Process Group is not a valid value for this field. We could initialize the value to null but we're setting the value below. We could instead determine the value before creating the form and then just use the correct value when creating the FormControl.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you mean by this. "Process Group" is what is shown whenever there is no process group selected. The only time this happens is if there are no valid process groups available.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first argument to the FormControl is the value. The string Process Group is not a valid value. What you see in the control when no value is select comes from the mat-label in the mat-form-field. If you want something else than the mat-label value you can specify a placeholder on the mat-select.

const parentBreadcrumb = breadcrumb.parentBreadcrumb;
if (parentBreadcrumb.permissions.canRead && parentBreadcrumb.permissions.canWrite) {
const option: SelectOption = {
text: parentBreadcrumb.breadcrumb.name + ' (Parent)',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please convert to a template string.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer used.

controllerId != null &&
!parentControllerServices.some((service) => service.id == controllerId)
) {
errorMsg += '[' + descriptors[descriptor].name + ']';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please convert to a template string if possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer used.

let errorMsg = '';
const descriptors = this.controllerService.component.descriptors;
for (const descriptor in descriptors) {
if (descriptors[descriptor].identifiesControllerService != undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (descriptors[descriptor].identifiesControllerService != undefined) {
if (descriptors[descriptor].identifiesControllerService) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer used.

const root = this.getProcessGroupById(currentProcessGroupEntity.component, child.value ?? '');
let errorMsg = '';

if (root == null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may make this more readable if we swap the if/else blocks and update the conditional to:

Suggested change
if (root == null) {
if (root) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer used.

if (root.id == processGroupId) {
return root;
} else {
if (root.contents != undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (root.contents != undefined) {
if (root.contents) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer used.

if (root.contents != undefined) {
for (const pg of root.contents.processGroups) {
const result = this.getProcessGroupById(pg, processGroupId);
if (result != null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (result != null) {
if (result) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer used.

Comment on lines 121 to 124
getProcessGroupWithContent(id: string): Observable<any> {
const params = new HttpParams().set('includeContent', true);
return this.httpClient.get(`${FlowService.API}/process-groups/${id}`, { params: params });
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is necessary for this feature to ensure that when moving a Controller Service it remains in scope for all components currently referencing it. However, I have some concerns about fetching the entire flow from the current Process Group when the Move dialog is opened. I wonder if we should consider a purpose built endpoint for does exactly what we need instead of returning everything. We've certainly seen some large flows over the years where I worry this could be problematic.

CC @exceptionfactory

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've created a new API endpoint, move-options, to get the information needed for the process groups. This shifts all the logic to the API, and greatly simplifies the UI form.

…I call. Moved move dialog to ui/controller-service.
</div>
<div>
<mat-form-field>
<mat-label>Process Group:</mat-label>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<mat-label>Process Group:</mat-label>
<mat-label>Process Group</mat-label>

Other mat-labels in the UI don't include a colon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
new ui Pull requests for work relating to the new user interface being developed.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants