Refactor artifact-list component (#17577)

Signed-off-by: AllForNothing <sshijun@vmware.com>

Signed-off-by: AllForNothing <sshijun@vmware.com>
This commit is contained in:
Shijun Sun 2022-09-20 17:16:16 +08:00 committed by GitHub
parent b6c978c7f7
commit 3d8959be49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 846 additions and 1260 deletions

View File

@ -5,21 +5,11 @@ import {
UserPermissionService,
USERSTATICPERMISSION,
} from '../../../../../shared/services';
import { LabelState } from './artifact-list/artifact-list-tab/artifact-list-tab.component';
import { forkJoin, Observable } from 'rxjs';
import { LabelService } from 'ng-swagger-gen/services/label.service';
import { Label } from 'ng-swagger-gen/models/label';
import { ErrorHandler } from '../../../../../shared/units/error-handler';
import { clone } from '../../../../../shared/units/utils';
const PAGE_SIZE: number = 100;
@Injectable()
export class ArtifactListPageService {
private _scanBtnState: ClrLoadingState;
private _allLabels: LabelState[] = [];
imageStickLabels: LabelState[] = [];
imageFilterLabels: LabelState[] = [];
private _hasEnabledScanner: boolean = false;
private _hasAddLabelImagePermission: boolean = false;
private _hasRetagImagePermission: boolean = false;
@ -28,14 +18,10 @@ export class ArtifactListPageService {
constructor(
private scanningService: ScanningResultService,
private labelService: LabelService,
private userPermissionService: UserPermissionService,
private errorHandlerService: ErrorHandler
) {}
resetClonedLabels() {
this.imageStickLabels = clone(this._allLabels);
this.imageFilterLabels = clone(this._allLabels);
}
getScanBtnState(): ClrLoadingState {
return this._scanBtnState;
}
@ -88,115 +74,6 @@ export class ArtifactListPageService {
);
}
private _getAllLabels(projectId: number): void {
// get all project labels
this._allLabels = []; // reset
this.labelService
.ListLabelsResponse({
pageSize: PAGE_SIZE,
page: 1,
scope: 'p',
projectId: projectId,
})
.subscribe(res => {
if (res.headers) {
const xHeader: string = res.headers.get('X-Total-Count');
const totalCount = parseInt(xHeader, 0);
let arr = res.body || [];
if (totalCount <= PAGE_SIZE) {
// already gotten all project labels
if (arr && arr.length) {
arr.forEach(data => {
this._allLabels.push({
iconsShow: false,
label: data,
show: true,
});
});
this.resetClonedLabels();
}
} else {
// get all the project labels in specified times
const times: number = Math.ceil(totalCount / PAGE_SIZE);
const observableList: Observable<Label[]>[] = [];
for (let i = 2; i <= times; i++) {
observableList.push(
this.labelService.ListLabels({
page: i,
pageSize: PAGE_SIZE,
scope: 'p',
projectId: projectId,
})
);
}
this._handleLabelRes(observableList, arr);
}
}
});
// get all global labels
this.labelService
.ListLabelsResponse({
pageSize: PAGE_SIZE,
page: 1,
scope: 'g',
})
.subscribe(res => {
if (res.headers) {
const xHeader: string = res.headers.get('X-Total-Count');
const totalCount = parseInt(xHeader, 0);
let arr = res.body || [];
if (totalCount <= PAGE_SIZE) {
// already gotten all global labels
if (arr && arr.length) {
arr.forEach(data => {
this._allLabels.push({
iconsShow: false,
label: data,
show: true,
});
});
this.resetClonedLabels();
}
} else {
// get all the global labels in specified times
const times: number = Math.ceil(totalCount / PAGE_SIZE);
const observableList: Observable<Label[]>[] = [];
for (let i = 2; i <= times; i++) {
observableList.push(
this.labelService.ListLabels({
page: i,
pageSize: PAGE_SIZE,
scope: 'g',
})
);
}
this._handleLabelRes(observableList, arr);
}
}
});
}
private _handleLabelRes(
observableList: Observable<Label[]>[],
arr: Label[]
) {
forkJoin(observableList).subscribe(response => {
if (response && response.length) {
response.forEach(item => {
arr = arr.concat(item);
});
arr.forEach(data => {
this._allLabels.push({
iconsShow: false,
label: data,
show: true,
});
});
this.resetClonedLabels();
}
});
}
private _getPermissionRule(projectId: number): void {
const permissions = [
{
@ -226,10 +103,6 @@ export class ArtifactListPageService {
this._hasRetagImagePermission = results[1];
this._hasDeleteImagePermission = results[2];
this._hasScanImagePermission = results[3];
// only has label permission
if (this._hasAddLabelImagePermission) {
this._getAllLabels(projectId);
}
},
error => this.errorHandlerService.error(error)
);

View File

@ -0,0 +1,92 @@
<div #filterArea id="filterArea" class="clr-row">
<clr-icon
id="{{ searchId }}"
*ngIf="!opened"
shape="search"
size="20"
class="search-btn"
(click)="opened = true; dropdownOpened = true"></clr-icon>
<ng-container *ngIf="opened">
<div class="clr-control-container m-r-10px">
<div class="clr-select-wrapper">
<select
id="{{ typeSelectId }}"
class="clr-select"
[(ngModel)]="filterByType"
(change)="selectFilterType()">
<option
*ngFor="let filter of multipleFilter"
value="{{ filter.filterBy }}">
{{ filter.filterByShowText | translate }}
</option>
</select>
</div>
</div>
<div class="dropdown" [class.open]="dropdownOpened">
<div
class="dropdown-toggle border-bottom-color"
(click)="dropdownOpened = !dropdownOpened">
<clr-icon
class="search-dropdown-toggle"
shape="search"
size="20"
(click)="opened = false"></clr-icon>
<div class="clr-control-container" *ngIf="!selectedValue">
<div class="clr-input-wrapper">
<input
readonly
placeholder="{{
'ARTIFACT.FILTER_FOR_ARTIFACTS' | translate
}}"
class="clr-input" />
</div>
</div>
<span *ngIf="selectedValue">
<span *ngIf="filterByType === 'labels'"
><hbr-label-piece
[label]="selectedValue"></hbr-label-piece
></span>
<span *ngIf="filterByType !== 'labels'">{{
selectedValue
}}</span>
</span>
</div>
<div class="dropdown-menu">
<ng-container
*ngIf="filterByType === multipleFilter[0].filterBy">
<div
(click)="selectValue(item)"
class="dropdown-item"
*ngFor="let item of multipleFilter[0].listItem">
{{ item.showItem | translate }}
</div>
</ng-container>
<ng-container
*ngIf="filterByType === multipleFilter[1].filterBy">
<div
(click)="selectValue(item)"
class="dropdown-item"
*ngFor="let item of multipleFilter[1].listItem">
{{ item.showItem | translate }}
</div>
</ng-container>
<ng-container
*ngIf="filterByType === multipleFilter[2].filterBy">
<label class="clr-control-label">{{
'REPOSITORY.FILTER_ARTIFACT_BY_LABEL' | translate
}}</label>
<app-label-selector
(clickLabel)="selectValue($event)"
[scope]="'p'"
[projectId]="projectId"
[width]="200"
[ownedLabels]="getSelectLabel()">
</app-label-selector>
</ng-container>
</div>
</div>
</ng-container>
<span class="filter-divider" *ngIf="withDivider"></span>
</div>

View File

@ -0,0 +1,37 @@
.search-btn {
cursor: pointer;
}
.dropdown-toggle {
height: 1.3rem;
width: 200px;
display: flex;
justify-content: left;
border-bottom: 0.05rem solid;
}
.clr-control-label {
margin-bottom: 1rem;
margin-left: 0.5rem;
}
.filter-divider {
display: inline-block;
height: 24px;
width: 1px;
margin-right: 6px;
margin-left: 6px;
}
.clr-row {
align-items: center;
margin: 0;
}
.m-r-10px {
margin-right: 10px;
}
.search-dropdown-toggle {
margin-right: 5px;
}

View File

@ -0,0 +1,50 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ArtifactFilterComponent } from './artifact-filter.component';
import { SharedTestingModule } from '../../../../../../../../shared/shared.module';
import { NO_ERRORS_SCHEMA } from '@angular/core';
describe('ArtifactFilterComponent', () => {
let component: ArtifactFilterComponent;
let fixture: ComponentFixture<ArtifactFilterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
schemas: [NO_ERRORS_SCHEMA],
imports: [SharedTestingModule],
declarations: [ArtifactFilterComponent],
}).compileComponents();
fixture = TestBed.createComponent(ArtifactFilterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('Expanding should work', async () => {
await fixture.whenStable();
const searchIcon = fixture.nativeElement.querySelector(
`#${component.searchId}`
);
searchIcon.click();
fixture.detectChanges();
await fixture.whenStable();
let selector;
selector = fixture.nativeElement.querySelector(
`#${component.typeSelectId}`
);
expect(selector).toBeTruthy();
const searchIconClose = fixture.nativeElement.querySelector(
`.search-dropdown-toggle`
);
searchIconClose.click();
fixture.detectChanges();
await fixture.whenStable();
selector = fixture.nativeElement.querySelector(
`#${component.typeSelectId}`
);
expect(!!selector).toBeFalse();
});
});

View File

@ -0,0 +1,90 @@
import {
Component,
ElementRef,
EventEmitter,
Input,
Output,
Renderer2,
ViewChild,
} from '@angular/core';
import { ArtifactFilterEvent, multipleFilter } from '../../../../artifact';
import { Label } from '../../../../../../../../../../ng-swagger-gen/models/label';
@Component({
selector: 'app-artifact-filter',
templateUrl: './artifact-filter.component.html',
styleUrls: ['./artifact-filter.component.scss'],
})
export class ArtifactFilterComponent {
@Input()
withDivider: boolean = false;
@ViewChild('filterArea')
filterArea: ElementRef;
@Input()
projectId: number;
opened: boolean = false;
multipleFilter = multipleFilter;
filterByType: string = multipleFilter[0].filterBy;
dropdownOpened: boolean = true;
selectedValue: string | Label;
@Output()
filterEvent = new EventEmitter<ArtifactFilterEvent>();
readonly searchId: string = 'search-btn';
readonly typeSelectId: string = 'type-select';
constructor(private renderer: Renderer2) {
// click outside, then close dropdown
this.renderer.listen('window', 'click', (e: Event) => {
if (
!(
(e.target as any).id === this.searchId ||
(e.target as any).id === this.typeSelectId ||
this.filterArea.nativeElement.contains(e.target)
)
) {
this.dropdownOpened = false;
}
});
}
selectFilterType() {
this.selectedValue = null;
this.dropdownOpened = true;
if (this.filterByType === this.multipleFilter[2].filterBy) {
this.filterEvent.emit({ type: this.filterByType, isLabel: true });
} else {
this.filterEvent.emit({ type: this.filterByType, isLabel: false });
}
}
selectValue(value: any) {
if (this.filterByType === this.multipleFilter[2].filterBy) {
// for labels
if (value.isAdd) {
this.selectedValue = value.label;
} else {
this.selectedValue = null;
}
this.filterEvent.emit({
type: this.filterByType,
isLabel: true,
label: this.selectedValue as Label,
});
} else {
this.selectedValue = value?.filterText;
this.filterEvent.emit({
type: this.filterByType,
isLabel: false,
stringValue: this.selectedValue as string,
});
}
}
getSelectLabel(): Label[] {
if (
this.filterByType === this.multipleFilter[2].filterBy &&
this.selectedValue
) {
return [this.selectedValue as Label];
}
return [];
}
}

View File

@ -10,7 +10,6 @@
<clr-datagrid
[clrDgLoading]="loading"
(clrDgRefresh)="clrDgRefresh($event)"
class="datagrid-top"
[(clrDgSelected)]="selectedRow">
<clr-dg-action-bar class="action-bar">
<div>
@ -55,6 +54,7 @@
clrPosition="bottom-left"
*clrIfOpen>
<div
id="artifact-list-copy-digest"
class="action-dropdown-item no-border"
aria-label="copy digest"
clrDropdownItem
@ -66,6 +66,7 @@
</div>
<clr-dropdown>
<button
id="artifact-list-add-labels"
class="action-dropdown-item"
clrDropdownTrigger
[disabled]="
@ -73,68 +74,29 @@
!hasAddLabelImagePermission ||
depth ||
inprogress
"
(click)="addLabels()">
">
{{ 'REPOSITORY.ADD_LABELS' | translate }}
</button>
<clr-dropdown-menu
[hidden]="!selectedRow.length">
<div class="filter-grid">
<div class="filter-grid" clrDropdownItem>
<label class="dropdown-header">{{
'REPOSITORY.ADD_LABEL_TO_IMAGE'
| translate
}}</label>
<div
class="form-group filter-label-input">
<input
clrInput
type="text"
placeholder="Filter labels"
[(ngModel)]="stickName"
(keyup)="
handleStickInputFilter()
" />
</div>
<div
[hidden]="
artifactListPageService
?.imageStickLabels.length
<app-label-selector
[width]="200"
[ownedLabels]="
selectedRow[0]?.labels
"
class="no-labels">
{{ 'LABEL.NO_LABELS' | translate }}
</div>
<div
[hidden]="
!artifactListPageService
?.imageStickLabels.length
"
class="has-label">
<button
type="button"
class="dropdown-item"
clrDropdownItem
*ngFor="
let label of artifactListPageService?.imageStickLabels
"
[hidden]="!label.show"
(click)="stickLabel(label)">
<clr-icon
shape="check"
class="pull-left"
[hidden]="!label.iconsShow">
</clr-icon>
<div class="labelDiv">
<hbr-label-piece
[label]="label.label"
[labelWidth]="130">
</hbr-label-piece>
</div>
</button>
</div>
(clickLabel)="stickLabel($event)"
[projectId]="projectId"
[scope]="'p'"></app-label-selector>
</div>
</clr-dropdown-menu>
</clr-dropdown>
<div
id="artifact-list-copy"
class="action-dropdown-item"
aria-label="retag"
[clrDisabled]="
@ -162,161 +124,11 @@
</clr-dropdown-menu>
</clr-dropdown>
</div>
<div class="row flex-items-xs-right rightPos">
<div id="filterArea" *ngIf="!depth">
<div
class="filterLabelPiece"
*ngIf="
openLabelFilterPiece &&
filterByType === 'labels'
"
[style.left.px]="110">
<hbr-label-piece
*ngIf="showlabel"
[hidden]="!filterOneLabel"
[label]="filterOneLabel"
[labelWidth]="130"></hbr-label-piece>
</div>
<div class="flex-xs-middle">
<div class="execution-select">
<div
class="select filter-tag"
[hidden]="!openSelectFilterPiece">
<clr-select-container>
<select
clrSelect
[(ngModel)]="filterByType"
(change)="selectFilterType()">
<option
*ngFor="
let filter of mutipleFilter
"
value="{{ filter.filterBy }}">
{{
filter.filterByShowText
| translate
}}
</option>
</select>
</clr-select-container>
</div>
<div class="flex-xs-middle">
<hbr-filter
[withDivider]="true"
[readonly]="isFilterReadonly"
filterPlaceholder="{{
getFilterPlaceholder() | translate
}}"
(filterEvt)="
doSearchArtifactByFilter($event)
"
(openFlag)="openFlagEvent($event)"
[currentValue]="lastFilteredTagName">
</hbr-filter>
<div
[hidden]="!openSelectFilterPiece"
class="label-filter-panel list-filter">
<div
*ngFor="
let filter of mutipleFilter
">
<ul
class="list-unstyled"
*ngIf="
filterByType ===
filter.filterBy
">
<li
class="cursor-pointer"
(click)="
selectFilter(
item.showItem,
item.filterText
)
"
*ngFor="
let item of filter.listItem
">
{{
item.showItem
| translate
}}
</li>
</ul>
</div>
</div>
<div
class="label-filter-panel"
[hidden]="
!(
openLabelFilterPanel &&
filterByType === 'labels'
)
">
<a
class="filterClose"
(click)="closeFilter()"
>&times;</a
>
<label
class="filterLabelHeader filter-dark"
>{{
'REPOSITORY.FILTER_ARTIFACT_BY_LABEL'
| translate
}}</label
>
<div class="form-group mb-05">
<input
clrInput
type="text"
placeholder="Filter labels"
[(ngModel)]="filterName"
(keyup)="handleInputFilter()" />
</div>
<div
[hidden]="
artifactListPageService
?.imageFilterLabels.length
"
class="no-labels">
{{ 'LABEL.NO_LABELS' | translate }}
</div>
<div
[hidden]="
!artifactListPageService
?.imageFilterLabels.length
"
class="has-label">
<button
type="button"
class="labelBtn"
*ngFor="
let label of artifactListPageService?.imageFilterLabels
"
[hidden]="!label.show"
(click)="
rightFilterLabel(label)
">
<clr-icon
shape="check"
class="pull-left"
[hidden]="
!label.iconsShow
"></clr-icon>
<div class="labelDiv top-3-px">
<hbr-label-piece
[label]="label.label"
[labelWidth]="
160
"></hbr-label-piece>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="right-pos">
<app-artifact-filter
[withDivider]="true"
(filterEvent)="filterEvent($event)"
[projectId]="projectId"></app-artifact-filter>
<span class="refresh-btn" (click)="refresh()">
<clr-icon shape="refresh"></clr-icon>
</span>
@ -649,27 +461,23 @@
<div
class="signpost-item"
[hidden]="artifact.labels?.length <= 1">
<div class="trigger-item">
<clr-signpost>
<button
class="btn btn-link"
clrSignpostTrigger>
...
</button>
<clr-signpost-content
[clrPosition]="'top-middle'"
*clrIfOpen>
<div>
<hbr-label-piece
*ngFor="
let label of artifact.labels
"
[label]="label">
</hbr-label-piece>
</div>
</clr-signpost-content>
</clr-signpost>
</div>
<clr-signpost>
<button class="btn btn-link" clrSignpostTrigger>
...
</button>
<clr-signpost-content
[clrPosition]="'top-middle'"
*clrIfOpen>
<div
*ngFor="let label of artifact.labels"
class="margin-5px">
<hbr-label-piece
[labelWidth]="130"
[label]="label">
</hbr-label-piece>
</div>
</clr-signpost-content>
</clr-signpost>
</div>
</div>
</clr-dg-cell>

View File

@ -1,52 +1,4 @@
@mixin text-overflow
{
overflow: hidden;
text-overflow: ellipsis;
word-wrap:break-word;
white-space: nowrap
}
@mixin text-overflow-param($width) {
width: $width;
@include text-overflow;
}
@mixin grid-right-top-pos{
position: absolute;
z-index: 100;
right: 35px;
margin-top: 4px;
}
@mixin absolute-center($width:108px) {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
width: $width !important;
height: $width !important;
}
@mixin flex-center {
display: flex;
justify-content: center;
align-items: center;
}
@mixin dropdown-as-action-button {
margin: .25rem .5rem .25rem 0;
}
.option-right {
padding-right: 18px;
padding-bottom: 6px;
}
.refresh-btn {
margin-top: 8px;
cursor: pointer;
}
@ -54,24 +6,11 @@
color: #007cbb;
}
.sub-header-title {
margin: 12px 0;
}
.embeded-datagrid {
width: 98%;
float: right;
}
.hidden-tag {
display: block;
height: 0;
}
:host::ng-deep .datagrid-placeholder {
display: none;
}
.truncated {
width: 100px;
line-height: 20px;
@ -82,196 +21,16 @@
text-overflow: ellipsis;
}
.copy-failed {
color: red;
margin-right: 6px;
}
:host ::ng-deep .datagrid clr-dg-column {
min-width: 80px;
}
/* stylelint-disable */
.rightPos {
position: absolute;
z-index: 1;
right: 35px;
.right-pos {
margin-right: 35px;
display: flex;
}
.btn-group .dropdown-menu clr-icon {
display: block;
}
.dropdown-menu .dropdown-item {
position: relative;
padding-left: 0.5rem;
padding-right: 0.5rem;
line-height: 1.3rem;
height: 1.3rem;
}
.dropdown-menu input {
position: relative;
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.pull-left {
display: inline-block;
float: left;
}
.pull-right {
display: inline-block;
float: right;
}
.btn-link {
display: inline-flex;
min-width: 15px;
vertical-align: super;
}
.trigger-item,
.signpost-item {
display: inline;
}
.signpost-content-body .label {
margin: 0.3rem;
}
.labelDiv {
position: absolute;
left: 34px;
top: 0;
}
.trigger-item hbr-label-piece {
display: flex !important;
margin: 6px 0;
}
:host::ng-deep .signpost-content {
min-width: 4rem;
}
:host::ng-deep .signpost-content-body {
padding: 0 0.4rem;
}
:host::ng-deep .signpost-content-header {
display: none;
}
.filterLabelPiece {
position: absolute;
top: 8px;
z-index: 1;
}
.dropdown .dropdown-toggle.btn {
@include dropdown-as-action-button;
}
.label-filter-panel {
display: flex;
position: relative;
flex-direction: column;
flex-direction: column;
padding: .5rem 0;
border: 1px solid #ccc;
box-shadow: 0 1px 0.125rem hsl(0deg 0% 45% / 25%);
min-width: 5rem;
max-width: 15rem;
border-radius: 0.125rem;
.form-group input {
position: relative;
margin-left: 0.5rem;
margin-right: 0.5rem;
}
}
.no-labels {
padding-left: 10px;
}
.has-label {
max-height: 300px;
overflow-y: auto;
}
.filter-grid {
display: grid;
}
.labelBtn {
position: relative;
overflow: hidden;
font-size: 0.58333rem;
letter-spacing: normal;
font-weight: 400;
background: transparent;
border: 0;
color: #565656;
cursor: pointer;
display: block;
margin: 0;
width: 100%;
height: 30px;
text-transform: none;
}
.labelBtn:focus {
outline: none;
}
.filterLabelHeader {
font-size: 0.5rem;
font-weight: 600;
letter-spacing: normal;
padding: 0 0.5rem;
line-height: 0.75rem;
margin: 0;
}
.filterClose {
position: absolute;
right: 8px;
top: 5px;
cursor: pointer;
font-size: 20px;
}
.retag-modal-body {
overflow-y: hidden;
}
hbr-image-name-input {
width: 100%;
align-items: center;
}
.tag-row {
position: relative;
}
.datagrid-top {
.flex-max-width {
max-width: 220px;
min-width: 200px;
}
.icon-cell {
max-width: 1.5rem;
min-width: 1.5rem;
clr-icon {
cursor: pointer;
}
}
}
.color-green {
color: #1d5100;
}
@ -284,12 +43,6 @@ hbr-image-name-input {
color: #565656;
}
clr-datagrid {
::ng-deep .datagrid-table {
position: static;
}
}
.cell {
display: flex;
align-items: center;
@ -301,17 +54,6 @@ clr-datagrid {
margin-left: 8px;
}
.immutable {
padding-right: 94px;
position: relative;
span {
position: absolute;
right: 0;
margin-right: 0;
}
}
.white-normal {
white-space: normal;
}
@ -322,15 +64,6 @@ clr-datagrid {
flex-shrink:0;
}
.max-width-38 {
max-width: 38px !important;
min-width: 0 !important;
}
clr-datagrid {
height: auto !important;
}
.artifact-icon {
width: 0.8rem;
height: 0.8rem;
@ -345,10 +78,6 @@ clr-datagrid {
}
}
.w-rem-4 {
min-width: 4rem !important;
}
.tag-header-color {
color: #fff;
}
@ -393,20 +122,6 @@ clr-datagrid {
}
}
.filter-label-input {
::ng-deep {
.clr-input-wrapper {
max-height: 2rem;
}
}
}
.scan-btn{
margin-top: -.3rem;
}
.eslip {
margin-left: -3px;
}
@ -426,16 +141,6 @@ clr-datagrid {
}
}
.list-filter {
width:7rem;
margin-left: 0.8rem;
padding: 0;
li {
padding-left: 1rem;
}
}
.no-border:focus {
outline: none;
}
@ -484,8 +189,36 @@ clr-datagrid {
.width-p-75 {
width: 75%;
}
.action-bar {
display: flex;
align-items: center;
justify-content: space-between;
}
.signpost-item {
margin-left: -24px;
height: 10px;
display: flex;
align-items: center;
}
.margin-5px {
margin: 5px;
}
.flex-max-width {
max-width: 220px;
min-width: 200px;
}
.filter-grid {
cursor: unset;
padding: 0;
}
.filter-grid:hover {
color: unset;
background-color: unset;
}

View File

@ -3,21 +3,15 @@ import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ArtifactListTabComponent } from './artifact-list-tab.component';
import { of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http';
import { HttpHeaders, HttpResponse } from '@angular/common/http';
import { ActivatedRoute, Router } from '@angular/router';
import {
ArtifactDefaultService,
ArtifactService,
} from '../../../artifact.service';
import {
Label,
ProjectDefaultService,
ProjectService,
ScanningResultDefaultService,
ScanningResultService,
UserPermissionDefaultService,
UserPermissionService,
USERSTATICPERMISSION,
} from '../../../../../../../shared/services';
import { ArtifactFront as Artifact } from '../../../artifact';
import { ErrorHandler } from '../../../../../../../shared/units/error-handler';
@ -25,30 +19,25 @@ import { OperationService } from '../../../../../../../shared/components/operati
import { ArtifactService as NewArtifactService } from '../../../../../../../../../ng-swagger-gen/services/artifact.service';
import { Tag } from '../../../../../../../../../ng-swagger-gen/models/tag';
import { SharedTestingModule } from '../../../../../../../shared/shared.module';
import { LabelService } from '../../../../../../../../../ng-swagger-gen/services/label.service';
import { Registry } from '../../../../../../../../../ng-swagger-gen/models/registry';
import { AppConfigService } from '../../../../../../../services/app-config.service';
import { ArtifactListPageService } from '../../artifact-list-page.service';
import { ClrLoadingState } from '@clr/angular';
import { Accessory } from 'ng-swagger-gen/models/accessory';
import { ArtifactModule } from '../../../artifact.module';
describe('ArtifactListTabComponent (inline template)', () => {
let comp: ArtifactListTabComponent;
let fixture: ComponentFixture<ArtifactListTabComponent>;
let userPermissionService: UserPermissionService;
let spyLabels: jasmine.Spy;
let spyLabels1: jasmine.Spy;
let spyScanner: jasmine.Spy;
const scannerMock = {
disabled: false,
name: 'Trivy',
};
const mockActivatedRoute = {
snapshot: {
params: {
id: 1,
repo: 'test',
digest: 'ABC',
parent: {
parent: {
id: 1,
repo: 'test',
digest: 'ABC',
},
},
},
data: {
projectResolver: {
@ -182,77 +171,9 @@ describe('ArtifactListTabComponent (inline template)', () => {
pull_time: '0001-01-01T00:00:00Z',
},
];
let filtereName = '';
let mockLabels: Label[] = [
{
color: '#9b0d54',
creation_time: '',
description: '',
id: 1,
name: 'label0-g',
project_id: 0,
scope: 'g',
update_time: '',
},
{
color: '#9b0d54',
creation_time: '',
description: '',
id: 2,
name: 'label1-g',
project_id: 0,
scope: 'g',
update_time: '',
},
];
let mockLabels1: Label[] = [
{
color: '#9b0d54',
creation_time: '',
description: '',
id: 1,
name: 'label0-g',
project_id: 1,
scope: 'p',
update_time: '',
},
{
color: '#9b0d54',
creation_time: '',
description: '',
id: 2,
name: 'label1-g',
project_id: 1,
scope: 'p',
update_time: '',
},
];
let mockHasAddLabelImagePermission: boolean = true;
let mockHasRetagImagePermission: boolean = true;
let mockHasDeleteImagePermission: boolean = true;
let mockHasScanImagePermission: boolean = true;
const mockErrorHandler = {
error: () => {},
};
const permissions = [
{
resource: USERSTATICPERMISSION.REPOSITORY_ARTIFACT_LABEL.KEY,
action: USERSTATICPERMISSION.REPOSITORY_ARTIFACT_LABEL.VALUE.CREATE,
},
{
resource: USERSTATICPERMISSION.REPOSITORY.KEY,
action: USERSTATICPERMISSION.REPOSITORY.VALUE.PULL,
},
{
resource: USERSTATICPERMISSION.ARTIFACT.KEY,
action: USERSTATICPERMISSION.ARTIFACT.VALUE.DELETE,
},
{
resource: USERSTATICPERMISSION.REPOSITORY_TAG_SCAN_JOB.KEY,
action: USERSTATICPERMISSION.REPOSITORY_TAG_SCAN_JOB.VALUE.CREATE,
},
];
const mockRouter = {
events: {
subscribe: () => {
@ -285,15 +206,10 @@ describe('ArtifactListTabComponent (inline template)', () => {
return of(null).pipe(delay(0));
},
listArtifactsResponse: () => {
if (filtereName === 'sha256:3e33e3e3') {
return of({
body: [mockArtifacts[1]],
});
} else {
return of({
body: mockArtifacts,
}).pipe(delay(0));
}
return of({
headers: new HttpHeaders({ 'x-total-count': '2' }),
body: mockArtifacts,
}).pipe(delay(0));
},
deleteArtifact: () => of(null),
getIconsFromBackEnd() {
@ -317,9 +233,6 @@ describe('ArtifactListTabComponent (inline template)', () => {
};
const mockedArtifactListPageService = {
imageStickLabels: [],
imageFilterLabels: [],
resetClonedLabels() {},
getScanBtnState(): ClrLoadingState {
return ClrLoadingState.DEFAULT;
},
@ -342,7 +255,7 @@ describe('ArtifactListTabComponent (inline template)', () => {
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SharedTestingModule],
imports: [SharedTestingModule, ArtifactModule],
schemas: [NO_ERRORS_SCHEMA],
declarations: [ArtifactListTabComponent],
providers: [
@ -354,15 +267,10 @@ describe('ArtifactListTabComponent (inline template)', () => {
{ provide: AppConfigService, useValue: mockedAppConfigService },
{ provide: Router, useValue: mockRouter },
{ provide: ArtifactService, useValue: mockNewArtifactService },
{ provide: ProjectService, useClass: ProjectDefaultService },
{
provide: ScanningResultService,
useClass: ScanningResultDefaultService,
},
{
provide: UserPermissionService,
useClass: UserPermissionDefaultService,
},
{ provide: ErrorHandler, useValue: mockErrorHandler },
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: OperationService, useValue: mockOperationService },
@ -374,43 +282,12 @@ describe('ArtifactListTabComponent (inline template)', () => {
}).compileComponents();
});
beforeEach(() => {
beforeEach(async () => {
fixture = TestBed.createComponent(ArtifactListTabComponent);
comp = fixture.componentInstance;
comp.projectId = 1;
comp.repoName = 'library/nginx';
comp.registryUrl = 'http://registry.testing.com';
let labelService: LabelService;
userPermissionService = fixture.debugElement.injector.get(
UserPermissionService
);
let http: HttpClient;
http = fixture.debugElement.injector.get(HttpClient);
spyScanner = spyOn(http, 'get').and.returnValue(of(scannerMock));
spyOn(userPermissionService, 'hasProjectPermissions')
.withArgs(comp.projectId, permissions)
.and.returnValue(
of([
mockHasAddLabelImagePermission,
mockHasRetagImagePermission,
mockHasDeleteImagePermission,
mockHasScanImagePermission,
])
);
labelService = fixture.debugElement.injector.get(LabelService);
const response: HttpResponse<Array<Registry>> = new HttpResponse<
Array<Registry>
>({
headers: new HttpHeaders({ 'x-total-count': [].length.toString() }),
body: mockLabels,
});
spyLabels = spyOn(labelService, 'ListLabelsResponse').and.returnValues(
of(response).pipe(delay(0))
);
spyLabels1 = spyOn(labelService, 'ListLabels')
.withArgs({ projectId: comp.projectId })
.and.returnValues(of(mockLabels1).pipe(delay(0)));
fixture.detectChanges();
await fixture.whenStable();
comp.loading = false;
fixture.detectChanges();
});
@ -426,33 +303,63 @@ describe('ArtifactListTabComponent (inline template)', () => {
expect(el.textContent).toBeTruthy();
expect(el.textContent.trim()).toEqual('sha256:4875cda3');
});
it('should filter data by keyword', async () => {
fixture.detectChanges();
await fixture.whenStable();
filtereName = 'sha256:3e33e3e3';
comp.doSearchArtifactByFilter('sha256:3e33e3e3');
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const el: HTMLAnchorElement =
fixture.nativeElement.querySelector('.digest');
expect(el).toBeTruthy();
expect(el.textContent).toBeTruthy();
expect(el.textContent.trim()).toEqual('sha256:3e33e3e3');
});
it('should delete artifact', async () => {
fixture.detectChanges();
it('should open copy digest modal', async () => {
await fixture.whenStable();
comp.selectedRow = [mockArtifacts[0]];
filtereName = 'sha256:3e33e3e3';
comp.confirmDeletion({ source: 9, state: 1, data: comp.selectedRow });
await stepOpenAction(fixture, comp);
fixture.nativeElement
.querySelector('#artifact-list-copy-digest')
.click();
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('textarea')).toBeTruthy();
});
it('should open add labels modal', async () => {
await fixture.whenStable();
comp.selectedRow = [mockArtifacts[1]];
await stepOpenAction(fixture, comp);
fixture.nativeElement
.querySelector('#artifact-list-add-labels')
.click();
fixture.detectChanges();
const el: HTMLAnchorElement =
fixture.nativeElement.querySelector('.digest');
expect(el).toBeTruthy();
expect(el.textContent).toBeTruthy();
expect(el.textContent.trim()).toEqual('sha256:3e33e3e3');
await fixture.whenStable();
expect(
fixture.nativeElement.querySelector('app-label-selector')
).toBeTruthy();
});
it('should open copy artifact modal', async () => {
await fixture.whenStable();
comp.selectedRow = [mockArtifacts[1]];
await stepOpenAction(fixture, comp);
fixture.nativeElement.querySelector('#artifact-list-copy').click();
fixture.detectChanges();
await fixture.whenStable();
expect(
fixture.nativeElement.querySelector('hbr-image-name-input')
).toBeTruthy();
});
it('should open delete modal', async () => {
await fixture.whenStable();
comp.selectedRow = [mockArtifacts[1]];
await stepOpenAction(fixture, comp);
fixture.nativeElement.querySelector('#artifact-list-delete').click();
fixture.detectChanges();
await fixture.whenStable();
expect(
fixture.nativeElement.querySelector('.confirmation-title')
).toBeTruthy();
});
});
async function stepOpenAction(fixture, comp) {
comp.projectId = 1;
fixture.detectChanges();
await fixture.whenStable();
fixture.nativeElement.querySelector('#artifact-list-action').click();
fixture.detectChanges();
await fixture.whenStable();
}

View File

@ -11,21 +11,9 @@
// 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,
ElementRef,
OnDestroy,
OnInit,
ViewChild,
} from '@angular/core';
import { forkJoin, Observable, of, Subject, Subscription } from 'rxjs';
import {
catchError,
debounceTime,
distinctUntilChanged,
finalize,
map,
} from 'rxjs/operators';
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { forkJoin, Observable, of, Subscription } from 'rxjs';
import { catchError, finalize, map } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import {
ClrDatagridComparatorInterface,
@ -48,7 +36,6 @@ import {
setPageSizeToLocalStorage,
VULNERABILITY_SCAN_STATUS,
} from '../../../../../../../shared/units/utils';
import { ImageNameInputComponent } from '../../../../../../../shared/components/image-name-input/image-name-input.component';
import { ErrorHandler } from '../../../../../../../shared/units/error-handler';
import { ArtifactService } from '../../../artifact.service';
import { OperationService } from '../../../../../../../shared/components/operation/operation.service';
@ -65,12 +52,12 @@ import {
import {
AccessoryType,
artifactDefault,
ArtifactFilterEvent,
ArtifactFront as Artifact,
ArtifactFront,
ArtifactType,
getPullCommandByDigest,
getPullCommandByTag,
mutipleFilter,
} from '../../../artifact';
import { Project } from '../../../../../project';
import { ArtifactService as NewArtifactService } from '../../../../../../../../../ng-swagger-gen/services/artifact.service';
@ -98,12 +85,6 @@ import { Tag } from '../../../../../../../../../ng-swagger-gen/models/tag';
import { CopyArtifactComponent } from './copy-artifact/copy-artifact.component';
import { CopyDigestComponent } from './copy-digest/copy-digest.component';
export interface LabelState {
iconsShow: boolean;
label: Label;
show: boolean;
}
export const AVAILABLE_TIME = '0001-01-01T00:00:00.000Z';
const CHECKING: string = 'checking';
@ -122,12 +103,7 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
registryUrl: string;
artifactList: ArtifactFront[] = [];
availableTime = AVAILABLE_TIME;
lastFilteredTagName: string;
inprogress: boolean;
openLabelFilterPanel: boolean;
openLabelFilterPiece: boolean;
showlabel: boolean;
pullComparator: Comparator<Artifact> = new CustomComparator<Artifact>(
'pull_time',
'date'
@ -139,21 +115,6 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
loading = true;
selectedRow: Artifact[] = [];
labelListOpen = false;
selectedTag: Artifact[];
labelNameFilter: Subject<string> = new Subject<string>();
stickLabelNameFilter: Subject<string> = new Subject<string>();
filterOnGoing: boolean;
stickName = '';
filterName = '';
initFilter = {
name: '',
description: '',
color: '',
scope: '',
project_id: 0,
};
filterOneLabel: Label = this.initFilter;
@ViewChild('confirmationDialog')
confirmationDialog: ConfirmationDialogComponent;
@ -193,11 +154,6 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
scanStoppedArtifactLength: number = 0;
artifactDigest: string;
depth: string;
labelNameFilterSub: Subscription;
stickLabelNameFilterSub: Subscription;
mutipleFilter = clone(mutipleFilter);
filterByType: string = this.mutipleFilter[0].filterBy;
openSelectFilterPiece = false;
// could Pagination filter
filters: string[];
scanFinishedArtifactLength: number = 0;
@ -214,7 +170,7 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
private activatedRoute: ActivatedRoute,
private router: Router,
private appConfigService: AppConfigService,
public artifactListPageService: ArtifactListPageService
private artifactListPageService: ArtifactListPageService
) {}
initRouterData() {
this.projectId =
@ -237,10 +193,8 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
const arr: string[] = this.depth.split('-');
this.artifactDigest = this.depth.split('-')[arr.length - 1];
}
this.lastFilteredTagName = '';
}
ngOnInit() {
this.artifactListPageService.resetClonedLabels();
this.registryUrl = this.appConfigService.getConfig().registry_url;
this.initRouterData();
if (!this.updateArtifactSub) {
@ -257,86 +211,17 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
}
);
}
if (!this.labelNameFilterSub) {
this.labelNameFilterSub = this.labelNameFilter
.pipe(debounceTime(500))
.pipe(distinctUntilChanged())
.subscribe((name: string) => {
if (this.filterName.length) {
this.filterOnGoing = true;
this.artifactListPageService.imageFilterLabels.forEach(
data => {
data.show =
data.label.name.indexOf(this.filterName) !==
-1;
}
);
}
});
}
if (!this.stickLabelNameFilterSub) {
this.stickLabelNameFilterSub = this.stickLabelNameFilter
.pipe(debounceTime(500))
.pipe(distinctUntilChanged())
.subscribe((name: string) => {
if (this.stickName.length) {
this.filterOnGoing = true;
this.artifactListPageService.imageStickLabels.forEach(
data => {
data.show =
data.label.name.indexOf(this.stickName) !==
-1;
}
);
}
});
}
}
ngOnDestroy() {
if (this.labelNameFilterSub) {
this.labelNameFilterSub.unsubscribe();
this.labelNameFilterSub = null;
}
if (this.stickLabelNameFilterSub) {
this.stickLabelNameFilterSub.unsubscribe();
this.stickLabelNameFilterSub = null;
}
if (this.updateArtifactSub) {
this.updateArtifactSub.unsubscribe();
this.updateArtifactSub = null;
}
}
public get filterLabelPieceWidth() {
let len = this.lastFilteredTagName.length
? this.lastFilteredTagName.length * 6 + 60
: 115;
return len > 210 ? 210 : len;
}
get withNotary(): boolean {
return this.appConfigService.getConfig()?.with_notary;
}
doSearchArtifactByFilter(filterWords) {
this.lastFilteredTagName = filterWords;
this.currentPage = 1;
let st: ClrDatagridStateInterface = this.currentState;
if (!st) {
st = { page: {} };
}
st.page.size = this.pageSize;
st.page.from = 0;
st.page.to = this.pageSize - 1;
this.filters = [];
if (this.lastFilteredTagName) {
this.filters.push(
`${this.filterByType}=~${this.lastFilteredTagName}`
);
}
this.clrLoad(st);
}
clrDgRefresh(state: ClrDatagridStateInterface) {
setTimeout(() => {
//add setTimeout to avoid ng check error
@ -538,39 +423,6 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
}
return pullCommand;
}
labelSelectedChange(artifact?: Artifact[]): void {
this.artifactListPageService.imageStickLabels.forEach(data => {
data.iconsShow = false;
data.show = true;
});
if (artifact && artifact[0].labels && artifact[0].labels.length) {
artifact[0].labels.forEach((labelInfo: Label) => {
let findedLabel =
this.artifactListPageService.imageStickLabels.find(
data => labelInfo.id === data['label'].id
);
if (findedLabel) {
this.artifactListPageService.imageStickLabels.splice(
this.artifactListPageService.imageStickLabels.indexOf(
findedLabel
),
1
);
this.artifactListPageService.imageStickLabels.unshift(
findedLabel
);
findedLabel.iconsShow = true;
}
});
}
}
addLabels(): void {
this.labelListOpen = true;
this.selectedTag = this.selectedRow;
this.stickName = '';
this.labelSelectedChange(this.selectedRow);
}
canAddLabel(): boolean {
if (this.selectedRow && this.selectedRow.length === 1) {
@ -590,250 +442,60 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
return false;
}
stickLabel(labelInfo: LabelState): void {
if (labelInfo && !labelInfo.iconsShow) {
this.selectLabel(labelInfo);
}
if (labelInfo && labelInfo.iconsShow) {
this.unSelectLabel(labelInfo);
stickLabel(labelEvent: { label: Label; isAdd: boolean }): void {
if (labelEvent.isAdd) {
this.addLabel(labelEvent?.label);
} else {
this.removeLabel(labelEvent?.label);
}
}
selectLabel(labelInfo: LabelState): void {
addLabel(label: Label) {
if (!this.inprogress) {
// add label to multiple artifact
const ObservableArr: Array<Observable<null>> = [];
this.selectedRow.forEach(item => {
const params: NewArtifactService.AddLabelParams = {
projectName: this.projectName,
repositoryName: dbEncodeURIComponent(this.repoName),
reference: item.digest,
label: labelInfo.label,
};
ObservableArr.push(this.newArtifactService.addLabel(params));
});
const params: NewArtifactService.AddLabelParams = {
projectName: this.projectName,
repositoryName: dbEncodeURIComponent(this.repoName),
reference: this.selectedRow[0].digest,
label: label,
};
this.inprogress = true;
forkJoin(ObservableArr)
this.newArtifactService
.addLabel(params)
.pipe(finalize(() => (this.inprogress = false)))
.subscribe(
res => {
.subscribe({
next: res => {
this.refresh();
// set the selected label in front
this.artifactListPageService.imageStickLabels.splice(
this.artifactListPageService.imageStickLabels.indexOf(
labelInfo
),
1
);
this.artifactListPageService.imageStickLabels.some(
(data, i) => {
if (!data.iconsShow) {
this.artifactListPageService.imageStickLabels.splice(
i,
0,
labelInfo
);
return true;
}
}
);
// when is the last one
if (
this.artifactListPageService.imageStickLabels.every(
data => data.iconsShow === true
)
) {
this.artifactListPageService.imageStickLabels.push(
labelInfo
);
}
labelInfo.iconsShow = true;
},
err => {
error: err => {
this.refresh();
this.errorHandlerService.error(err);
}
);
},
});
}
}
unSelectLabel(labelInfo: LabelState): void {
removeLabel(label: Label) {
if (!this.inprogress) {
this.inprogress = true;
let labelId = labelInfo.label.id;
this.selectedRow = this.selectedTag;
let params: NewArtifactService.RemoveLabelParams = {
projectName: this.projectName,
repositoryName: dbEncodeURIComponent(this.repoName),
reference: this.selectedRow[0].digest,
labelId: labelId,
labelId: label.id,
};
this.newArtifactService.removeLabel(params).subscribe(
res => {
this.refresh();
// insert the unselected label to groups with the same icons
this.sortOperation(
this.artifactListPageService.imageStickLabels,
labelInfo
);
labelInfo.iconsShow = false;
this.inprogress = false;
},
err => {
this.inprogress = false;
this.errorHandlerService.error(err);
}
);
this.inprogress = true;
this.newArtifactService
.removeLabel(params)
.pipe(finalize(() => (this.inprogress = false)))
.subscribe({
next: res => {
this.refresh();
},
error: err => {
this.refresh();
this.errorHandlerService.error(err);
},
});
}
}
rightFilterLabel(labelInfo: LabelState): void {
if (labelInfo) {
if (!labelInfo.iconsShow) {
this.filterLabel(labelInfo);
this.showlabel = true;
} else {
this.unFilterLabel(labelInfo);
this.showlabel = false;
}
}
}
filterLabel(labelInfo: LabelState): void {
let labelId = labelInfo.label.id;
this.artifactListPageService.imageFilterLabels.filter(data => {
data.iconsShow = data.label.id === labelId;
});
this.filterOneLabel = labelInfo.label;
// reload data
this.currentPage = 1;
let st: ClrDatagridStateInterface = this.currentState;
if (!st) {
st = { page: {} };
}
st.page.size = this.pageSize;
st.page.from = 0;
st.page.to = this.pageSize - 1;
this.filters = [`${this.filterByType}=(${labelId})`];
this.clrLoad(st);
}
unFilterLabel(labelInfo: LabelState): void {
this.filterOneLabel = this.initFilter;
labelInfo.iconsShow = false;
// reload data
this.currentPage = 1;
let st: ClrDatagridStateInterface = this.currentState;
if (!st) {
st = { page: {} };
}
st.page.size = this.pageSize;
st.page.from = 0;
st.page.to = this.pageSize - 1;
this.filters = [];
this.clrLoad(st);
}
closeFilter(): void {
this.openLabelFilterPanel = false;
}
reSortImageFilterLabels() {
if (
this.artifactListPageService.imageFilterLabels &&
this.artifactListPageService.imageFilterLabels.length
) {
for (
let i = 0;
i < this.artifactListPageService.imageFilterLabels.length;
i++
) {
if (
this.artifactListPageService.imageFilterLabels[i].iconsShow
) {
const arr: LabelState[] =
this.artifactListPageService.imageFilterLabels.splice(
i,
1
);
this.artifactListPageService.imageFilterLabels.unshift(
...arr
);
break;
}
}
}
}
getFilterPlaceholder(): string {
return this.showlabel ? '' : 'ARTIFACT.FILTER_FOR_ARTIFACTS';
}
openFlagEvent(isOpen: boolean): void {
if (isOpen) {
this.openLabelFilterPanel = true;
// every time when filer panel opens, resort imageFilterLabels labels
this.reSortImageFilterLabels();
this.openLabelFilterPiece = true;
this.openSelectFilterPiece = true;
this.filterName = '';
// redisplay all labels
this.artifactListPageService.imageFilterLabels.forEach(data => {
data.show = data.label.name.indexOf(this.filterName) !== -1;
});
} else {
this.openLabelFilterPanel = false;
this.openLabelFilterPiece = false;
this.openSelectFilterPiece = false;
}
}
handleInputFilter() {
if (this.filterName.length) {
this.labelNameFilter.next(this.filterName);
} else {
this.artifactListPageService.imageFilterLabels.every(
data => (data.show = true)
);
}
}
handleStickInputFilter() {
if (this.stickName.length) {
this.stickLabelNameFilter.next(this.stickName);
} else {
this.artifactListPageService.imageStickLabels.every(
data => (data.show = true)
);
}
}
// insert the unselected label to groups with the same icons
sortOperation(labelList: LabelState[], labelInfo: LabelState): void {
labelList.some((data, i) => {
if (!data.iconsShow) {
if (data.label.scope === labelInfo.label.scope) {
labelList.splice(i, 0, labelInfo);
labelList.splice(labelList.indexOf(labelInfo, 0), 1);
return true;
}
if (
data.label.scope !== labelInfo.label.scope &&
i === labelList.length - 1
) {
labelList.push(labelInfo);
labelList.splice(labelList.indexOf(labelInfo), 1);
return true;
}
}
});
}
sizeTransform(tagSize: string): string {
return formatSize(tagSize);
}
@ -1011,7 +673,6 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
return of(error);
})
);
// }
}
showDigestId() {
@ -1147,56 +808,19 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
}
}
selectFilterType() {
this.lastFilteredTagName = '';
if (this.filterByType === 'labels') {
this.openLabelFilterPanel = true;
// every time when filer panel opens, resort imageFilterLabels labels
this.reSortImageFilterLabels();
this.openLabelFilterPiece = true;
filterEvent(e: ArtifactFilterEvent) {
this.filters = [];
if (e?.isLabel) {
if (e?.label?.name) {
this.filters.push(`${e.type}=(${e?.label?.id})`);
}
} else {
this.openLabelFilterPiece = false;
this.filterOneLabel = this.initFilter;
this.showlabel = false;
this.artifactListPageService.imageFilterLabels.forEach(data => {
data.iconsShow = false;
});
if (e?.stringValue) {
this.filters.push(`${e.type}=${e?.stringValue}`);
}
}
this.currentPage = 1;
let st: ClrDatagridStateInterface = this.currentState;
if (!st) {
st = { page: {} };
}
st.page.size = this.pageSize;
st.page.from = 0;
st.page.to = this.pageSize - 1;
this.filters = [];
this.clrLoad(st);
this.refresh();
}
selectFilter(showItem: string, filterItem: string) {
this.lastFilteredTagName = filterItem;
this.currentPage = 1;
let st: ClrDatagridStateInterface = this.currentState;
if (!st) {
st = { page: {} };
}
st.page.size = this.pageSize;
st.page.from = 0;
st.page.to = this.pageSize - 1;
this.filters = [];
if (filterItem) {
this.filters.push(`${this.filterByType}=${filterItem}`);
}
this.clrLoad(st);
}
get isFilterReadonly() {
return this.filterByType === 'labels' ? 'readonly' : null;
}
// when finished, remove it from selectedRow
scanFinished(artifact: Artifact) {
if (this.selectedRow && this.selectedRow.length) {

View File

@ -23,6 +23,7 @@ import { SubAccessoriesComponent } from './artifact-list-page/artifact-list/arti
import { ArtifactListPageService } from './artifact-list-page/artifact-list-page.service';
import { CopyArtifactComponent } from './artifact-list-page/artifact-list/artifact-list-tab/copy-artifact/copy-artifact.component';
import { CopyDigestComponent } from './artifact-list-page/artifact-list/artifact-list-tab/copy-digest/copy-digest.component';
import { ArtifactFilterComponent } from './artifact-list-page/artifact-list/artifact-list-tab/artifact-filter/artifact-filter.component';
const routes: Routes = [
{
@ -86,6 +87,7 @@ const routes: Routes = [
SubAccessoriesComponent,
CopyArtifactComponent,
CopyDigestComponent,
ArtifactFilterComponent,
],
imports: [RouterModule.forChild(routes), SharedModule],
providers: [

View File

@ -1,6 +1,7 @@
import { Accessory } from 'ng-swagger-gen/models/accessory';
import { Artifact } from '../../../../../../ng-swagger-gen/models/artifact';
import { Platform } from '../../../../../../ng-swagger-gen/models/platform';
import { Label } from '../../../../../../ng-swagger-gen/models/label';
export interface ArtifactFront extends Artifact {
platform?: Platform;
@ -18,7 +19,11 @@ export interface AccessoryFront extends Accessory {
scan_overview?: any;
}
export const mutipleFilter = [
export const multipleFilter: Array<{
filterBy: string;
filterByShowText: string;
listItem: any[];
}> = [
{
filterBy: 'type',
filterByShowText: 'Type',
@ -120,3 +125,10 @@ export function getPullCommandByTag(
}
return pullCommand;
}
export interface ArtifactFilterEvent {
type?: string;
stringValue?: string;
isLabel?: boolean;
label?: Label;
}

View File

@ -0,0 +1,37 @@
<div [style.width.px]="width">
<div class="clr-input-wrapper filter-label-input">
<input
class="clr-input"
type="text"
autocomplete="off"
placeholder="{{ 'LABEL.FILTER_LABEL_PLACEHOLDER' | translate }}"
[(ngModel)]="searchValue"
(keyup)="search()" />
</div>
<div class="no-labels" [hidden]="!loading">
<span class="spinner spinner-inline"></span>
</div>
<div [hidden]="loading">
<div
[hidden]="candidateLabels.length"
class="no-labels"
(click)="goToLabelPage()">
{{ 'LABEL.NO_LABELS' | translate }}
</div>
<div [hidden]="!candidateLabels.length" class="has-label">
<button
type="button"
class="dropdown-item"
*ngFor="let label of candidateLabels"
(click)="selectLabel(label)">
<clr-icon
shape="check"
class="check-icon"
[style.visibility]="isSelect(label) ? 'visible' : 'hidden'">
</clr-icon>
<hbr-label-piece [label]="label" [labelWidth]="130">
</hbr-label-piece>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,33 @@
.has-label {
max-height: 300px;
overflow-y: auto;
margin-top: 10px;
}
.filter-label-input {
display: flex;
justify-content: center;
}
.no-labels {
height: 4rem;
display: flex;
align-items: center;
justify-content: center;
}
.dropdown-item {
padding: 3px;
display: flex;
align-items: center;
}
.check-icon {
margin-left: 5px;
margin-right: 5px;
margin-top: 3px;
}
.clr-form-control {
margin-top: 0 !important;
}

View File

@ -0,0 +1,64 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LabelSelectorComponent } from './label-selector.component';
import { SharedTestingModule } from '../../shared.module';
import { LabelService } from '../../../../../ng-swagger-gen/services/label.service';
import { Label } from '../../../../../ng-swagger-gen/models/label';
import { of } from 'rxjs';
import { delay, finalize } from 'rxjs/operators';
describe('LabelSelectorComponent', () => {
let component: LabelSelectorComponent;
let fixture: ComponentFixture<LabelSelectorComponent>;
const mockedLabels: Label[] = [
{
id: 1,
name: 'good',
scope: 'p',
project_id: 1,
color: '#ccc',
},
{
id: 2,
name: 'bad',
scope: 'p',
project_id: 1,
color: '#ccc',
},
];
let spy: jasmine.Spy;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SharedTestingModule],
declarations: [LabelSelectorComponent],
}).compileComponents();
fixture = TestBed.createComponent(LabelSelectorComponent);
component = fixture.componentInstance;
spy = spyOn(TestBed.inject(LabelService), 'ListLabels').and.returnValue(
of(mockedLabels).pipe(delay(0))
);
fixture.detectChanges();
await fixture.whenStable();
component.loading = false;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render candidates', async () => {
await fixture.whenStable();
const rows = fixture.nativeElement.querySelectorAll('hbr-label-piece');
expect(rows.length).toEqual(2);
});
it('owned labels should be checked', async () => {
await fixture.whenStable();
component.ownedLabels = [mockedLabels[0]];
fixture.detectChanges();
await fixture.whenStable();
const checkIcon = fixture.nativeElement.querySelector('.check-icon');
expect(checkIcon.style.visibility).toEqual('visible');
});
});

View File

@ -0,0 +1,213 @@
import {
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
} from '@angular/core';
import { Label } from '../../../../../ng-swagger-gen/models/label';
import { forkJoin, Observable, Subject, Subscription } from 'rxjs';
import { LabelService } from '../../../../../ng-swagger-gen/services/label.service';
import {
debounceTime,
distinctUntilChanged,
filter,
finalize,
map,
switchMap,
} from 'rxjs/operators';
import { Router } from '@angular/router';
const GLOBAL: string = 'g';
const PROJECT: string = 'p';
const PAGE_SIZE: number = 50;
@Component({
selector: 'app-label-selector',
templateUrl: './label-selector.component.html',
styleUrls: ['./label-selector.component.scss'],
})
export class LabelSelectorComponent implements OnInit, OnChanges, OnDestroy {
@Input()
ownedLabels: Label[] = [];
@Input()
width: number = 180; // unit px
@Input()
scope: string = GLOBAL; // 'g' for global and 'p' for project, default 'g'
@Input()
projectId: number; // if scope = 'p', projectId is required
candidateLabels: Label[] = [];
searchValue: string;
loading: boolean = false;
@Output()
clickLabel = new EventEmitter<{
label: Label;
isAdd: boolean;
}>();
private _searchSubject = new Subject<string>();
private _subSearch: Subscription;
constructor(private labelService: LabelService, private router: Router) {
if (!this._subSearch) {
this._subSearch = this._searchSubject
.pipe(
debounceTime(500),
distinctUntilChanged(),
filter(labelName => {
if (!labelName) {
this.initCandidateLabel();
}
return !!labelName;
}),
switchMap(labelName => {
return this.getLabelObservable(labelName);
})
)
.subscribe(res => {
this.candidateLabels = res;
});
}
}
ngOnInit(): void {
this.checkProjectId();
this.initCandidateLabel();
}
initCandidateLabel() {
// Place the owned label at the top of the array then remove duplicates
const Obs: Observable<Label[]>[] = [];
if (this.ownedLabels?.length) {
const projectLabelIds: number[] = [];
const globalLabelIds: number[] = [];
this.ownedLabels?.forEach(item => {
if (item.scope === PROJECT) {
projectLabelIds.push(item.id);
}
if (item.scope === GLOBAL) {
globalLabelIds.push(item.id);
}
});
if (projectLabelIds?.length) {
Obs.push(
this.labelService.ListLabels({
page: 1,
pageSize: PAGE_SIZE,
scope: PROJECT,
projectId: this.projectId,
q: encodeURIComponent(
`id={${projectLabelIds.join(' ')}}`
),
})
);
}
if (globalLabelIds?.length) {
Obs.push(
this.labelService.ListLabels({
page: 1,
pageSize: PAGE_SIZE,
scope: GLOBAL,
q: encodeURIComponent(
`id={${globalLabelIds.join(' ')}}`
),
})
);
}
}
Obs.push(this.getLabelObservable(''));
forkJoin(Obs)
.pipe(
map(result => [].concat.apply([], result)),
map((result: Label[]) => {
return result.filter(
(v, i, a) => a.findIndex(v2 => v2.id === v.id) === i
);
})
)
.subscribe(res => {
this.candidateLabels = res;
});
}
ngOnChanges(changes: SimpleChanges): void {
this.checkProjectId();
}
ngOnDestroy() {
if (this._subSearch) {
this._subSearch.unsubscribe();
this._subSearch = null;
}
}
checkProjectId() {
if (this.scope === PROJECT && !this.projectId) {
throw new Error('Attribute [projectId] is required');
}
}
search() {
this._searchSubject.next(this.searchValue);
}
selectLabel(label: Label) {
this.clickLabel.emit({ label: label, isAdd: !this.isSelect(label) });
}
isSelect(label: Label): boolean {
if (this.ownedLabels?.length) {
return this.ownedLabels.some(item => {
return item.id === label.id;
});
}
return false;
}
goToLabelPage() {
if (this.scope === PROJECT) {
this.router.navigate([
'harbor',
'projects',
this.projectId,
'labels',
]);
} else {
this.router.navigate(['harbor', 'labels']);
}
}
getLabelObservable(labelName: string): Observable<Label[]> {
this.loading = true;
if (this.scope === PROJECT) {
return forkJoin([
this.labelService.ListLabels({
page: 1,
pageSize: PAGE_SIZE,
scope: PROJECT,
projectId: this.projectId,
q: labelName
? encodeURIComponent(`name=~${labelName}`)
: null,
}),
this.labelService.ListLabels({
page: 1,
pageSize: PAGE_SIZE,
scope: GLOBAL,
q: labelName
? encodeURIComponent(`name=~${labelName}`)
: null,
}),
]).pipe(
map(result => [].concat.apply([], result)),
finalize(() => (this.loading = false))
);
}
return this.labelService
.ListLabels({
page: 1,
pageSize: PAGE_SIZE,
scope: GLOBAL,
q: labelName ? encodeURIComponent(`name=~${labelName}`) : null,
})
.pipe(finalize(() => (this.loading = false)));
}
}

View File

@ -6,11 +6,13 @@
border: labelColor?.color === '#FFFFFF' ? '1px solid #A1A1A1' : 'none'
}"
[style.max-width.px]="labelWidth">
<clr-icon
*ngIf="hasIcon && label.scope === 'p'"
shape="organization"></clr-icon>
<clr-icon
*ngIf="hasIcon && label.scope === 'g'"
shape="administrator"></clr-icon>
{{ label.name }}
<span>
<clr-icon
*ngIf="hasIcon && label.scope === 'p'"
shape="organization"></clr-icon>
<clr-icon
*ngIf="hasIcon && label.scope === 'g'"
shape="administrator"></clr-icon>
</span>
<span class="label-name">{{ label.name }}</span>
</label>

View File

@ -1,19 +1,16 @@
.label {
border: none;
color: #222;
display: inline-block;
justify-content: flex-start;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
line-height: .875rem;
display: flex;
align-items: center;
cursor: inherit;
}
.label clr-icon {
margin-right: 3px;
margin-top: 3px;
}
.btn-group .dropdown-menu clr-icon {
display: block;
}
.label-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -90,6 +90,7 @@ import locale_pt from '@angular/common/locales/pt-PT';
import locale_tr from '@angular/common/locales/tr';
import locale_de from '@angular/common/locales/de';
import { SupportedLanguage } from './entities/shared.const';
import { LabelSelectorComponent } from './components/label-selector/label-selector.component';
const localesForSupportedLangs: Record<SupportedLanguage, unknown[]> = {
'en-us': locale_en,
@ -171,6 +172,7 @@ ClarityIcons.add({
ImageNameInputComponent,
HarborDatetimePipe,
RemainingTimeComponent,
LabelSelectorComponent,
],
exports: [
TranslateModule,
@ -210,6 +212,7 @@ ClarityIcons.add({
ImageNameInputComponent,
HarborDatetimePipe,
RemainingTimeComponent,
LabelSelectorComponent,
],
providers: [
{ provide: EndpointService, useClass: EndpointDefaultService },

View File

@ -338,3 +338,12 @@ hbr-create-edit-rule {
border-bottom-color: $normal-border-color !important;
}
}
app-artifact-filter {
.border-bottom-color {
border-bottom-color: $normal-border-color !important;
}
.search-dropdown-toggle {
color: $normal-border-color !important;;
}
}