diff --git a/src/openssf-dashboard/account-view/account-view.component.scss b/src/openssf-dashboard/account-view/account-view.component.scss index 0c63d2c..c45f96c 100644 --- a/src/openssf-dashboard/account-view/account-view.component.scss +++ b/src/openssf-dashboard/account-view/account-view.component.scss @@ -50,7 +50,8 @@ .score { display: flex; - osd-score-ring { + osd-score-ring, + osd-loading { width: 40px; height: 40px; diff --git a/src/openssf-dashboard/account-view/views/repository-list-view/repository-list-view.component.html b/src/openssf-dashboard/account-view/views/repository-list-view/repository-list-view.component.html index 0bfe3a8..ac1e753 100644 --- a/src/openssf-dashboard/account-view/views/repository-list-view/repository-list-view.component.html +++ b/src/openssf-dashboard/account-view/views/repository-list-view/repository-list-view.component.html @@ -13,9 +13,21 @@ [icon]="getIcon('sort')" [label]="getSortLabel()" (clicked)="onToggleSortMode()"> - + diff --git a/src/openssf-dashboard/account-view/views/repository-list-view/repository-list-view.component.ts b/src/openssf-dashboard/account-view/views/repository-list-view/repository-list-view.component.ts index 456670a..7ba0760 100644 --- a/src/openssf-dashboard/account-view/views/repository-list-view/repository-list-view.component.ts +++ b/src/openssf-dashboard/account-view/views/repository-list-view/repository-list-view.component.ts @@ -20,10 +20,12 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + effect, OnDestroy, OnInit, signal, - WritableSignal } from '@angular/core'; + WritableSignal +} from '@angular/core'; import { ButtonComponent } from '../../../shared/components/button/button.component'; import { InputComponent } from '../../../shared/components/input/input.component'; import { RepositoryWidgetComponent } from '../../components/repository-widget/repository-widget.component'; @@ -33,8 +35,13 @@ import { AccountModel } from '../../../shared/models/account.model'; import { RepositoryModel } from '../../../shared/models/repository.model'; import { LoadingState } from '../../../shared/LoadingState'; import { Subject, takeUntil, tap } from 'rxjs'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Params, Router } from '@angular/router'; import { SelectedAccountStateService } from '../../../shared/services/selected-account-state.service'; +import { TransientStorage } from '../../../shared/services/transient-storage.service'; +import { + MultiToggleButtonComponent, + ToggleButtonItem +} from '../../../shared/components/multi-toggle-button/multi-toggle-button.component'; @Component({ selector: 'osd-repository-list-view', @@ -44,7 +51,8 @@ import { SelectedAccountStateService } from '../../../shared/services/selected-a RepositoryWidgetComponent, InputComponent, LoadingComponent, - NgClass + NgClass, + MultiToggleButtonComponent ], templateUrl: './repository-list-view.component.html', styleUrl: './repository-list-view.component.scss', @@ -58,6 +66,7 @@ export class RepositoryListViewComponent implements OnInit, OnDestroy { readonly LoadingState = LoadingState; readonly LayoutView = LayoutView; + readonly LayoutVisibility = LayoutVisibility; readonly selectedAccount: WritableSignal = signal(undefined); readonly selectedAccountRepositories: WritableSignal = signal([]); @@ -66,11 +75,13 @@ export class RepositoryListViewComponent implements OnInit, OnDestroy { readonly repositoryLoadState: WritableSignal = signal(LoadingState.LOADING); readonly layoutView: WritableSignal = signal(LayoutView.GRID); - readonly layoutVisibility: WritableSignal = signal(LayoutVisibility.ALL); readonly layoutSortMode: WritableSignal = signal(LayoutSortMode.NAME_ASC); readonly layoutVisibleResults: WritableSignal = signal(RepositoryListViewComponent.RESULTS_PER_PAGE); readonly searchString: WritableSignal = signal(''); + readonly hideNoScorecardRepos: WritableSignal = signal(false); + readonly hideArchivedRepos: WritableSignal = signal(false); + public filteredRepositoriesCount: number = 0; private cleanup = new Subject(); @@ -80,13 +91,24 @@ export class RepositoryListViewComponent implements OnInit, OnDestroy { * @param activatedRoute * @param changeDetectorRef * @param selectedAccountService + * @param transientStorage */ constructor( protected router: Router, protected activatedRoute: ActivatedRoute, protected changeDetectorRef: ChangeDetectorRef, protected selectedAccountService: SelectedAccountStateService, - ) { } + protected transientStorage: TransientStorage + ) { + effect(() => { + // Save changes to the ui settings to the storage + console.log('Saving changes to storage...'); + this.setStorageValue('layout', this.layoutView()); + this.setStorageValue('sort', this.layoutSortMode()); + this.setStorageValue('hide-nsr', this.hideNoScorecardRepos()); + this.setStorageValue('hide-ar', this.hideArchivedRepos()); + }); + } /** * @inheritdoc @@ -127,9 +149,19 @@ export class RepositoryListViewComponent implements OnInit, OnDestroy { .subscribe(); this.activatedRoute.queryParams.subscribe(params => { - this.layoutVisibility.set(params['visible'] ? params['visible'] : LayoutVisibility.ALL); - this.layoutView.set(params['layout'] ? params['layout'] : LayoutView.GRID); - this.setSortMode(params['sort'] ? params['sort'] : LayoutSortMode.NAME_ASC, false); + if (Object.keys(params).length == 0) { + this.navigateWithQueryParams({ + 'layout': this.getStorageValue('layout'), + 'sort': this.getStorageValue('sort'), + 'hide-nsr': this.getStorageValue('hide-nsr') === true || undefined, + 'hide-ar': this.getStorageValue('hide-ar') === true || undefined, + }); + } + + this.layoutView.set(this.getParamValue(params, 'layout') || this.layoutView()); + this.layoutSortMode.set(this.getParamValue(params, 'sort') || this.layoutSortMode()); + this.hideNoScorecardRepos.set(this.getParamValue(params, 'hide-nsr') || this.hideNoScorecardRepos()); + this.hideArchivedRepos.set(this.getParamValue(params, 'hide-ar') || this.hideArchivedRepos()); this.changeDetectorRef.detectChanges(); }); @@ -142,17 +174,32 @@ export class RepositoryListViewComponent implements OnInit, OnDestroy { this.reset(); } + /** + * Get a param value, casting some value to a proper type (all params are strings or arrays). + * @param params + * @param key + */ + getParamValue( + params: Params, + key: string + ) { + const value = params[key]; + + if (value === 'true') { + return true; + } else if (value === 'false') { + return false; + } + + return value; + } + /** * Get a list of repositories after the results have had the sort filters applied. */ getVisibleRepositories(): RepositoryModel[] { let repositories: RepositoryModel[] = this.getFilteredRepositories(); - if (this.layoutVisibility() == LayoutVisibility.SCORECARDS) { - repositories = repositories.filter( - (repo) => repo.scorecard?.score !== undefined); - } - repositories.sort((a, b) => { const aScore = a.scorecard?.score !== undefined ? a.scorecard.score : 0; const bScore = b.scorecard?.score !== undefined ? b.scorecard.score : 0; @@ -190,37 +237,69 @@ export class RepositoryListViewComponent implements OnInit, OnDestroy { } /** - * Called when a user presses the toggle visibility button. + * Called when a user presses the sort toggle button. */ - onToggleVisibility() { - switch (this.layoutVisibility()) { - case LayoutVisibility.ALL: - this.layoutVisibility.set(LayoutVisibility.SCORECARDS); + onToggleSortMode() { + switch (this.layoutSortMode()) { + case LayoutSortMode.NAME_ASC: { + this.setSortMode(LayoutSortMode.NAME_DESC); break; - case LayoutVisibility.SCORECARDS: - this.layoutVisibility.set(LayoutVisibility.ALL); + } + case LayoutSortMode.NAME_DESC: { + this.setSortMode(LayoutSortMode.SCORE_ASC); break; + } + case LayoutSortMode.SCORE_ASC: { + this.setSortMode(LayoutSortMode.SCORE_DESC); + break; + } + case LayoutSortMode.SCORE_DESC: { + this.setSortMode(LayoutSortMode.NAME_ASC); + break; + } } + } - this.navigateWithQueryParams({ - 'visible': this.layoutVisibility() - }); + /** + * Get the sort label for the UI toggle button. + */ + getSortLabel() { + switch (this.layoutSortMode()) { + case LayoutSortMode.NAME_ASC: + case LayoutSortMode.NAME_DESC: + return 'Name'; + case LayoutSortMode.SCORE_ASC: + case LayoutSortMode.SCORE_DESC: + return 'Score'; + } } /** - * Set the sort mode. - * @param sortMode - * @param redirect + * Called when a user clicks the "view more" button in the UI. */ - setSortMode( - sortMode: LayoutSortMode, - redirect: boolean = true + onViewMore() { + this.layoutVisibleResults.set( + this.layoutVisibleResults() + RepositoryListViewComponent.RESULTS_PER_PAGE); + } + + /** + * Called when the view filters have changed. + * @param changedItem + */ + onVisibleItemsChanged( + changedItem: ToggleButtonItem ) { - this.layoutSortMode.set(sortMode); + if (changedItem.id == LayoutVisibility.HIDE_NO_SCORECARD_REPOS) { + this.hideNoScorecardRepos.set(changedItem.active); - if (redirect) { this.navigateWithQueryParams({ - 'sort': sortMode + 'hide-nsr': changedItem.active ? true : undefined + }); + } else if (changedItem.id == LayoutVisibility.HIDE_ARCHIVED_REPOS) { + this.hideArchivedRepos.set(changedItem.active); + + this.navigateWithQueryParams({ + 'hide-ar': changedItem.active ? true : undefined }); } } @@ -245,13 +324,7 @@ export class RepositoryListViewComponent implements OnInit, OnDestroy { if (this.layoutView() == LayoutView.GRID) { return 'grid_view'; } else if (this.layoutView() == LayoutView.LIST) { - return 'view_list'; - } - } else if (element == 'visibility') { - if (this.layoutVisibility() == LayoutVisibility.ALL) { - return 'visibility'; - } else { - return 'visibility_off'; + return 'list'; } } @@ -259,49 +332,43 @@ export class RepositoryListViewComponent implements OnInit, OnDestroy { } /** - * Called when a user presses the sort toggle button. + * Set the sort mode. + * @param sortMode + * @param redirect */ - onToggleSortMode() { - switch (this.layoutSortMode()) { - case LayoutSortMode.NAME_ASC: { - this.setSortMode(LayoutSortMode.NAME_DESC); - break; - } - case LayoutSortMode.NAME_DESC: { - this.setSortMode(LayoutSortMode.SCORE_ASC); - break; - } - case LayoutSortMode.SCORE_ASC: { - this.setSortMode(LayoutSortMode.SCORE_DESC); - break; - } - case LayoutSortMode.SCORE_DESC: { - this.setSortMode(LayoutSortMode.NAME_ASC); - break; - } + private setSortMode( + sortMode: LayoutSortMode, + redirect: boolean = true + ) { + this.layoutSortMode.set(sortMode); + + if (redirect) { + this.navigateWithQueryParams({ + 'sort': sortMode + }); } } /** - * Get the sort label for the UI toggle button. + * Set a storage value to the transient storage for the UI. + * @param key + * @param value */ - getSortLabel() { - switch (this.layoutSortMode()) { - case LayoutSortMode.NAME_ASC: - case LayoutSortMode.NAME_DESC: - return 'Name'; - case LayoutSortMode.SCORE_ASC: - case LayoutSortMode.SCORE_DESC: - return 'Score'; - } + private setStorageValue( + key: string, + value: any + ) { + this.transientStorage.set(`ui-${key}`, value); } /** - * Called when a user clicks the "view more" button in the UI. + * Get a storage value, falling back. + * @param key */ - onViewMore() { - this.layoutVisibleResults.set( - this.layoutVisibleResults() + RepositoryListViewComponent.RESULTS_PER_PAGE); + private getStorageValue( + key: string + ) { + return this.transientStorage.get(`ui-${key}`); } /** @@ -312,6 +379,16 @@ export class RepositoryListViewComponent implements OnInit, OnDestroy { const searchString = this.searchString(); + if (this.hideNoScorecardRepos()) { + repositories = repositories.filter( + (repo) => repo.scorecard?.score !== undefined); + } + + if (this.hideArchivedRepos()) { + repositories = repositories.filter( + (repo) => !repo.archived); + } + if (searchString.length > 0) { repositories = repositories.filter((repo) => JSON.stringify(repo).toLowerCase().includes(searchString)); @@ -320,20 +397,6 @@ export class RepositoryListViewComponent implements OnInit, OnDestroy { return repositories; } - /** - * Reset the UI. - */ - private reset() { - this.cleanup.next(); - this.cleanup.complete(); - - this.fatalError.set(false); - this.selectedAccount.set(undefined); - this.selectedAccountRepositories.set([]); - - this.repositoryLoadState.set(LoadingState.LOADING); - } - /** * Updates the query params, merging values and ensuring the page doesn't reload. * @param queryParams @@ -348,30 +411,41 @@ export class RepositoryListViewComponent implements OnInit, OnDestroy { queryParamsHandling: 'merge' }).then(); } + + /** + * Reset the UI. + */ + private reset() { + this.cleanup.next(); + this.cleanup.complete(); + + this.fatalError.set(false); + this.selectedAccount.set(undefined); + this.selectedAccountRepositories.set([]); + + this.repositoryLoadState.set(LoadingState.LOADING); + } } /** * Enum for layout views. */ enum LayoutView { - GRID = 'GRID', - LIST = 'LIST' + GRID = 'grid', + LIST = 'list' } /** * Enum for layout sort modes. */ enum LayoutSortMode { - NAME_ASC = 'NAME_ASC', - NAME_DESC = 'NAME_DESC', - SCORE_ASC = 'SCORE_ASC', - SCORE_DESC = 'SCORE_DESC', + NAME_ASC = 'name-asc', + NAME_DESC = 'name-desc', + SCORE_ASC = 'score-asc', + SCORE_DESC = 'score-desc', } -/** - * Enum for layout visibility. - */ enum LayoutVisibility { - ALL = 'ALL', - SCORECARDS = 'WITH_SCORECARDS' + HIDE_NO_SCORECARD_REPOS, + HIDE_ARCHIVED_REPOS } diff --git a/src/openssf-dashboard/shared/components/button/button.component.html b/src/openssf-dashboard/shared/components/button/button.component.html index a9abb13..fb619cd 100644 --- a/src/openssf-dashboard/shared/components/button/button.component.html +++ b/src/openssf-dashboard/shared/components/button/button.component.html @@ -1,4 +1,4 @@ -
+
@if (icon(); as icon) {
{{ icon }} diff --git a/src/openssf-dashboard/shared/components/button/button.component.scss b/src/openssf-dashboard/shared/components/button/button.component.scss index 58caba2..9f020b6 100644 --- a/src/openssf-dashboard/shared/components/button/button.component.scss +++ b/src/openssf-dashboard/shared/components/button/button.component.scss @@ -16,6 +16,7 @@ transition: var(--default-transition); cursor: pointer; + &.active, &:not(.disabled):hover { opacity: 1; background-color: rgba(255, 255, 255, .5); diff --git a/src/openssf-dashboard/shared/components/button/button.component.ts b/src/openssf-dashboard/shared/components/button/button.component.ts index 50e63ef..09f9314 100644 --- a/src/openssf-dashboard/shared/components/button/button.component.ts +++ b/src/openssf-dashboard/shared/components/button/button.component.ts @@ -32,6 +32,7 @@ export class ButtonComponent { readonly icon = input(undefined); readonly label = input(undefined); readonly disabled = input(false); + readonly active = input(false); readonly clicked = output(); /** diff --git a/src/openssf-dashboard/shared/components/multi-toggle-button/multi-toggle-button.component.html b/src/openssf-dashboard/shared/components/multi-toggle-button/multi-toggle-button.component.html new file mode 100644 index 0000000..5b0f21e --- /dev/null +++ b/src/openssf-dashboard/shared/components/multi-toggle-button/multi-toggle-button.component.html @@ -0,0 +1,26 @@ + + +@if (optionsVisible()) { +
+
+ @for (option of items(); track option.name) { +
+
+ +
+
+
+ @if (option.active) { + check_circle + } @else { + do_not_disturb_on + } +
+
+ } +
+
+} diff --git a/src/openssf-dashboard/shared/components/multi-toggle-button/multi-toggle-button.component.scss b/src/openssf-dashboard/shared/components/multi-toggle-button/multi-toggle-button.component.scss new file mode 100644 index 0000000..006a7cc --- /dev/null +++ b/src/openssf-dashboard/shared/components/multi-toggle-button/multi-toggle-button.component.scss @@ -0,0 +1,49 @@ +.container { + position: relative; + background-color: green; + + .options { + position: absolute; + top: 0; + right: 0; + border-radius: var(--default-border-radius); + box-shadow: var(--default-box-shadow); + background-color: var(--ui-primary-color); + padding: .3rem; + width: 300px; + z-index: 99999; + user-select: none; + + > div { + display: flex; + gap: 1rem; + padding: 1rem; + border-radius: var(--default-border-radius); + transition: var(--default-transition); + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, .05); + } + + > div { + display: flex; + align-items: center; + } + + .name { + flex: 1; + } + + .state { + .active { + color: green; + } + + .not-active { + color: grey; + } + } + } + } +} \ No newline at end of file diff --git a/src/openssf-dashboard/shared/components/multi-toggle-button/multi-toggle-button.component.ts b/src/openssf-dashboard/shared/components/multi-toggle-button/multi-toggle-button.component.ts new file mode 100644 index 0000000..615495b --- /dev/null +++ b/src/openssf-dashboard/shared/components/multi-toggle-button/multi-toggle-button.component.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * + * Copyright (C) Codeplay Software Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *--------------------------------------------------------------------------------------------*/ + +import { Component, HostListener, input, model, output, signal, WritableSignal } from '@angular/core'; +import { NgClass } from '@angular/common'; +import { ButtonComponent } from '../button/button.component'; + +@Component({ + selector: 'osd-multi-toggle-button', + standalone: true, + imports: [ + ButtonComponent, + ], + templateUrl: './multi-toggle-button.component.html', + styleUrl: './multi-toggle-button.component.scss' +}) +export class MultiToggleButtonComponent { + readonly items = model([]); + readonly itemChange = output(); + readonly optionsVisible: WritableSignal = signal(false); + + /** + * Called when a user clicks anywhere. + */ + @HostListener('document:click', ['$event']) + documentClick() { + if (!this.optionsVisible()) { + return ; + } + + this.optionsVisible.set(false); + } + + /** + * Called when a user clicks on the button. + */ + onButtonClicked(event: MouseEvent) { + event.stopPropagation(); + this.optionsVisible.set(!this.optionsVisible()); + } + + /** + * Called when a user toggles an option. + * @param event + * @param option + */ + onOptionToggled(event: MouseEvent, option: ToggleButtonItem): void { + event.stopPropagation(); + + option.active = !option.active; + this.itemChange.emit(option); + } +} + +/** + * Public interface for the toggle button item. + */ +export interface ToggleButtonItem { + id: any + name: string + icon: string + active: boolean +} diff --git a/src/openssf-dashboard/shared/models/repository.model.ts b/src/openssf-dashboard/shared/models/repository.model.ts index cbc1c90..5dbf242 100644 --- a/src/openssf-dashboard/shared/models/repository.model.ts +++ b/src/openssf-dashboard/shared/models/repository.model.ts @@ -27,5 +27,6 @@ export interface RepositoryModel { lastUpdated: Date stars: number description: string + archived: boolean scorecard?: ScorecardModel } diff --git a/src/openssf-dashboard/shared/services/repository-services/github.service.ts b/src/openssf-dashboard/shared/services/repository-services/github.service.ts index 502824b..389cd2c 100644 --- a/src/openssf-dashboard/shared/services/repository-services/github.service.ts +++ b/src/openssf-dashboard/shared/services/repository-services/github.service.ts @@ -100,7 +100,8 @@ export class GithubService extends BaseRepositoryService { url: repository['url'], lastUpdated: new Date(repository['updated_at']), stars: repository['stargazers_count'], - description: repository['description'] ?? 'This repository has no description available.' + description: repository['description'] ?? 'This repository has no description available.', + archived: repository['archived'] }); } diff --git a/src/openssf-dashboard/shared/services/transient-storage.service.ts b/src/openssf-dashboard/shared/services/transient-storage.service.ts index aa1cafd..fa7ce6c 100644 --- a/src/openssf-dashboard/shared/services/transient-storage.service.ts +++ b/src/openssf-dashboard/shared/services/transient-storage.service.ts @@ -64,8 +64,12 @@ export class TransientStorage { set( key: string, value: T, - timeoutInDays: number + timeoutInDays?: number ) { + if (!timeoutInDays) { + timeoutInDays = 365; + } + const expires = new Date(); expires.setDate(expires.getDate() + timeoutInDays); diff --git a/src/styles.scss b/src/styles.scss index 8a05dc6..02856b2 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -5,9 +5,9 @@ html, body { color: #111; --default-transition: .2s all; - --default-border-radius: 8px; - --default-border-radius-large: 12px; - --default-box-shadow: 2px 2px 5px 0 rgba(0, 0, 0, .12); + --default-border-radius: 7px; + --default-border-radius-large: 10px; + --default-box-shadow: 2px 2px 5px 0 rgba(0, 0, 0, .14); --default-border: #ccc 1px solid; --hint-color: #23064c;