diff --git a/packages/api/src/models/license/license.ts b/packages/api/src/models/license/license.ts index fbad8f423f..20cc7de7ed 100644 --- a/packages/api/src/models/license/license.ts +++ b/packages/api/src/models/license/license.ts @@ -17,7 +17,7 @@ export class License extends Model { readonly licenseCapabilities?: CapabilityList; readonly version?: string; readonly installationId?: string; - readonly valid?: boolean; + readonly valid: boolean; readonly message?: string; readonly present?: boolean; readonly usageRestriction?: string; @@ -42,7 +42,7 @@ export class License extends Model { this.licenseCapabilities = data?.licenseCapabilities; this.version = data?.version || ''; this.installationId = data?.installationId || ''; - this.valid = data?.valid; + this.valid = data?.valid ?? false; this.typeOfUse = data?.typeOfUse || ''; this.message = data?.message || ''; this.present = data?.present || false; diff --git a/packages/api/src/models/repositories/repository-list.ts b/packages/api/src/models/repositories/repository-list.ts index 597189a4d2..ddd6aad9fd 100644 --- a/packages/api/src/models/repositories/repository-list.ts +++ b/packages/api/src/models/repositories/repository-list.ts @@ -22,6 +22,10 @@ export class RepositoryList extends ModelList { super(repositories); } + filterWithCallback(filterCallback: (repository: Repository) => boolean): RepositoryList { + return new RepositoryList(super.filter(filterCallback)); + } + /** * Finds a repository in the list by its ID and location. * diff --git a/packages/api/src/models/repositories/repository.ts b/packages/api/src/models/repositories/repository.ts index e9c074466c..4e2e6a0c9f 100644 --- a/packages/api/src/models/repositories/repository.ts +++ b/packages/api/src/models/repositories/repository.ts @@ -18,6 +18,7 @@ export class Repository extends Model implements RepositoryReference externalUrl: string; location: string; state: RepositoryState | undefined; + stateLowercase: string | undefined; local: boolean | undefined; readable: boolean | undefined; writable: boolean | undefined; @@ -34,6 +35,7 @@ export class Repository extends Model implements RepositoryReference this.externalUrl = data.externalUrl || ''; this.location = data.location || ''; this.state = data.state; + this.stateLowercase = data.stateLowercase; this.local = data.local; this.readable = data.readable; this.writable = data.writable; diff --git a/packages/api/src/services/domain/repository/mappers/repository-list.mapper.ts b/packages/api/src/services/domain/repository/mappers/repository-list.mapper.ts index afcec2f030..612ca18f08 100644 --- a/packages/api/src/services/domain/repository/mappers/repository-list.mapper.ts +++ b/packages/api/src/services/domain/repository/mappers/repository-list.mapper.ts @@ -28,6 +28,7 @@ export const mapRepositoryListResponseToModel: MapperFn this.canWriteRepo(repository)); + } + + /** + * Retrieves a list of repositories that the user has read access to, including those that are readable through + * GraphQL permissions. + * + * @returns {RepositoryList} A list of repositories that the user can read. + */ + getReadableRepositories(): RepositoryList { + return this.repositoryContextService.getRepositoryList().filterWithCallback(repository => + this.canReadRepo(repository) || this.canReadGqlRepo(repository) + ); + } + + /** + * Retrieves a list of repositories that the user has access to, based on the specified parameters. + * @param includeRemote - If true, includes remote repositories in the list; otherwise, only local repositories are included. + * @param isRestricted - If true, returns repositories that the user has write access to; if false, returns repositories that the user has read access to. + * @returns A list of repositories that the user has access to, filtered based on the provided parameters. + */ + getAccessibleRepositories(includeRemote = false, isRestricted = true): RepositoryList { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let remoteLocationsFilter = (_repo: Repository) => true; + if (!includeRemote) { + remoteLocationsFilter = (repo) => !!repo.local; + } + if (isRestricted) { + return new RepositoryList(this.getWritableRepositories().filter(remoteLocationsFilter)); + } else { + return new RepositoryList(this.getReadableRepositories().filter(remoteLocationsFilter)); + } + } + private resolveAuthorities(authoritiesList?: string[]) { // If no authorities list is provided, return empty array. if (!authoritiesList) { diff --git a/packages/legacy-workbench/src/js/angular/core/templates/core-errors.html b/packages/legacy-workbench/src/js/angular/core/templates/core-errors.html index 46e2ed58ca..fe34fd027b 100644 --- a/packages/legacy-workbench/src/js/angular/core/templates/core-errors.html +++ b/packages/legacy-workbench/src/js/angular/core/templates/core-errors.html @@ -2,7 +2,7 @@
-
+
{{'core.errors.no.connected.repository.warning.msg' | translate}}
{{'core.errors.restricted.warning.msg' | translate}} {{'core.errors.not.valid.license.warning.msg' | translate}} @@ -36,7 +36,7 @@ class="list-group-item list-group-item-action repository" ng-class="{'remote': !repository.local}" ng-mouseenter="showPopoverForRepo($event, repository)" ngx-mouseleave="setPopoverForRepo($event, repository, false)"> -
+
{ // singleSpaWebpackConfig.devServer.client.overlay = false; // singleSpaWebpackConfig.devServer.liveReload = false; - // console.log('=============', JSON.stringify(singleSpaWebpackConfig, null, 2)); return singleSpaWebpackConfig; }; diff --git a/packages/workbench/src/app/components/page-layout/page-layout.component.html b/packages/workbench/src/app/components/page-layout/page-layout.component.html index 831c68801c..21a102a477 100644 --- a/packages/workbench/src/app/components/page-layout/page-layout.component.html +++ b/packages/workbench/src/app/components/page-layout/page-layout.component.html @@ -9,11 +9,7 @@

}

-
- @if (!selectedRepository()) { - - } -
+ } diff --git a/packages/workbench/src/app/components/page-layout/page-layout.component.scss b/packages/workbench/src/app/components/page-layout/page-layout.component.scss index 36f7ea9243..2010d7de78 100644 --- a/packages/workbench/src/app/components/page-layout/page-layout.component.scss +++ b/packages/workbench/src/app/components/page-layout/page-layout.component.scss @@ -15,7 +15,7 @@ gap: 0.5rem; } - .page-banners { + app-page-restrictions { display: block; /* Compensate for the potential negative margin as the one coming from the sparql-editor page */ diff --git a/packages/workbench/src/app/components/page-layout/page-layout.component.spec.ts b/packages/workbench/src/app/components/page-layout/page-layout.component.spec.ts index 2cb823593c..c104094466 100644 --- a/packages/workbench/src/app/components/page-layout/page-layout.component.spec.ts +++ b/packages/workbench/src/app/components/page-layout/page-layout.component.spec.ts @@ -5,7 +5,7 @@ import {CommonModule} from '@angular/common'; import {ActivatedRoute} from '@angular/router'; import {provideTranslocoForTesting} from '../../../testing-utils/transloco-utils'; import {provideNoopAnimations} from '@angular/platform-browser/animations'; -import {Repository, RepositoryContextService, RepositoryList, ServiceProvider} from '@ontotext/workbench-api'; +import {RepositoryContextService, RepositoryList, ServiceProvider} from '@ontotext/workbench-api'; function buildActivatedRouteMock(queryParams: Record = {}, data: Record = {}) { return { @@ -136,67 +136,4 @@ describe('PageLayoutComponent', () => { it('should leave documentationLink undefined when not in route data', () => { expect(component.documentationLink()).toBeUndefined(); }); - - describe('repository-required-banner visibility', () => { - it('should show the banner when no repository is selected', () => { - const banner = fixture.nativeElement.querySelector('app-repository-required-banner'); - expect(banner).not.toBeNull(); - }); - - it('should hide the banner when a repository is already selected on init', async () => { - TestBed.resetTestingModule(); - await TestBed.configureTestingModule({ - imports: [PageLayoutComponent, CommonModule, provideTranslocoForTesting()], - providers: [ - {provide: ActivatedRoute, useValue: buildActivatedRouteMock()}, - provideNoopAnimations() - ] - }).compileComponents(); - - const repoContextService = ServiceProvider.get(RepositoryContextService); - const repository = new Repository({id: 'test', location: '', uri: 'http://test'}); - repoContextService.updateRepositoryList(new RepositoryList([repository])); - await repoContextService.updateSelectedRepository(repository); - - const f = TestBed.createComponent(PageLayoutComponent); - f.detectChanges(); - - const banner = f.nativeElement.querySelector('app-repository-required-banner'); - expect(banner).toBeNull(); - }); - - it('should hide the banner reactively when a repository is selected after init', async () => { - const repository = new Repository({id: 'test', location: '', uri: 'http://test'}); - repositoryContextService.updateRepositoryList(new RepositoryList([repository])); - - let banner = fixture.nativeElement.querySelector('app-repository-required-banner'); - expect(banner).not.toBeNull(); - - await repositoryContextService.updateSelectedRepository(repository); - fixture.detectChanges(); - - banner = fixture.nativeElement.querySelector('app-repository-required-banner'); - expect(banner).toBeNull(); - }); - - it('should show the banner again when the selected repository is cleared', async () => { - const repository = new Repository({id: 'test', location: '', uri: 'http://test'}); - repositoryContextService.updateRepositoryList(new RepositoryList([repository])); - await repositoryContextService.updateSelectedRepository(repository); - fixture.detectChanges(); - - expect(fixture.nativeElement.querySelector('app-repository-required-banner')).toBeNull(); - - await repositoryContextService.updateSelectedRepository(undefined); - fixture.detectChanges(); - - expect(fixture.nativeElement.querySelector('app-repository-required-banner')).not.toBeNull(); - }); - - it('should unsubscribe from repository changes on destroy', () => { - const unsubscribeSpy = jest.spyOn(component['subscriptions'], 'unsubscribeAll'); - component.ngOnDestroy(); - expect(unsubscribeSpy).toHaveBeenCalled(); - }); - }); }); diff --git a/packages/workbench/src/app/components/page-layout/page-layout.component.ts b/packages/workbench/src/app/components/page-layout/page-layout.component.ts index fa429af5ba..3f330ea1a1 100644 --- a/packages/workbench/src/app/components/page-layout/page-layout.component.ts +++ b/packages/workbench/src/app/components/page-layout/page-layout.component.ts @@ -1,16 +1,10 @@ -import {Component, inject, OnDestroy, OnInit, signal} from '@angular/core'; +import {Component, inject, OnInit, signal} from '@angular/core'; import {CommonModule} from '@angular/common'; import {ActivatedRoute} from '@angular/router'; import {ApplicationQueryParams} from '../../models/application-query-params'; import {TranslocoPipe} from '@jsverse/transloco'; import {PageInfoTooltipComponent} from '../page-info-tooltip/page-info-tooltip.component'; -import {RepositoryRequiredBannerComponent} from '../repository-required-banner/repository-required-banner.component'; -import { - service, - RepositoryContextService, - Repository, - SubscriptionList, -} from '@ontotext/workbench-api'; +import {PageRestrictionsComponent} from '../page-restrictions/page-restrictions.component'; @Component({ selector: 'app-page-layout', @@ -19,32 +13,20 @@ import { CommonModule, TranslocoPipe, PageInfoTooltipComponent, - RepositoryRequiredBannerComponent + PageRestrictionsComponent ], templateUrl: './page-layout.component.html', styleUrl: './page-layout.component.scss' }) -export class PageLayoutComponent implements OnInit, OnDestroy { - private readonly repositoryContextService = service(RepositoryContextService); - +export class PageLayoutComponent implements OnInit { private readonly activatedRoute = inject(ActivatedRoute); embedded = signal(false); title = signal(undefined); helpInfo = signal(undefined); documentationLink = signal(undefined); - selectedRepository = signal(undefined); - - private readonly subscriptions = new SubscriptionList(); ngOnInit(): void { - this.selectedRepository.set(this.repositoryContextService.getSelectedRepository()); - this.subscriptions.add( - this.repositoryContextService.onSelectedRepositoryChanged((repo) => { - this.selectedRepository.set(repo); - }) - ); - this.getPageData(); const queryParams = this.activatedRoute.snapshot.queryParams; if (queryParams.hasOwnProperty(ApplicationQueryParams.EMBEDDED)) { @@ -52,10 +34,6 @@ export class PageLayoutComponent implements OnInit, OnDestroy { } } - ngOnDestroy(): void { - this.subscriptions.unsubscribeAll(); - } - private getPageData(): void { const routeData = this.activatedRoute.snapshot.data; this.title.set(routeData['title']); diff --git a/packages/workbench/src/app/components/page-restrictions/page-restrictions.component.html b/packages/workbench/src/app/components/page-restrictions/page-restrictions.component.html new file mode 100644 index 0000000000..4b95800cb0 --- /dev/null +++ b/packages/workbench/src/app/components/page-restrictions/page-restrictions.component.html @@ -0,0 +1,18 @@ +
+ @if (restrictions().length) { + + @for (reason of restrictions(); track reason.translationKey) { +
+ {{ reason.translationKey | transloco: reason.translationParams }} + @if (reason.ctaKey) { + {{ reason.ctaKey | transloco }} + } +
+ } +
+ } + @if (repositoryList() && !selectedRepository()) { + + } +
+ diff --git a/packages/workbench/src/app/components/page-restrictions/page-restrictions.component.scss b/packages/workbench/src/app/components/page-restrictions/page-restrictions.component.scss new file mode 100644 index 0000000000..1db3649a9d --- /dev/null +++ b/packages/workbench/src/app/components/page-restrictions/page-restrictions.component.scss @@ -0,0 +1,7 @@ +.page-restrictions { + margin-top: 1em; + + &:empty { + margin-top: 0; + } +} diff --git a/packages/workbench/src/app/components/page-restrictions/page-restrictions.component.spec.ts b/packages/workbench/src/app/components/page-restrictions/page-restrictions.component.spec.ts new file mode 100644 index 0000000000..788c069abd --- /dev/null +++ b/packages/workbench/src/app/components/page-restrictions/page-restrictions.component.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; + +import { PageRestrictionsComponent } from './page-restrictions.component'; +import {provideTranslocoForTesting} from '../../../testing-utils/transloco-utils'; + +describe('PageRestrictionsComponent', () => { + let component: PageRestrictionsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PageRestrictionsComponent, provideTranslocoForTesting()], + providers: [provideRouter([])], + }) + .compileComponents(); + + fixture = TestBed.createComponent(PageRestrictionsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/packages/workbench/src/app/components/page-restrictions/page-restrictions.component.ts b/packages/workbench/src/app/components/page-restrictions/page-restrictions.component.ts new file mode 100644 index 0000000000..d698467711 --- /dev/null +++ b/packages/workbench/src/app/components/page-restrictions/page-restrictions.component.ts @@ -0,0 +1,64 @@ +import {Component, computed, inject, input, OnDestroy, OnInit, signal} from '@angular/core'; +import { + service, + RepositoryContextService, + Repository, + SubscriptionList, + RepositoryList, +} from '@ontotext/workbench-api'; +import {RepositoryPickerListComponent} from '../repository-picker-list/repository-picker-list.component'; +import {Message} from 'primeng/message'; +import {TranslocoPipe} from '@jsverse/transloco'; +import {RouterLink} from '@angular/router'; +import {RestrictionResolverService} from '../../services/page-restrictions/restriction-resolver.service'; + +@Component({ + selector: 'app-page-restrictions', + standalone: true, + imports: [ + RepositoryPickerListComponent, + Message, + TranslocoPipe, + RouterLink, + ], + templateUrl: './page-restrictions.component.html', + styleUrl: './page-restrictions.component.scss', +}) +export class PageRestrictionsComponent implements OnInit, OnDestroy { + /** + * Current page title. + */ + readonly title = input(); + + private readonly restrictionResolverService = inject(RestrictionResolverService); + private readonly repositoryContextService = service(RepositoryContextService); + + repositoryList = signal(undefined); + selectedRepository = signal(undefined); + isRestricted = signal(false); + + private readonly subscriptions = new SubscriptionList(); + + readonly restrictions = computed(() => + this.restrictionResolverService.resolve({ + selectedRepository: this.selectedRepository(), + isRestricted: this.isRestricted(), + pageTitle: this.title() ?? 'Missing page title', + }) + ); + + ngOnInit(): void { + this.selectedRepository.set(this.repositoryContextService.getSelectedRepository()); + this.repositoryList.set(this.repositoryContextService.getRepositoryList()); + + this.subscriptions.add( + this.repositoryContextService.onSelectedRepositoryChanged((repo) => { + this.selectedRepository.set(repo); + }) + ); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribeAll(); + } +} diff --git a/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.component.html b/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.component.html index e75c0e147f..f45f7e1948 100644 --- a/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.component.html +++ b/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.component.html @@ -1 +1,81 @@ -

repository-picker-list works!

+
+
+ @if (vm().showFilters) { + + + + + + + + {{ option.label | transloco }} + + + {{ option.label | transloco }} + + + + } + + @if (vm().canCreateRepository) { + + + } +
+ + @if (vm().showRepositoryList) { + + + + + + + +
+ + @if (repository.state === RepositoryState.RUNNING) { + + } @else if (repository.state === RepositoryState.INACTIVE) { + + } @else { + + } + + {{repository.id}} +
+
+ {{repository.title}} +
+
+ {{repository.location ?? 'components.repository_picker_list.location_local_label' | transloco}} +
+ + + @if (vm().canSelectRepository) { + + + } + + +
+
+ } +
diff --git a/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.component.scss b/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.component.scss new file mode 100644 index 0000000000..b8a4514a5e --- /dev/null +++ b/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.component.scss @@ -0,0 +1,113 @@ +@use 'src/styles/variables' as *; + +.repository-picker-list { + color: var(--gw-panel-color); + + .toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: .8em 0; + gap: .5em; + + .filters { + display: flex; + gap: .4em; + } + + .create-repository-btn { + margin-left: auto; + } + } + + .repository-list { + width: 100%; + table-layout: fixed; + border: 1px solid var(--gw-datatable-border-color); + + tr > td { + padding: .4em; + } + + .info-column { + width: 4rem; + } + + .actions-column { + width: 7rem; + + .select-repository-btn { + visibility: hidden; + } + } + + tr:hover { + background-color: var(--gw-datatable-row-hover-background); + + .actions-column .select-repository-btn { + visibility: visible; + } + } + + .type-column { + width: 2.5rem; + padding-right: 0 !important; + text-align: center; + + .type-icon { + font-size: 1.2em; + color: var(--gw-button-primary-link-hover-color); + } + } + + .name-column { + padding-left: 0 !important; + overflow: hidden; + + // wrapper div for state icon + id + div:first-child { + display: flex; + align-items: baseline; + overflow: hidden; + min-width: 0; + } + + .repository-description, + .repository-location { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .repository-id { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1; + } + + .repository-location { + color: var(--gw-info-light); + } + + .repository-state { + font-size: .8em; + margin-right: .5em; + + .running { + color: var(--gw-button-success-background); + } + + .inactive { + color: var(--gw-button-info-background); + } + + .transitioning { + color: var(--gw-button-warn-background); + } + } + } + } +} diff --git a/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.component.spec.ts b/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.component.spec.ts deleted file mode 100644 index 7d1c68ee6c..0000000000 --- a/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { RepositoryPickerListComponent } from './repository-picker-list.component'; - -describe('RepositoryPickerListComponent', () => { - let component: RepositoryPickerListComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RepositoryPickerListComponent] - }) - .compileComponents(); - - fixture = TestBed.createComponent(RepositoryPickerListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.component.ts b/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.component.ts index b1cb04b0b4..f72b8b51dd 100644 --- a/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.component.ts +++ b/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.component.ts @@ -1,10 +1,132 @@ -import { Component } from '@angular/core'; +import {Component, input, OnDestroy, OnInit, signal} from '@angular/core'; +import {TranslocoPipe} from '@jsverse/transloco'; +import {Button} from 'primeng/button'; +import { + service, + RepositoryContextService, + LicenseContextService, + Repository, + navigate, + WindowService, + RepositoryState, + SubscriptionList, + AuthorizationService, +} from '@ontotext/workbench-api'; +import {NgClass} from '@angular/common'; +import {TableModule} from 'primeng/table'; +import {InputText} from 'primeng/inputtext'; +import {IconField} from 'primeng/iconfield'; +import {InputIcon} from 'primeng/inputicon'; +import {Select} from 'primeng/select'; +import {FormsModule} from '@angular/forms'; +import {Tooltip} from 'primeng/tooltip'; +import {RepositoryPickerListViewModel} from './repository-picker-list.view-model'; @Component({ selector: 'app-repository-picker-list', - imports: [], + imports: [ + TranslocoPipe, + Button, + NgClass, + TableModule, + InputText, + IconField, + InputIcon, + Select, + FormsModule, + Tooltip, + ], templateUrl: './repository-picker-list.component.html', + styleUrl: './repository-picker-list.component.scss', }) -export class RepositoryPickerListComponent { +export class RepositoryPickerListComponent implements OnInit, OnDestroy { + private readonly repositoryContextService = service(RepositoryContextService); + private readonly licenseContextService = service(LicenseContextService); + private readonly authorizationService = service(AuthorizationService); + /** + * If the user has permissions to manage repositories for example. + */ + isRestricted = input(true); + + /** + * List of subscriptions to context changes, to be unsubscribed on component destroy. + */ + private readonly subscriptions = new SubscriptionList(); + + /** + * The view model holds all data and visualization state for the component. It is implemented as a signal so that the + * template can reactively update when the view model changes. The view model is updated in response to context + * changes and user interactions (e.g. filtering). + */ + readonly vm = signal(new RepositoryPickerListViewModel()); + + /** + * Expose the RepositoryState enum to the template for use in filtering and displaying repository states. + */ + readonly RepositoryState = RepositoryState; + + ngOnInit(): void { + this.vm.update((vm) => { + vm.repositoryList = this.authorizationService.getAccessibleRepositories(true, this.isRestricted()).getItems(); + vm.license = this.licenseContextService.getLicenseSnapshot(); + vm.canManageRepositories = this.authorizationService.isRepoManager(); + return vm; + }); + + this.subscriptions.addAll([ + this.repositoryContextService.onRepositoryListChanged( + () => this.onRepositoryListChanged()), + ]); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribeAll(); + } + + /** + * Navigates to the repository creation page, passing the current page as a query parameter to allow returning to it + * after repository creation. + */ + createRepository(): void { + const currentPage = WindowService.getLocationPathname(); + navigate(`/repository/create?previous=${currentPage}`); + } + + /** + * Updates the selected repository in the repository context when a repository is selected from the list. + * @param repository The repository that was selected by the user. + */ + async selectRepository(repository: Repository): Promise { + await this.repositoryContextService.updateSelectedRepository(repository.toRepositoryReference()); + } + + /** + * Updates the filter query on the view model when the user types in the search input. + * @param query The new filter query value. + */ + onFilterQueryChange(query: string): void { + this.vm.update((vm) => { + vm.filterQuery = query; + return vm; + }); + } + + /** + * Updates the state filter on the view model when the user selects a state from the dropdown. + * @param state The new state filter value. + */ + onStateFilterChange(state: RepositoryState | null): void { + this.vm.update((vm) => { + vm.stateFilter = state; + return vm; + }); + } + + private onRepositoryListChanged(): void { + this.vm.update((vm) => { + vm.repositoryList = this.authorizationService.getAccessibleRepositories(true, this.isRestricted()).getItems(); + return vm; + }); + } } diff --git a/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.view-model.ts b/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.view-model.ts new file mode 100644 index 0000000000..9abd9dbfec --- /dev/null +++ b/packages/workbench/src/app/components/repository-picker-list/repository-picker-list.view-model.ts @@ -0,0 +1,91 @@ +import {Repository, RepositoryState, License} from '@ontotext/workbench-api'; + +export interface StateFilterOption { + label: string; + value: RepositoryState | null; +} + +/** + * View model for the RepositoryPickerListComponent. + * Holds all data and visualization state for the component. + */ +export class RepositoryPickerListViewModel { + /** + * Full list of repositories loaded from the context. + */ + repositoryList: Repository[] = []; + + /** + * The current license, used to control action visibility (e.g. "Select" button). + */ + license: License | undefined = undefined; + + /** + * Whether the user has permissions to manage repositories. If false, the "Create Repository" button will be hidden. + */ + canManageRepositories = false; + + /** + * Text typed into the search input to filter repositories by id or title. + */ + filterQuery = ''; + + /** + * The currently selected state filter value. Null means "all". + */ + stateFilter: RepositoryState | null = null; + + /** + * Fixed set of options for the state filter dropdown. + */ + readonly stateFilterOptions: StateFilterOption[] = [ + {label: 'components.repository_picker_list.state_filter.all', value: null}, + {label: 'components.repository_picker_list.state_filter.running', value: RepositoryState.RUNNING}, + {label: 'components.repository_picker_list.state_filter.inactive', value: RepositoryState.INACTIVE}, + ]; + + /** + * Returns the repositories matching the current filterQuery and stateFilter. + */ + get filteredRepositoryList(): Repository[] { + const query = this.filterQuery.trim().toLowerCase(); + const state = this.stateFilter; + return this.repositoryList.filter((repo) => { + const matchesQuery = + !query || + repo.id?.toLowerCase().includes(query) || + repo.title?.toLowerCase().includes(query); + const matchesState = state === null || repo.state === state; + return matchesQuery && matchesState; + }); + } + + /** + * Whether the repository list toolbar filters should be shown. + * Filters are only relevant when there is at least one repository. + */ + get showFilters(): boolean { + return this.repositoryList.length > 0; + } + + /** + * Whether the create repository button should be shown. Depends on user permissions and current license validity. + */ + get canCreateRepository(): boolean { + return this.canManageRepositories && !!this.license?.valid; + } + + /** + * Whether the repository table should be rendered. + */ + get showRepositoryList(): boolean { + return this.repositoryList.length > 0; + } + + /** + * Whether the select repository button should be shown for a row. + */ + get canSelectRepository(): boolean { + return !!this.license?.valid; + } +} diff --git a/packages/workbench/src/app/components/repository-required-banner/repository-required-banner.component.html b/packages/workbench/src/app/components/repository-required-banner/repository-required-banner.component.html deleted file mode 100644 index 9e1092613c..0000000000 --- a/packages/workbench/src/app/components/repository-required-banner/repository-required-banner.component.html +++ /dev/null @@ -1,7 +0,0 @@ -
-
- - {{ 'components.repository_banners.no_active_repository' | transloco }} - -
-
diff --git a/packages/workbench/src/app/components/repository-required-banner/repository-required-banner.component.scss b/packages/workbench/src/app/components/repository-required-banner/repository-required-banner.component.scss deleted file mode 100644 index ae63db4370..0000000000 --- a/packages/workbench/src/app/components/repository-required-banner/repository-required-banner.component.scss +++ /dev/null @@ -1,7 +0,0 @@ -:host { - display: block; -} - -.repository-required-banner { - margin-top: 1em; -} diff --git a/packages/workbench/src/app/components/repository-required-banner/repository-required-banner.component.spec.ts b/packages/workbench/src/app/components/repository-required-banner/repository-required-banner.component.spec.ts deleted file mode 100644 index 675aa3b5e7..0000000000 --- a/packages/workbench/src/app/components/repository-required-banner/repository-required-banner.component.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { RepositoryRequiredBannerComponent } from './repository-required-banner.component'; -import {provideTranslocoForTesting} from '../../../testing-utils/transloco-utils'; -import {provideNoopAnimations} from '@angular/platform-browser/animations'; - -describe('RepositoryRequiredBannerComponent', () => { - let component: RepositoryRequiredBannerComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RepositoryRequiredBannerComponent, provideTranslocoForTesting()], - providers: [provideNoopAnimations()] - }) - .compileComponents(); - - fixture = TestBed.createComponent(RepositoryRequiredBannerComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/packages/workbench/src/app/components/repository-required-banner/repository-required-banner.component.ts b/packages/workbench/src/app/components/repository-required-banner/repository-required-banner.component.ts deleted file mode 100644 index 634debdf2f..0000000000 --- a/packages/workbench/src/app/components/repository-required-banner/repository-required-banner.component.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {Component} from '@angular/core'; -import {TranslocoPipe} from '@jsverse/transloco'; -import {MessageModule} from 'primeng/message'; - -@Component({ - selector: 'app-repository-required-banner', - imports: [ - TranslocoPipe, - MessageModule - ], - templateUrl: './repository-required-banner.component.html', - styleUrl: './repository-required-banner.component.scss', -}) -export class RepositoryRequiredBannerComponent { - -} diff --git a/packages/workbench/src/app/pages/ux-test/ux-test-page.component.html b/packages/workbench/src/app/pages/ux-test/ux-test-page.component.html index da89ee3bf8..bf47af5bb5 100644 --- a/packages/workbench/src/app/pages/ux-test/ux-test-page.component.html +++ b/packages/workbench/src/app/pages/ux-test/ux-test-page.component.html @@ -1,5 +1,5 @@
-
Buttons:
+

Buttons:

@@ -11,24 +11,47 @@
-
Checkbox
+

Checkbox


-
InputText
+

InputText




-
Progressbar
+

Progressbar


-
Confirmation dialog
+

Confirmation dialog

+ +
+

Message

+ + Success message + + + Info message + + + Warn message + + + Error message + + + Secondary message + + + Contrast message + + +
diff --git a/packages/workbench/src/app/pages/ux-test/ux-test-page.component.ts b/packages/workbench/src/app/pages/ux-test/ux-test-page.component.ts index c631ff2405..1aa68c22b3 100644 --- a/packages/workbench/src/app/pages/ux-test/ux-test-page.component.ts +++ b/packages/workbench/src/app/pages/ux-test/ux-test-page.component.ts @@ -13,6 +13,7 @@ import {ConfirmDialogModule} from 'primeng/confirmdialog'; import {ToastModule} from 'primeng/toast'; import {ConfirmationService, MessageService} from 'primeng/api'; import {ProgressBarModule} from 'primeng/progressbar'; +import {Message} from 'primeng/message'; @Component({ selector: 'app-ux-test', @@ -31,6 +32,7 @@ import {ProgressBarModule} from 'primeng/progressbar'; ConfirmDialogModule, ToastModule, ProgressBarModule, + Message, ], providers: [ConfirmationService, MessageService], schemas: [CUSTOM_ELEMENTS_SCHEMA], diff --git a/packages/workbench/src/app/services/page-restrictions/restriction-reason.ts b/packages/workbench/src/app/services/page-restrictions/restriction-reason.ts new file mode 100644 index 0000000000..0f964ae55c --- /dev/null +++ b/packages/workbench/src/app/services/page-restrictions/restriction-reason.ts @@ -0,0 +1,9 @@ +export type RestrictionSeverity = 'info' | 'warn'; + +export interface RestrictionReason { + severity: RestrictionSeverity; + translationKey: string; + translationParams?: Record; + ctaKey?: string; + ctaLink?: string; +} diff --git a/packages/workbench/src/app/services/page-restrictions/restriction-resolver.service.ts b/packages/workbench/src/app/services/page-restrictions/restriction-resolver.service.ts new file mode 100644 index 0000000000..0d20856ec0 --- /dev/null +++ b/packages/workbench/src/app/services/page-restrictions/restriction-resolver.service.ts @@ -0,0 +1,103 @@ +import {inject, Injectable} from '@angular/core'; +import {TranslocoService} from '@jsverse/transloco'; +import { + AuthorizationService, + LicenseContextService, + Repository, + SecurityContextService, + service, +} from '@ontotext/workbench-api'; +import {RestrictionReason} from './restriction-reason'; + +export interface RestrictionContext { + selectedRepository: Repository | undefined; + isRestricted: boolean; + pageTitle: string; + ctaKey?: string; + ctaLink?: string; +} + +@Injectable({ providedIn: 'root' }) +export class RestrictionResolverService { + private readonly transloco = inject(TranslocoService); + private readonly licenseContextService = service(LicenseContextService); + private readonly securityContextService = service(SecurityContextService); + private readonly authorizationService = service(AuthorizationService); + + resolve(ctx: RestrictionContext): RestrictionReason[] { + const repo = ctx.selectedRepository; + const isLicenseValid = this.licenseContextService.getLicenseSnapshot()?.valid ?? false; + const isSecurityEnabled = this.securityContextService.getSecurityConfig()?.isEnabled() ?? false; + const canWrite = this.authorizationService.canWriteRepo(repo); + const canManage = this.authorizationService.isRepoManager(); + const accessibleRepositoriesCount = this.authorizationService.getAccessibleRepositories(true, ctx.isRestricted).size(); + const hasAccessibleRepositories = accessibleRepositoriesCount > 0; + + const reasons: RestrictionReason[] = []; + + if (!repo && isLicenseValid) { + if (canManage && !hasAccessibleRepositories) { + reasons.push({ + severity: 'info', + translationKey: 'components.page_restrictions.no_accessible_repos_create_one', + }); + } else if (canManage && hasAccessibleRepositories) { + reasons.push({ + severity: 'info', + translationKey: 'components.page_restrictions.no_active_repository_select_or_create_one', + }); + } else if (!canManage && hasAccessibleRepositories) { + reasons.push({ + severity: 'info', + translationKey: 'components.page_restrictions.no_active_repository_select_one', + }); + } + } + + if (repo) { + if (isSecurityEnabled && !canWrite) { + reasons.push({ + severity: 'warn', + translationKey: 'components.page_restrictions.no_write_permission', + translationParams: { repositoryId: repo.id }, + }); + } + + if (canWrite && repo.isOntop()) { + reasons.push({ + severity: 'warn', + translationKey: 'components.page_restrictions.read_only_ontop', + translationParams: {repositoryId: repo.id}, + }); + } + + if (canWrite && repo.isFedx()) { + reasons.push({ + severity: 'warn', + translationKey: 'components.page_restrictions.fedx_unsupported', + translationParams: {pageTitle: ctx.pageTitle}, + }); + } + } + + if (!isLicenseValid) { + reasons.push({ + severity: 'warn', + translationKey: 'components.page_restrictions.invalid_license', + ctaKey: 'components.page_restrictions.set_new_license', + ctaLink: '/license', + }); + } + + if (!hasAccessibleRepositories) { + if (ctx.isRestricted) { + reasons.push({ + severity: 'info', + translationKey: 'components.page_restrictions.no_accessible_writable_repos', + }); + } + } + + return reasons; + } +} diff --git a/packages/workbench/src/assets/i18n/en.json b/packages/workbench/src/assets/i18n/en.json index 0fabb7a193..f5663f9198 100644 --- a/packages/workbench/src/assets/i18n/en.json +++ b/packages/workbench/src/assets/i18n/en.json @@ -50,8 +50,34 @@ "page_info_tooltip": { "documentation_link": "Learn more in the GraphDB documentation" }, - "repository_banners": { - "no_active_repository": "Some functionalities are not available because you are not connected to any repository." + "page_restrictions": { + "no_active_repository_select_one": "Some functionalities are not available because you are not connected to any repository. Select a repository below.", + "no_active_repository_select_or_create_one": "Some functionalities are not available because you are not connected to any repository. Select a repository below or create a new one.", + "no_accessible_repos_create_one": "There are no accessible repositories. You can create a new repository.", + "invalid_license": "Some functionalities are not available because your license is not valid.", + "set_new_license": "Set a new license", + "no_write_permission": "Some functionalities are not available because you have no write permission to the repository {{repositoryId}}.", + "read_only_ontop": "Some functionalities are not available because {{repositoryId}} is read-only Virtual Repository.", + "fedx_unsupported": "Some functionalities are not available because {{pageTitle}} view is not supported by FedX Repository.", + "no_accessible_writable_repos": "There are no accessible writable repositories." + }, + "repository_picker_list": { + "hide_remote_locations_btn": { + "label": "Hide remote locations" + }, + "create_repository_btn": { + "label": "Create new repository" + }, + "select_repository_btn": { + "label": "Select" + }, + "location_local_label": "Local", + "filter_placeholder": "Filter by name or title", + "state_filter": { + "all": "All repositories", + "running": "Running", + "inactive": "Inactive" + } } }, "login": { diff --git a/packages/workbench/src/assets/i18n/fr.json b/packages/workbench/src/assets/i18n/fr.json index 6e5f86e6e4..89f6ad97ba 100644 --- a/packages/workbench/src/assets/i18n/fr.json +++ b/packages/workbench/src/assets/i18n/fr.json @@ -50,8 +50,34 @@ "page_info_tooltip": { "documentation_link": "Pour en savoir plus, consultez la documentation GraphDB" }, - "repository_banners": { - "no_active_repository": "Certaines fonctionnalités ne sont pas disponibles car vous n'êtes connecté à aucun dépôt." + "page_restrictions": { + "no_active_repository_select_one": "Certaines fonctionnalités ne sont pas disponibles car vous n'êtes connecté à aucun dépôt. Sélectionnez un dépôt ci-dessous.", + "no_active_repository_select_or_create_one": "Certaines fonctionnalités ne sont pas disponibles car vous n'êtes connecté à aucun dépôt. Sélectionnez un dépôt ci-dessous ou créez-en un nouveau.", + "no_accessible_repos_create_one": "Il n'y a pas de dépôts accessibles. Vous pouvez créer un nouveau dépôt.", + "invalid_license": "Certaines fonctionnalités ne sont pas disponibles car votre licence n'est pas valide.", + "set_new_license": "Définir une nouvelle licence", + "no_write_permission": "Certaines fonctionnalités ne sont pas disponibles car vous n'avez pas de droit d'écriture sur le dépôt {{repositoryId}}.", + "read_only_ontop": "Certaines fonctionnalités ne sont pas disponibles car {{repositoryId}} est un dépôt virtuel en lecture seule.", + "fedx_unsupported": "Certaines fonctionnalités ne sont pas disponibles car la vue {{pageTitle}} n'est pas prise en charge par le dépôt FedX.", + "no_accessible_writable_repos": "Il n'y a pas de dépôts accessibles en écriture." + }, + "repository_picker_list": { + "hide_remote_locations_btn": { + "label": "Masquer les emplacements distants" + }, + "create_repository_btn": { + "label": "Créer un dépôt" + }, + "select_repository_btn": { + "label": "Select" + }, + "location_local_label": "Local", + "filter_placeholder": "Filtrer par nom ou titre", + "state_filter": { + "all": "Tous les dépôts", + "running": "En cours", + "inactive": "Inactif" + } } }, "login": { diff --git a/packages/workbench/src/styles.scss b/packages/workbench/src/styles.scss index 79da01a2d4..111f303875 100644 --- a/packages/workbench/src/styles.scss +++ b/packages/workbench/src/styles.scss @@ -1,2 +1,4 @@ /* You can add global styles to this file, and also import other style files */ @use "styles/primeng-overrides"; +@use "styles/variables"; + diff --git a/packages/workbench/src/styles/_variables.scss b/packages/workbench/src/styles/_variables.scss new file mode 100644 index 0000000000..f78378af91 --- /dev/null +++ b/packages/workbench/src/styles/_variables.scss @@ -0,0 +1,7 @@ +// Responsive breakpoints +$gw-breakpoint-xs: 0px; +$gw-breakpoint-sm: 576px; +$gw-breakpoint-md: 768px; +$gw-breakpoint-lg: 992px; +$gw-breakpoint-xl: 1200px; +$gw-breakpoint-xxl: 1400px; diff --git a/packages/workbench/src/styles/primeng/_button.scss b/packages/workbench/src/styles/primeng/_button.scss index 8ba0bc5cfa..68176a7d62 100644 --- a/packages/workbench/src/styles/primeng/_button.scss +++ b/packages/workbench/src/styles/primeng/_button.scss @@ -1,6 +1,12 @@ .p-button.p-button-link { - text-decoration-line: underline; - text-decoration-style: dotted; - text-decoration-thickness: var(--link-decoration-thickness); - text-underline-offset: var(--link-underline-offset); + color: var(--gw-button-primary-link-hover-color); + + // Show the underline only below the label. If there is an icon in-front or after the link button, we don't want + // underline to be shown below the icon. + .p-button-label { + text-decoration-line: underline; + text-decoration-style: dotted; + text-decoration-thickness: var(--link-decoration-thickness); + text-underline-offset: var(--link-underline-offset); + } }