Skip to content

Commit

Permalink
✨ feat: add environment key (#282)
Browse files Browse the repository at this point in the history
* feat: added key to environment

* added key tooltip & translations

* added current tag to current project and env

* code format

* fix tooltip style

* fix current tag style

* style fix

* i18n update

---------

Co-authored-by: deleteLater <mikcczhang@gmail.com>
  • Loading branch information
cosmos-explorer and deleteLater committed Mar 17, 2023
1 parent 030837d commit 984c235
Show file tree
Hide file tree
Showing 23 changed files with 451 additions and 235 deletions.
13 changes: 13 additions & 0 deletions modules/back-end/src/Api/Controllers/EnvironmentController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,17 @@ public async Task<ApiResponse<bool>> DeleteAsync(Guid id)
var success = await Mediator.Send(request);
return Ok(success);
}

[HttpGet("is-key-used")]
public async Task<ApiResponse<bool>> IsKeyUsedAsync(Guid projectId, string key)
{
var request = new IsKeyUsed
{
ProjectId = projectId,
Key = key
};

var isUsed = await Mediator.Send(request);
return Ok(isUsed);
}
}
4 changes: 2 additions & 2 deletions modules/back-end/src/Application/Bases/ErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ public static class ErrorCodes

// common
public const string NameIsRequired = nameof(NameIsRequired);

public const string KeyIsRequired = nameof(KeyIsRequired);

// onboarding
public const string OrganizationNameRequired = nameof(OrganizationNameRequired);
public const string ProjectNameRequired = nameof(ProjectNameRequired);
Expand All @@ -47,7 +48,6 @@ public static class ErrorCodes
public const string InvalidVariationType = nameof(InvalidVariationType);
public const string FeatureFlagIdIsRequired = nameof(FeatureFlagIdIsRequired);
public const string FeatureFlagVariationIdIsRequired = nameof(FeatureFlagVariationIdIsRequired);
public const string FeatureFlagKeyIsRequired = nameof(FeatureFlagKeyIsRequired);
public const string InvalidIntervalType = nameof(InvalidIntervalType);
public const string InvalidFrom = nameof(InvalidFrom);
public const string InvalidTo = nameof(InvalidTo);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public class GetFeatureFlagEndUserListValidator : AbstractValidator<GetFeatureFl
public GetFeatureFlagEndUserListValidator()
{
RuleFor(x => x.Filter.FeatureFlagKey)
.NotEmpty().WithErrorCode(ErrorCodes.FeatureFlagKeyIsRequired);
.NotEmpty().WithErrorCode(ErrorCodes.KeyIsRequired);

RuleFor(x => x.Filter.From)
.GreaterThan(0).WithErrorCode(ErrorCodes.InvalidFrom);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ public class CreateEnvironment : IRequest<EnvironmentVm>

public string Name { get; set; }

public string Key { get; set; }

public string Description { get; set; }
}

Expand All @@ -18,6 +20,9 @@ public CreateEnvironmentValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithErrorCode(ErrorCodes.NameIsRequired);

RuleFor(x => x.Key)
.NotEmpty().WithErrorCode(ErrorCodes.KeyIsRequired);
}
}

Expand All @@ -36,7 +41,7 @@ public CreateEnvironmentHandler(IEnvironmentService service, IMapper mapper, IEn

public async Task<EnvironmentVm> Handle(CreateEnvironment request, CancellationToken cancellationToken)
{
var env = new Environment(request.ProjectId, request.Name, request.Description);
var env = new Environment(request.ProjectId, request.Name, request.Key, request.Description);
await _service.AddOneAsync(env);

// add env built-in end-user properties
Expand Down
26 changes: 26 additions & 0 deletions modules/back-end/src/Application/Environments/IsKeyUsed.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace Application.Environments;

public class IsKeyUsed : IRequest<bool>
{
public Guid ProjectId { get; set; }

public string Key { get; set; }
}

public class IsKeyUsedHandler : IRequestHandler<IsKeyUsed, bool>
{
private readonly IEnvironmentService _service;

public IsKeyUsedHandler(IEnvironmentService service)
{
_service = service;
}

public async Task<bool> Handle(IsKeyUsed request, CancellationToken cancellationToken)
{
return await _service.AnyAsync(x =>
x.ProjectId == request.ProjectId &&
string.Equals(x.Key, request.Key, StringComparison.OrdinalIgnoreCase)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public CreateFeatureFlagValidator()
.NotEmpty().WithErrorCode(ErrorCodes.NameIsRequired);

RuleFor(x => x.Key)
.NotEmpty().WithErrorCode(ErrorCodes.FeatureFlagKeyIsRequired)
.NotEmpty().WithErrorCode(ErrorCodes.KeyIsRequired)
.Matches(FeatureFlag.KeyFormat).WithErrorCode(ErrorCodes.InvalidFlagKeyFormat);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public class GetInsightsValidator : AbstractValidator<GetInsights>
public GetInsightsValidator()
{
RuleFor(x => x.Filter.FeatureFlagKey)
.NotEmpty().WithErrorCode(ErrorCodes.FeatureFlagKeyIsRequired);
.NotEmpty().WithErrorCode(ErrorCodes.KeyIsRequired);

RuleFor(x => x.Filter.IntervalType)
.Must(IntervalType.IsDefined).WithErrorCode(ErrorCodes.InvalidIntervalType);
Expand Down
5 changes: 4 additions & 1 deletion modules/back-end/src/Domain/Environments/Environment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,21 @@ public class Environment : AuditedEntity

public string Name { get; set; }

public string Key { get; set; }

public string Description { get; set; }

public ICollection<Secret> Secrets { get; set; }

public ICollection<Setting> Settings { get; set; }

public Environment(Guid projectId, string name, string description = "")
public Environment(Guid projectId, string name, string key, string description = "")
{
Id = Guid.NewGuid();

ProjectId = projectId;
Name = name;
Key = key;
Description = description;
Secrets = new List<Secret>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public async Task<ProjectWithEnvs> AddWithEnvsAsync(Project project, IEnumerable
{
await MongoDb.CollectionOf<Project>().InsertOneAsync(project);

var envs = envNames.Select(envName => new Environment(project.Id, envName)).ToList();
var envs = envNames.Select(envName => new Environment(project.Id, envName, envName.ToLower())).ToList();
await MongoDb.CollectionOf<Environment>().InsertManyAsync(envs);

// add env built-in end-user properties
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,23 @@
<nz-form-item>
<nz-form-label nzRequired i18n="@@common.name">Name</nz-form-label>
<nz-form-control nzErrorTip="Environment name is mandatory!" i18n-nzErrorTip="@@org.project.envNameMandatory">
<input type="text" nz-input formControlName="name" placeholder="Name" i18n-placeholder="@@common.name"/>
<input type="text" nz-input (ngModelChange)="nameChange($event)" formControlName="name" placeholder="Name" i18n-placeholder="@@common.name"/>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label
nzRequired
i18n-nzTooltipTitle="@@org.project.env-key-description"
nzTooltipTitle="We use the key to give you friendly URLs. Keys should only contain letters, numbers, ., _ or -.">
Key
</nz-form-label>
<nz-form-control nzHasFeedback i18n-nzValidatingTip="@@common.validating" nzValidatingTip="Validating..." [nzErrorTip]="keyErrorTpl">
<input nz-input formControlName="key" i18n-placeholder="@@common.key-generated-from-name" placeholder="Key can be auto-generated from name" />
<ng-template #keyErrorTpl let-control>
<ng-container *ngIf="control.hasError('required')" i18n="@@common.key-cannot-be-empty">Key cannot be empty</ng-container>
<ng-container *ngIf="control.hasError('duplicated')" i18n="@@common.key-has-been-used">This key has been used</ng-container>
<ng-container *ngIf="control.hasError('unknown')" i18n="@@common.key-validation-failed">Key validation failed</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
<nz-form-item>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
import { NzMessageService } from 'ng-zorro-antd/message';
import { IEnvironment } from '@shared/types';
import { EnvService } from '@services/env.service';
import { ProjectService } from "@services/project.service";
import { generalResourceRNPattern, permissionActions } from "@shared/permissions";
import { PermissionsService } from "@services/permissions.service";
import { debounceTime, first, map, switchMap } from "rxjs/operators";
import { slugify } from "@utils/index";

@Component({
selector: 'app-env-drawer',
templateUrl: './env-drawer.component.html',
styleUrls: ['./env-drawer.component.less']
})
export class EnvDrawerComponent implements OnInit {
export class EnvDrawerComponent {

private _env: IEnvironment;

Expand All @@ -27,9 +29,11 @@ export class EnvDrawerComponent implements OnInit {
this.isEditing = env && !!env.id;
if (this.isEditing) {
this.title = $localize `:@@org.project.editEnv:Edit environment`;
this.initForm(true);
this.patchForm(env);
} else {
this.title = $localize `:@@org.project.addEnv:Add environment`;
this.initForm(false);
this.resetForm();
}
this._env = env;
Expand All @@ -50,22 +54,45 @@ export class EnvDrawerComponent implements OnInit {
private message: NzMessageService,
private projectSrv: ProjectService,
private permissionsService: PermissionsService
) { }

ngOnInit(): void {
this.initForm();
) {
}

initForm() {
initForm(isKeyDisabled: boolean) {
this.envForm = this.fb.group({
name: [null, [Validators.required]],
key: [{ disabled: isKeyDisabled, value: null }, Validators.required, this.keyAsyncValidator],
description: [null],
});
}

nameChange(name: string) {
if (this.isEditing) return;

let keyControl = this.envForm.get('key')!;
keyControl.setValue(slugify(name ?? ''));
keyControl.markAsDirty();
}

keyAsyncValidator = (control: FormControl) => control.valueChanges.pipe(
debounceTime(300),
switchMap(value => this.envService.isKeyUsed(this.env.projectId, value as string)),
map(isKeyUsed => {
switch (isKeyUsed) {
case true:
return { error: true, duplicated: true };
case undefined:
return { error: true, unknown: true };
default:
return null;
}
}),
first()
);

patchForm(env: Partial<IEnvironment>) {
this.envForm.patchValue({
name: env.name,
key: env.key,
description: env.description,
});
}
Expand All @@ -89,7 +116,7 @@ export class EnvDrawerComponent implements OnInit {

this.isLoading = true;

const { name, description } = this.envForm.value;
const { name, key, description } = this.envForm.value;
const projectId = this.env.projectId;

if (this.isEditing) {
Expand All @@ -101,20 +128,20 @@ export class EnvDrawerComponent implements OnInit {
.subscribe(
({id, name, description, secrets}) => {
this.isLoading = false;
this.close.emit({isEditing: true, env: { name, description, id, projectId, secrets }});
this.close.emit({isEditing: true, env: { name, description, id, key, projectId, secrets }});
this.message.success($localize `:@@org.project.envUpdateSuccess:Environment successfully updated`);
},
() => {
this.isLoading = false;
}
);
} else {
this.envService.postCreateEnv(this.env.projectId, { name, description, projectId })
this.envService.postCreateEnv(this.env.projectId, { name, key, description, projectId })
.pipe()
.subscribe(
({id, name, description, secrets}) => {
this.isLoading = false;
this.close.emit({isEditing: false, env: { name, description, id, projectId, secrets }});
this.close.emit({isEditing: false, env: { name, description, id, key, projectId, secrets }});
this.message.success($localize `:@@org.project.envCreateSuccess:Environment successfully created`);
},
() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
<nz-list class="overflow-y-list" style="--list-height: 320px" [nzHeader]="projectHeader">
<nz-list-item [ngClass]="{'item-selected': selectedProject?.id === project.id}"
*ngFor="let project of availableProjects" (click)="onSelectProject(project)">
{{ project.name }}
{{ project.name }} <nz-tag nzColor="green" *ngIf="isCurrentProject(project)" i18n="@@common.current">Current</nz-tag>
</nz-list-item>
<ng-template #projectHeader>
<span class="header" i18n="@@common.projects">Projects</span>
Expand All @@ -67,7 +67,7 @@
<nz-list class="overflow-y-list" style="--list-height: 320px" [nzHeader]="envHeader">
<nz-list-item [ngClass]="{'item-selected': selectedEnv?.id === env.id}" *ngFor="let env of availableEnvs"
(click)="onSelectEnv(env)">
{{ env.name }}
{{ env.name }} <nz-tag nzColor="green" *ngIf="isCurrentEnv(env)" i18n="@@common.current">Current</nz-tag>
</nz-list-item>
<ng-template #envHeader>
<span class="header" i18n="@@common.environments">Environments</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@
}
}

.ant-tag {
font-size: 12px;
border-radius: 8px;
white-space: nowrap;
padding: 2px 8px;
}

.subscription-plan-wrapper {
margin-right: 16px;
cursor: pointer;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ export class HeaderComponent implements OnInit {
});
}

isCurrentProject(project: IProject): boolean {
return this.currentProjectEnv?.projectId === project.id;
}

isCurrentEnv(env: IEnvironment): boolean {
return this.currentProjectEnv?.envId === env.id;
}

canListProjects = false;
get availableProjects() {
return this.canListProjects ? this.allProjects : [];
Expand Down Expand Up @@ -104,6 +112,7 @@ export class HeaderComponent implements OnInit {
projectId: this.selectedProject.id,
projectName: this.selectedProject.name,
envId: this.selectedEnv.id,
envKey: this.selectedEnv.key,
envName: this.selectedEnv.name,
envSecret: this.selectedEnv.secrets[0].value
};
Expand Down
9 changes: 8 additions & 1 deletion modules/front-end/src/app/core/services/env.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { environment } from 'src/environments/environment';
import { IEnvironment } from '@shared/types';
import { catchError } from "rxjs/operators";

@Injectable({
providedIn: 'root'
Expand Down Expand Up @@ -32,4 +33,10 @@ export class EnvService {
const url = this.baseUrl.replace(/#projectId/ig, `${projectId}`) + `/${envId}`;
return this.http.delete(url);
}

isKeyUsed(projectId: string, key: string): Observable<boolean> {
const url = this.baseUrl.replace(/#projectId/ig, `${projectId}`) + `/is-key-used?key=${key}`;

return this.http.get<boolean>(url).pipe(catchError(() => of(undefined)));
}
}
Loading

0 comments on commit 984c235

Please sign in to comment.