diff --git a/Makefile b/Makefile index 8532d6d284..92feef254b 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,13 @@ CURRENT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) include vars.mk +################################# +# Test and lint targets +################################# +.PHONY: find-ignored-tests +find-ignored-tests: ## find all ignored tests (e.g. fdescribe) + ./find-ignored-tests.sh + ################################# # Documentation targets ################################# diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4c09eeba43..b088a0057e 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -61,6 +61,7 @@ import { PropertyFormComponent } from './project/ontology/property-form/property import { PropertyInfoComponent } from './project/ontology/property-info/property-info.component'; import { ResourceClassFormComponent } from './project/ontology/resource-class-form/resource-class-form.component'; import { ResourceClassInfoComponent } from './project/ontology/resource-class-info/resource-class-info.component'; +import { ResourceClassPropertyFormComponent } from './project/ontology/resource-class-property-form/resource-class-property-form.component'; import { AddGroupComponent } from './project/permission/add-group/add-group.component'; import { PermissionComponent } from './project/permission/permission.component'; import { ProjectFormComponent } from './project/project-form/project-form.component'; @@ -90,10 +91,6 @@ import { SelectResourceClassComponent } from './workspace/resource/resource-inst import { ResourceComponent } from './workspace/resource/resource.component'; import { ResultsComponent } from './workspace/results/results.component'; - - - - // translate: AoT requires an exported function for factories export function httpLoaderFactory(httpClient: HttpClient) { return new TranslateHttpLoader(httpClient, 'assets/i18n/', '.json'); @@ -101,97 +98,98 @@ export function httpLoaderFactory(httpClient: HttpClient) { @NgModule({ declarations: [ + AccountComponent, + AddGroupComponent, + AddressTemplateComponent, + AddUserComponent, AppComponent, - ProjectComponent, + AttributionTabViewComponent, BoardComponent, - ProjectFormComponent, CollaborationComponent, - AddUserComponent, - OntologyComponent, - UserComponent, - PasswordFormComponent, - ProfileComponent, - ProjectsListComponent, - UserFormComponent, CollectionListComponent, - UserMenuComponent, - MainComponent, - HeaderComponent, - ErrorComponent, - LoginComponent, - AccountComponent, - SelectLanguageComponent, - ProjectsComponent, - SelectGroupComponent, - ResultsComponent, - ResourceComponent, + ContactsTabViewComponent, + CookiePolicyComponent, DashboardComponent, + DatasetTabViewComponent, DialogComponent, - SystemComponent, - UsersComponent, - UsersListComponent, DialogHeaderComponent, + EditListItemComponent, + ErrorComponent, + ExternalLinksDirective, + FooterComponent, GridComponent, - CookiePolicyComponent, GroupsComponent, GroupsListComponent, - PermissionComponent, - AddGroupComponent, - ResourceClassFormComponent, - PropertyFormComponent, - OntologyFormComponent, - OntologyVisualizerComponent, - VisualizerComponent, + HeaderComponent, + HelpComponent, + InvalidControlScrollDirective, ListComponent, ListInfoFormComponent, ListItemComponent, ListItemFormComponent, + LoginComponent, + MainComponent, MembershipComponent, - HelpComponent, - FooterComponent, - ExternalLinksDirective, - InvalidControlScrollDirective, + OntologyComponent, + OntologyFormComponent, + OntologyVisualizerComponent, + OrganisationTemplateComponent, + PasswordFormComponent, + PermissionComponent, + PersonTemplateComponent, + ProfileComponent, + ProjectComponent, + ProjectFormComponent, + ProjectsComponent, + ProjectsListComponent, + ProjectTabViewComponent, + PropertyFormComponent, + PropertyInfoComponent, + ResourceClassFormComponent, + ResourceClassInfoComponent, + ResourceClassPropertyFormComponent, + ResourceComponent, ResourceInstanceFormComponent, - SelectProjectComponent, + ResultsComponent, + SelectGroupComponent, + SelectLanguageComponent, SelectOntologyComponent, - SelectResourceClassComponent, + SelectProjectComponent, SelectPropertiesComponent, + SelectResourceClassComponent, SwitchPropertiesComponent, - ProjectTabViewComponent, - DatasetTabViewComponent, - AttributionTabViewComponent, + SystemComponent, TermsTabViewComponent, - ContactsTabViewComponent, - PersonTemplateComponent, - AddressTemplateComponent, - OrganisationTemplateComponent, - EditListItemComponent, - PropertyInfoComponent, - ResourceClassInfoComponent, - UrlTemplateComponent + UrlTemplateComponent, + UserComponent, + UserFormComponent, + UserMenuComponent, + UsersComponent, + UsersListComponent, + VisualizerComponent, ], imports: [ AppRoutingModule, - BrowserModule, + AngularSplitModule.forRoot(), BrowserAnimationsModule, + BrowserModule, + ClipboardModule, CommonModule, - HttpClientModule, - DspCoreModule, - DspViewerModule, DspActionModule, + DspCoreModule, DspSearchModule, + DspViewerModule, + FormsModule, + HttpClientModule, MaterialModule, ReactiveFormsModule, - FormsModule, TranslateModule.forRoot({ loader: { provide: TranslateLoader, useFactory: httpLoaderFactory, deps: [HttpClient] } - }), - ClipboardModule, - AngularSplitModule.forRoot() + }) ], providers: [ { diff --git a/src/app/main/declarations/autocomplete-item.ts b/src/app/main/declarations/autocomplete-item.ts deleted file mode 100644 index 79296952ef..0000000000 --- a/src/app/main/declarations/autocomplete-item.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * a list, which is used in the mat-autocomplete form field - * contains objects with id and name. the id is usual the iri - */ -export interface AutocompleteItem { - iri: string; - name: string; - label?: string; -} diff --git a/src/app/main/dialog/dialog.component.html b/src/app/main/dialog/dialog.component.html index d90b9dfb81..0cc68ccc16 100644 --- a/src/app/main/dialog/dialog.component.html +++ b/src/app/main/dialog/dialog.component.html @@ -141,7 +141,8 @@
- +
@@ -149,14 +150,17 @@
- + +
- +
@@ -164,12 +168,14 @@
- +
- + + Do you want to delete this node? + + +
@@ -292,11 +325,23 @@ + +
+ +
+

+ + + + +
diff --git a/src/app/project/collaboration/add-user/add-user.component.ts b/src/app/project/collaboration/add-user/add-user.component.ts index 1b60c2e69b..4ddc4ccf09 100644 --- a/src/app/project/collaboration/add-user/add-user.component.ts +++ b/src/app/project/collaboration/add-user/add-user.component.ts @@ -11,11 +11,10 @@ import { UserResponse, UsersResponse } from '@dasch-swiss/dsp-js'; -import { DspApiConnectionToken, existingNamesValidator } from '@dasch-swiss/dsp-ui'; +import { AutocompleteItem, DspApiConnectionToken, existingNamesValidator } from '@dasch-swiss/dsp-ui'; import { Observable } from 'rxjs'; import { map, startWith } from 'rxjs/operators'; import { CacheService } from 'src/app/main/cache/cache.service'; -import { AutocompleteItem } from 'src/app/main/declarations/autocomplete-item'; import { DialogComponent } from 'src/app/main/dialog/dialog.component'; import { ErrorHandlerService } from 'src/app/main/error/error-handler.service'; diff --git a/src/app/project/collaboration/select-group/select-group.component.ts b/src/app/project/collaboration/select-group/select-group.component.ts index d105db6d99..edd9e2b4e3 100644 --- a/src/app/project/collaboration/select-group/select-group.component.ts +++ b/src/app/project/collaboration/select-group/select-group.component.ts @@ -1,9 +1,8 @@ import { Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core'; import { FormControl } from '@angular/forms'; import { ApiResponseData, ApiResponseError, GroupsResponse, KnoraApiConnection } from '@dasch-swiss/dsp-js'; -import { DspApiConnectionToken } from '@dasch-swiss/dsp-ui'; +import { AutocompleteItem, DspApiConnectionToken } from '@dasch-swiss/dsp-ui'; import { CacheService } from 'src/app/main/cache/cache.service'; -import { AutocompleteItem } from 'src/app/main/declarations/autocomplete-item'; import { ErrorHandlerService } from 'src/app/main/error/error-handler.service'; @Component({ diff --git a/src/app/project/list/list-info-form/list-info-form.component.html b/src/app/project/list/list-info-form/list-info-form.component.html index 9146368562..f39251bff9 100644 --- a/src/app/project/list/list-info-form/list-info-form.component.html +++ b/src/app/project/list/list-info-form/list-info-form.component.html @@ -4,10 +4,10 @@
- - {{ labelInvalidMessage }} + {{ labelInvalidMessage }}

diff --git a/src/app/project/list/list-item-form/edit-list-item/edit-list-item.component.html b/src/app/project/list/list-item-form/edit-list-item/edit-list-item.component.html index 248fdbd988..9948d8e245 100644 --- a/src/app/project/list/list-item-form/edit-list-item/edit-list-item.component.html +++ b/src/app/project/list/list-item-form/edit-list-item/edit-list-item.component.html @@ -4,11 +4,11 @@ - {{ formInvalidMessage }} + {{ formInvalidMessage }}

diff --git a/src/app/project/list/list-item-form/edit-list-item/edit-list-item.component.spec.ts b/src/app/project/list/list-item-form/edit-list-item/edit-list-item.component.spec.ts index edd6a58e97..cca88d25e8 100644 --- a/src/app/project/list/list-item-form/edit-list-item/edit-list-item.component.spec.ts +++ b/src/app/project/list/list-item-form/edit-list-item/edit-list-item.component.spec.ts @@ -130,7 +130,7 @@ describe('EditListItemComponent', () => { testHostComponent.editListItem.handleData([], 'labels'); expect(testHostComponent.editListItem.saveButtonDisabled).toBeTruthy(); testHostFixture.detectChanges(); - formInvalidMessageDe = editListItemComponentDe.query(By.css('span.invalid-form')); + formInvalidMessageDe = editListItemComponentDe.query(By.css('mat-hint.invalid-form')); expect(formInvalidMessageDe.nativeElement.innerText).toEqual('A label is required.'); }); diff --git a/src/app/project/list/list-item-form/list-item-form.component.scss b/src/app/project/list/list-item-form/list-item-form.component.scss index 05a38548df..18594bec0f 100644 --- a/src/app/project/list/list-item-form/list-item-form.component.scss +++ b/src/app/project/list/list-item-form/list-item-form.component.scss @@ -31,51 +31,4 @@ .list-item { position: relative; - - .action-bubble { - position: absolute; - right: 5px; - top: 0px; - border: 1px solid #e4e4e4; - border-radius: 10px; - padding: 0 1px; - background-color: #e4e4e4; - z-index: 2; - box-shadow: #949494 1px 4px 5px 0px; - - .button-container { - - button { - cursor: pointer; - border: none; - padding: 2px; - outline: none; - background-color: transparent; - color: #000000; - margin: 0 2px; - border-radius: 10px; - transition: background-color ease-out 0.5s; - min-width: inherit; - line-height: normal; - - .material-icons { - font-size: 18px; - } - - .mat-icon { - width: 18px; - height: 18px; - vertical-align: middle; - } - } - - button.info { - cursor: default; - } - - button:hover { - background-color: #c7c7c7; - } - } - } } diff --git a/src/app/project/ontology/default-data/default-properties.ts b/src/app/project/ontology/default-data/default-properties.ts index d8b0d47bc0..2ab74f032c 100644 --- a/src/app/project/ontology/default-data/default-properties.ts +++ b/src/app/project/ontology/default-data/default-properties.ts @@ -1,11 +1,28 @@ -import { Constants } from '@dasch-swiss/dsp-js'; +import { Constants, ResourcePropertyDefinitionWithAllLanguages } from '@dasch-swiss/dsp-js'; -export interface Category { +/** + * property object with all information to create or edit a property + */ +export interface PropertyInfoObject { + propDef?: ResourcePropertyDefinitionWithAllLanguages; + propType: DefaultProperty; +} + +/** + * property category can be + * text, list, data and time, number, link, location and shape + */ +export interface PropertyCategory { group: string; - elements: PropertyType[]; + elements: DefaultProperty[]; } -export interface PropertyType { +/** + * own default property defined for the gui + * with this information we can build the correct + * property object to send it to the API + */ +export interface DefaultProperty { icon: string; label: string; subPropOf: string; @@ -15,7 +32,7 @@ export interface PropertyType { } export class DefaultProperties { - public static data: Category[] = [ + public static data: PropertyCategory[] = [ { group: 'Text', elements: [ @@ -49,11 +66,11 @@ export class DefaultProperties { group: 'List', elements: [ { - icon: 'radio_button_checked', - label: 'Multiple choice', + icon: 'arrow_drop_down_circle', + label: 'Dropdown', subPropOf: Constants.HasValue, objectType: Constants.ListValue, - guiEle: Constants.SalsahGui + Constants.HashDelimiter + 'Radio', + guiEle: Constants.SalsahGui + Constants.HashDelimiter + 'List', // 'Pulldown' group: 'List' }, { @@ -65,11 +82,11 @@ export class DefaultProperties { group: 'List' }, { - icon: 'arrow_drop_down_circle', - label: 'Dropdown', + icon: 'radio_button_checked', + label: 'Multiple choice', subPropOf: Constants.HasValue, objectType: Constants.ListValue, - guiEle: Constants.SalsahGui + Constants.HashDelimiter + 'List', // 'Pulldown' + guiEle: Constants.SalsahGui + Constants.HashDelimiter + 'Radio', group: 'List' }, { @@ -103,10 +120,10 @@ export class DefaultProperties { }, { icon: 'access_time', - label: 'Time', + label: 'Timestamp', subPropOf: Constants.HasValue, objectType: Constants.TimeValue, - guiEle: Constants.SalsahGui + Constants.HashDelimiter + 'Interval', + guiEle: Constants.SalsahGui + Constants.HashDelimiter + 'TimeStamp', group: 'Date / Time' }, { @@ -123,7 +140,7 @@ export class DefaultProperties { group: 'Number', elements: [ { - icon: 'integer_icon', + icon: 'money', label: 'Integer', subPropOf: Constants.HasValue, objectType: Constants.IntValue, @@ -131,7 +148,7 @@ export class DefaultProperties { group: 'Number' }, { - icon: 'decimal_icon', + icon: 'functions', label: 'Decimal', subPropOf: Constants.HasValue, objectType: Constants.DecimalValue, @@ -145,13 +162,21 @@ export class DefaultProperties { elements: [ { icon: 'link', - label: 'Other resource e.g. Person', + label: 'Resource class', subPropOf: Constants.HasLinkTo, objectType: Constants.LinkValue, guiEle: Constants.SalsahGui + Constants.HashDelimiter + 'Searchbox', // 'Autocomplete', group: 'Link' }, // { + // icon: 'picture_in_picture', + // label: 'Part of resource class', + // subPropOf: Constants.KnoraApiV2 + Constants.HashDelimiter + 'isPartOf', + // objectType: Constants.LinkValue, + // guiEle: Constants.SalsahGui + Constants.HashDelimiter + 'Searchbox', // 'Autocomplete', + // group: 'Link' + // }, + // { // icon: 'compare_arrows', // label: 'External resource', // subPropOf: Constants.HasValue, @@ -186,12 +211,20 @@ export class DefaultProperties { group: 'Shape', elements: [ { - icon: 'color_lens', + icon: 'palette', label: 'Color', subPropOf: Constants.HasValue, objectType: Constants.ColorValue, guiEle: Constants.SalsahGui + Constants.HashDelimiter + 'Colorpicker', group: 'Shape' + }, + { + icon: 'format_shapes', + label: 'Geometry', + subPropOf: Constants.HasValue, + objectType: Constants.GeomValue, + guiEle: Constants.SalsahGui + Constants.HashDelimiter + 'Geometry', + group: 'Shape' } ] } diff --git a/src/app/project/ontology/default-data/default-resource-classes.ts b/src/app/project/ontology/default-data/default-resource-classes.ts index 704bc7e5c2..d5e05b05f6 100644 --- a/src/app/project/ontology/default-data/default-resource-classes.ts +++ b/src/app/project/ontology/default-data/default-resource-classes.ts @@ -1,6 +1,6 @@ import { Constants } from '@dasch-swiss/dsp-js'; -export interface DefaultInfo { +export interface DefaultClass { iri: string; label: string; icons?: string[]; // icons can be used to be selected in the resource class form @@ -8,10 +8,10 @@ export interface DefaultInfo { export class DefaultResourceClasses { - public static data: DefaultInfo[] = [ + public static data: DefaultClass[] = [ { iri: Constants.Resource, - label: 'Object without file representation (metadata only)', + label: 'Object without representation', icons: [ 'person', 'person_outline', diff --git a/src/app/project/ontology/ontology-form/ontology-form.component.spec.ts b/src/app/project/ontology/ontology-form/ontology-form.component.spec.ts index fa9c8ff635..67dfbd0633 100644 --- a/src/app/project/ontology/ontology-form/ontology-form.component.spec.ts +++ b/src/app/project/ontology/ontology-form/ontology-form.component.spec.ts @@ -78,7 +78,7 @@ describe('OntologyFormComponent', () => { ontologyFormFixture = TestBed.createComponent(OntologyFormComponent); ontologyFormComponent = ontologyFormFixture.componentInstance; - ontologyFormComponent.projectcode = '00FF'; + ontologyFormComponent.projectCode = '00FF'; // ontologyFormComponent.iri = 'http://0.0.0.0:3333/ontology/0001/anything/v2'; ontologyFormComponent.existingOntologyNames = ['images']; diff --git a/src/app/project/ontology/ontology-form/ontology-form.component.ts b/src/app/project/ontology/ontology-form/ontology-form.component.ts index 49cbda9bb5..89f303015d 100644 --- a/src/app/project/ontology/ontology-form/ontology-form.component.ts +++ b/src/app/project/ontology/ontology-form/ontology-form.component.ts @@ -30,7 +30,7 @@ export interface NewOntology { export class OntologyFormComponent implements OnInit { // project short code - @Input() projectcode: string; + @Input() projectCode: string; // ontology iri in case of edit @Input() iri: string; @@ -103,10 +103,10 @@ export class OntologyFormComponent implements OnInit { this.loading = true; // set the cache - this._cache.get(this.projectcode, this._dspApiConnection.admin.projectsEndpoint.getProjectByShortcode(this.projectcode)); + this._cache.get(this.projectCode, this._dspApiConnection.admin.projectsEndpoint.getProjectByShortcode(this.projectCode)); // get project - this._cache.get(this.projectcode, this._dspApiConnection.admin.projectsEndpoint.getProjectByShortcode(this.projectcode)).subscribe( + this._cache.get(this.projectCode, this._dspApiConnection.admin.projectsEndpoint.getProjectByShortcode(this.projectCode)).subscribe( (response: ApiResponseData) => { this.project = response.body.project; diff --git a/src/app/project/ontology/ontology.component.html b/src/app/project/ontology/ontology.component.html index ff2c4cb377..01f92ed6ce 100644 --- a/src/app/project/ontology/ontology.component.html +++ b/src/app/project/ontology/ontology.component.html @@ -124,45 +124,78 @@

Classes + + + + + + + + + + + Properties - - - + + + + + + + + + - - Properties -
- +
- -
-
+
+ - + @@ -175,7 +208,7 @@

- +
diff --git a/src/app/project/ontology/ontology.component.scss b/src/app/project/ontology/ontology.component.scss index a3f7f20337..6d9ae2d770 100644 --- a/src/app/project/ontology/ontology.component.scss +++ b/src/app/project/ontology/ontology.component.scss @@ -34,6 +34,13 @@ $width: 340px; .ontology-editor-header { z-index: 2; top: 121px; + .mat-toolbar-row { + padding-right: 0; + + .ontology-actions { + width: 176px; + } + } } } .ontology-viewer { @@ -51,7 +58,7 @@ $width: 340px; } .ontology-editor-sidenav { - width: 160px; + width: 200px; button { width: 100%; @@ -71,6 +78,11 @@ $width: 340px; } .ontology-editor-list { margin: 16px; + + &.properties { + width: 80%; + margin: 16px 10%; + } } } } diff --git a/src/app/project/ontology/ontology.component.ts b/src/app/project/ontology/ontology.component.ts index 19f99fcdea..d119aa428f 100644 --- a/src/app/project/ontology/ontology.component.ts +++ b/src/app/project/ontology/ontology.component.ts @@ -10,6 +10,7 @@ import { Constants, DeleteOntologyResponse, DeleteResourceClass, + DeleteResourceProperty, KnoraApiConnection, ListsResponse, OntologiesMetadata, @@ -25,7 +26,8 @@ import { DspApiConnectionToken, Session, SessionService, SortingService } from ' import { CacheService } from 'src/app/main/cache/cache.service'; import { DialogComponent } from 'src/app/main/dialog/dialog.component'; import { ErrorHandlerService } from 'src/app/main/error/error-handler.service'; -import { DefaultInfo, DefaultResourceClasses } from './default-data/default-resource-classes'; +import { DefaultProperties, DefaultProperty, PropertyCategory, PropertyInfoObject } from './default-data/default-properties'; +import { DefaultClass, DefaultResourceClasses } from './default-data/default-resource-classes'; import { ResourceClassFormService } from './resource-class-form/resource-class-form.service'; export interface OntologyInfo { @@ -54,7 +56,7 @@ export class OntologyComponent implements OnInit { projectAdmin = false; // project shortcode; as identifier in project cache service - projectcode: string; + projectCode: string; // project data project: ReadProject; @@ -69,6 +71,7 @@ export class OntologyComponent implements OnInit { ontology: ReadOntology; ontoClasses: ClassDefinition[]; + expandClasses = false; ontoProperties: PropertyDefinition[]; @@ -92,7 +95,8 @@ export class OntologyComponent implements OnInit { /** * list of all default resource classes (sub class of) */ - defaultClasses: DefaultInfo[] = DefaultResourceClasses.data; + defaultClasses: DefaultClass[] = DefaultResourceClasses.data; + defaultProperties: PropertyCategory[] = DefaultProperties.data; // @ViewChild(AddToDirective, { static: false }) addToHost: AddToDirective; @@ -114,7 +118,7 @@ export class OntologyComponent implements OnInit { // get the shortcode of the current project this._route.parent.paramMap.subscribe((params: Params) => { - this.projectcode = params.get('shortcode'); + this.projectCode = params.get('shortcode'); }); if (this._route.snapshot) { @@ -128,10 +132,10 @@ export class OntologyComponent implements OnInit { // set the page title if (this.ontologyIri) { - this._titleService.setTitle('Project ' + this.projectcode + ' | Data model'); + this._titleService.setTitle('Project ' + this.projectCode + ' | Data model'); } else { // set the page title in case of more than one existing project ontologies - this._titleService.setTitle('Project ' + this.projectcode + ' | Data models'); + this._titleService.setTitle('Project ' + this.projectCode + ' | Data models'); } } @@ -147,10 +151,10 @@ export class OntologyComponent implements OnInit { this.projectAdmin = this.sysAdmin; // set the project cache - this._cache.get(this.projectcode, this._dspApiConnection.admin.projectsEndpoint.getProjectByShortcode(this.projectcode)); + this._cache.get(this.projectCode, this._dspApiConnection.admin.projectsEndpoint.getProjectByShortcode(this.projectCode)); // get the project data from cache - this._cache.get(this.projectcode, this._dspApiConnection.admin.projectsEndpoint.getProjectByShortcode(this.projectcode)).subscribe( + this._cache.get(this.projectCode, this._dspApiConnection.admin.projectsEndpoint.getProjectByShortcode(this.projectCode)).subscribe( (response: ApiResponseData) => { this.project = response.body.project; @@ -304,7 +308,7 @@ export class OntologyComponent implements OnInit { */ openOntologyRoute(id: string, view: 'classes' | 'properties' | 'graph' = 'classes') { this.view = view; - const goto = 'project/' + this.projectcode + '/ontologies/' + encodeURIComponent(id) + '/' + view; + const goto = 'project/' + this.projectCode + '/ontologies/' + encodeURIComponent(id) + '/' + view; this._router.navigateByUrl(goto, { skipLocationChange: false }); } @@ -362,7 +366,7 @@ export class OntologyComponent implements OnInit { * @param mode * @param resClassInfo (could be subClassOf (create mode) or resource class itself (edit mode)) */ - openResourceClassForm(mode: 'createResourceClass' | 'editResourceClass', resClassInfo: DefaultInfo): void { + openResourceClassForm(mode: 'createResourceClass' | 'editResourceClass', resClassInfo: DefaultClass): void { const dialogConfig: MatDialogConfig = { disableClose: true, @@ -385,6 +389,37 @@ export class OntologyComponent implements OnInit { }); } + /** + * opens property form + * @param mode + * @param propertyInfo (could be subClassOf (create mode) or resource class itself (edit mode)) + */ + openPropertyForm(mode: 'createProperty' | 'editProperty', propertyInfo: PropertyInfoObject): void { + + const title = (propertyInfo.propDef ? propertyInfo.propDef.label : propertyInfo.propType.label); + + const dialogConfig: MatDialogConfig = { + disableClose: false, + width: '640px', + maxHeight: '90vh', + position: { + top: '112px' + }, + data: { propInfo: propertyInfo, title: title, subtitle: 'Customize property', mode: mode, project: this.project.id } + }; + + const dialogRef = this._dialog.open( + DialogComponent, + dialogConfig + ); + + dialogRef.afterClosed().subscribe(result => { + // update the view + this.initOntologiesList(); + }); + } + + /** * updates cardinality * @param subClassOf resource class @@ -398,7 +433,7 @@ export class OntologyComponent implements OnInit { position: { top: '112px' }, - data: { mode: 'updateCardinality', id: subClassOf.id, title: subClassOf.label, subtitle: 'Update the metadata fields of resource class', project: this.project.id } + data: { mode: 'updateCardinality', id: subClassOf.id, title: subClassOf.label, subtitle: 'Update the metadata fields of resource class', project: this.projectCode } }; const dialogRef = this._dialog.open( @@ -419,7 +454,7 @@ export class OntologyComponent implements OnInit { * @param id * @param title */ - delete(mode: 'Ontology' | 'ResourceClass', info: DefaultInfo) { + delete(mode: 'Ontology' | 'ResourceClass' | 'Property', info: DefaultClass) { const dialogConfig: MatDialogConfig = { width: '560px', maxHeight: '80vh', @@ -451,7 +486,7 @@ export class OntologyComponent implements OnInit { // get the ontologies for this project this.initOntologiesList(); // go to project ontology page - const goto = 'project/' + this.projectcode + '/ontologies/'; + const goto = 'project/' + this.projectCode + '/ontologies/'; this._router.navigateByUrl(goto, { skipLocationChange: false }); }, (error: ApiResponseError) => { @@ -480,6 +515,24 @@ export class OntologyComponent implements OnInit { } ); break; + case 'Property': + // delete resource property and refresh the view + this.loadOntology = true; + const resProp: DeleteResourceProperty = new DeleteResourceProperty(); + resProp.id = info.iri; + resProp.lastModificationDate = this.ontology.lastModificationDate; + this._dspApiConnection.v2.onto.deleteResourceProperty(resProp).subscribe( + (response: OntologyMetadata) => { + this.loading = false; + this.resetOntology(this.ontologyIri); + }, + (error: ApiResponseError) => { + this._errorHandler.showMessage(error); + this.loading = false; + this.loadOntology = false; + } + ); + break; } } diff --git a/src/app/project/ontology/property-form/property-form.component.html b/src/app/project/ontology/property-form/property-form.component.html index 45d5300ed9..cf0c0eff11 100644 --- a/src/app/project/ontology/property-form/property-form.component.html +++ b/src/app/project/ontology/property-form/property-form.component.html @@ -1,121 +1,103 @@ -
- {{index + 1}})   + + + - - - +
- - - - - Already existing property. - - - - - - - {{ prop?.label }} - - + +
+ + + + Label is required + +
- - - Property type * + +
+ + +
- - - {{propertyForm.controls['type'].value.group}}:  - {{propertyForm.controls['type'].value.label}} - - - - - - - - {{ele.icon}} - - {{ele.label}} - - - - - You can re-use but not edit. - -
+ + + + {{propertyInfo.propType?.icon}}  + + Property type + + - -
-
-
- - Select list - - - {{item.labels[0].value}} - - - -
+ +
+ + + {{guiAttrIcon}}  + + Select list + + + {{item.labels[0].value}} + + + -
- - Select resource class - - - {{item.label}} - - - -
+ + + {{guiAttrIcon}}  + + Select resource class + + + {{item.label}} + + + -
- - Define range - - - - -
- + + + + {{guiAttrIcon}}  + + Define range + + + + + + {{formErrors.guiAttr}} + +
-
- -
- - - - - -
- - Multiple values? - -
- -
- - Required field? - -
- -
-
-
- + +
+ + +
+ + diff --git a/src/app/project/ontology/property-form/property-form.component.scss b/src/app/project/ontology/property-form/property-form.component.scss index ee7c59b8e9..d093c110db 100644 --- a/src/app/project/ontology/property-form/property-form.component.scss +++ b/src/app/project/ontology/property-form/property-form.component.scss @@ -1,38 +1,44 @@ -:host { - margin: 12px 0 0 20px; +@import "../../../../assets/style/config"; +@import "../../../../assets/style/mixins"; - form { - margin: 0 0 16px 0; +.form-content { + width: 100%; + margin-top: 0; + + .form-panel { width: 100%; } -} -.large-field, -.medium-field, -.small-field, -.x-small-field { - margin: 0 2px; - display: inline-flex; -} + .large-field, + .medium-field, + .small-field { + margin: 0 2px; -.index { - color: rgba(0, 0, 0, 0.54); - font-size: small; - margin-left: -12px; - margin-right: 6px; -} + &.string-literal-container { + display: inline-block; + position: relative; + top: -3px; + } + } -.empty-index { - display: inline-block; - width: 12px; + .form-action { + margin-top: 48px; + } } -.reset-button { - position: absolute; - right: 0; - bottom: 12px; -} +.property-type { + .property-type-icon { + width: 36px; + padding: 0 8px; + display: block; + } -.hidden { - display: none; + mat-label, + mat-select, + input { + margin-left: 12px; + } + mat-select { + width: calc(100% - 12px); + } } diff --git a/src/app/project/ontology/property-form/property-form.component.spec.ts b/src/app/project/ontology/property-form/property-form.component.spec.ts index 380d05e161..78ad952fc0 100644 --- a/src/app/project/ontology/property-form/property-form.component.spec.ts +++ b/src/app/project/ontology/property-form/property-form.component.spec.ts @@ -1,78 +1,271 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { Component, DebugElement, ViewChild } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { ReactiveFormsModule } from '@angular/forms'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatButtonModule } from '@angular/material/button'; import { MatOptionModule } from '@angular/material/core'; -import { MatDialogModule } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { By } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { KnoraApiConnection } from '@dasch-swiss/dsp-js'; -import { AppInitService, DspApiConfigToken, DspApiConnectionToken } from '@dasch-swiss/dsp-ui'; +import { RouterTestingModule } from '@angular/router/testing'; +import { KnoraApiConnection, MockOntology, ReadOntology } from '@dasch-swiss/dsp-js'; +import { AppInitService, DspActionModule, DspApiConfigToken, DspApiConnectionToken } from '@dasch-swiss/dsp-ui'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { CacheService } from 'src/app/main/cache/cache.service'; import { TestConfig } from 'test.config'; +import { PropertyInfoObject } from '../default-data/default-properties'; +import { ResourceClassFormService } from '../resource-class-form/resource-class-form.service'; import { PropertyFormComponent } from './property-form.component'; -xdescribe('PropertyFormComponent', () => { - let component: PropertyFormComponent; - let fixture: ComponentFixture; +/** + * test host component to simulate parent component + * Property is of type simple text + */ +@Component({ + template: '' +}) +class SimpleTextHostComponent { - const formBuilder: FormBuilder = new FormBuilder(); + @ViewChild('propertyForm') propertyFormComponent: PropertyFormComponent; + + propertyInfo: PropertyInfoObject = { + 'propDef': { + 'id': 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasPictureTitle', + 'subPropertyOf': ['http://api.knora.org/ontology/knora-api/v2#hasValue'], + 'label': 'Titel', + 'guiElement': 'http://api.knora.org/ontology/salsah-gui/v2#SimpleText', + 'subjectType': 'http://0.0.0.0:3333/ontology/0001/anything/v2#ThingPicture', + 'objectType': 'http://api.knora.org/ontology/knora-api/v2#TextValue', + 'isLinkProperty': false, + 'isLinkValueProperty': false, + 'isEditable': true, + 'guiAttributes': ['maxlength=255', 'size=80'], + 'comments': [], + 'labels': [{ + 'language': 'de', + 'value': 'Titel' + }, { + 'language': 'en', + 'value': 'Title' + }, { + 'language': 'fr', + 'value': 'Titre' + }, { + 'language': 'it', + 'value': 'Titolo' + }] + }, + 'propType': { + 'icon': 'short_text', + 'label': 'Short', + 'subPropOf': 'http://api.knora.org/ontology/knora-api/v2#hasValue', + 'objectType': 'http://api.knora.org/ontology/knora-api/v2#TextValue', + 'guiEle': 'http://api.knora.org/ontology/salsah-gui/v2#SimpleText', + 'group': 'Text' + } + }; + +} + +/** + * test host component to simulate parent component + * Property is of type resource link + */ +@Component({ + template: '' +}) +class LinkHostComponent { + + @ViewChild('propertyForm') propertyFormComponent: PropertyFormComponent; + + propertyInfo: PropertyInfoObject = { + 'propDef': { + 'id': 'http://0.0.0.0:3333/ontology/0001/anything/v2#hasOtherThing', + 'subPropertyOf': ['http://api.knora.org/ontology/knora-api/v2#hasLinkTo'], + 'label': 'Ein anderes Ding', + 'guiElement': 'http://api.knora.org/ontology/salsah-gui/v2#Searchbox', + 'subjectType': 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', + 'objectType': 'http://0.0.0.0:3333/ontology/0001/anything/v2#Thing', + 'isLinkProperty': true, + 'isLinkValueProperty': false, + 'isEditable': true, + 'guiAttributes': [], + 'comments': [], + 'labels': [{ + 'language': 'de', + 'value': 'Ein anderes Ding' + }, { + 'language': 'en', + 'value': 'Another thing' + }, { + 'language': 'fr', + 'value': 'Une autre chose' + }, { + 'language': 'it', + 'value': 'Un\'altra cosa' + }] + }, + 'propType': { + 'icon': 'link', + 'label': 'Resource class', + 'subPropOf': 'http://api.knora.org/ontology/knora-api/v2#hasLinkTo', + 'objectType': 'http://api.knora.org/ontology/knora-api/v2#LinkValue', + 'guiEle': 'http://api.knora.org/ontology/salsah-gui/v2#Searchbox', + 'group': 'Link' + } + }; + +} + +describe('PropertyFormComponent', () => { + let simpleTextHostComponent: SimpleTextHostComponent; + let simpleTextHostFixture: ComponentFixture; + + let linkHostComponent: LinkHostComponent; + let linkHostFixture: ComponentFixture; beforeEach(async(() => { + + const cacheServiceSpyOnto = jasmine.createSpyObj('CacheServiceOnto', ['get']); + const cacheServiceSpyLists = jasmine.createSpyObj('CacheServiceLists', ['get']); + + const ontologyEndpointSpyObj = { + v2: { + ontologyEndpoint: jasmine.createSpyObj('onto', ['updateResourceProperty', 'createResourceProperty']) + } + }; + TestBed.configureTestingModule({ - declarations: [PropertyFormComponent], + declarations: [ + LinkHostComponent, + SimpleTextHostComponent, + PropertyFormComponent + ], imports: [ BrowserAnimationsModule, + DspActionModule, HttpClientTestingModule, MatAutocompleteModule, - MatDialogModule, + MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatOptionModule, MatSelectModule, - MatSlideToggleModule, - MatSnackBarModule, - ReactiveFormsModule + ReactiveFormsModule, + RouterTestingModule, + TranslateModule.forRoot() ], providers: [ - // reference the new instance of formBuilder from above - { provide: FormBuilder, useValue: formBuilder }, - AppInitService, { - provide: DspApiConfigToken, - useValue: TestConfig.ApiConfig + provide: DspApiConnectionToken, + useValue: ontologyEndpointSpyObj }, { - provide: DspApiConnectionToken, - useValue: new KnoraApiConnection(TestConfig.ApiConfig) - } + provide: CacheService, + useValue: cacheServiceSpyOnto + }, + { + provide: CacheService, + useValue: cacheServiceSpyLists + }, + ResourceClassFormService ] }) .compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(PropertyFormComponent); - component = fixture.componentInstance; - - // pass in the form dynamically - component.propertyForm = formBuilder.group({ - type: null, - label: null, - multiple: null, - required: null, - permission: null - }); - - fixture.detectChanges(); + const cacheSpyOnto = TestBed.inject(CacheService); + + // mock cache service for currentOntology + (cacheSpyOnto as jasmine.SpyObj).get.and.callFake( + () => { + const response: ReadOntology = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2'); + return of(response); + } + ); + + // simple text + simpleTextHostFixture = TestBed.createComponent(SimpleTextHostComponent); + simpleTextHostComponent = simpleTextHostFixture.componentInstance; + simpleTextHostFixture.detectChanges(); + + expect(simpleTextHostComponent).toBeTruthy(); + + // link + linkHostFixture = TestBed.createComponent(LinkHostComponent); + linkHostComponent = linkHostFixture.componentInstance; + linkHostFixture.detectChanges(); + + expect(linkHostComponent).toBeTruthy(); + }); - it('should create', () => { - expect(component).toBeTruthy(); + + + it('should create an instance', () => { + expect(simpleTextHostComponent.propertyFormComponent).toBeTruthy(); + }); + + + it('expect property type "text" has four labels but no comment"', () => { + expect(simpleTextHostComponent.propertyFormComponent).toBeTruthy(); + expect(simpleTextHostComponent.propertyFormComponent.propertyInfo.propDef).toBeDefined(); + expect(simpleTextHostComponent.propertyFormComponent.propertyInfo.propType).toBeDefined(); + + const form = linkHostComponent.propertyFormComponent.propertyForm; + + expect(simpleTextHostComponent.propertyFormComponent.labels).toEqual( + [{ + 'language': 'de', + 'value': 'Titel' + }, { + 'language': 'en', + 'value': 'Title' + }, { + 'language': 'fr', + 'value': 'Titre' + }, { + 'language': 'it', + 'value': 'Titolo' + }] + ); + + expect(simpleTextHostComponent.propertyFormComponent.comments).toEqual([]); + + expect(simpleTextHostComponent.propertyFormComponent.showGuiAttr).toBeFalsy(); + + }); + + it('should update labels when the value changes', () => { + + const hostCompDe = simpleTextHostFixture.debugElement; + const submitButton: DebugElement = hostCompDe.query(By.css('button.submit')); + expect(submitButton.nativeElement.innerText).toEqual(' Update '); + + simpleTextHostComponent.propertyFormComponent.handleData([], 'labels'); + simpleTextHostFixture.detectChanges(); + + const formInvalidMessageDe: DebugElement = hostCompDe.query(By.css('mat-hint')); + expect(formInvalidMessageDe.nativeElement.innerText).toEqual(' Label is required '); + + }); + + + it('expect link to other resource called "Thing"', () => { + expect(linkHostComponent.propertyFormComponent).toBeTruthy(); + expect(linkHostComponent.propertyFormComponent.propertyInfo.propDef).toBeDefined(); + expect(linkHostComponent.propertyFormComponent.propertyInfo.propType).toBeDefined(); + + const form = linkHostComponent.propertyFormComponent.propertyForm; + + expect(form.controls['guiAttr'].value).toEqual('http://0.0.0.0:3333/ontology/0001/anything/v2#Thing'); + }); }); diff --git a/src/app/project/ontology/property-form/property-form.component.ts b/src/app/project/ontology/property-form/property-form.component.ts index 0d680e3391..b26ed9747d 100644 --- a/src/app/project/ontology/property-form/property-form.component.ts +++ b/src/app/project/ontology/property-form/property-form.component.ts @@ -1,38 +1,25 @@ import { Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core'; -import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { MatOption } from '@angular/material/core'; -import { MatIconRegistry } from '@angular/material/icon'; -import { MatSelectChange } from '@angular/material/select'; -import { DomSanitizer } from '@angular/platform-browser'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; import { ApiResponseError, ClassDefinition, Constants, + CreateResourceProperty, KnoraApiConnection, ListNodeInfo, ReadOntology, - ResourcePropertyDefinition + ResourcePropertyDefinitionWithAllLanguages, + StringLiteral, + UpdateOntology, + UpdateResourcePropertyComment, + UpdateResourcePropertyLabel } from '@dasch-swiss/dsp-js'; -import { DspApiConnectionToken } from '@dasch-swiss/dsp-ui'; +import { AutocompleteItem, DspApiConnectionToken } from '@dasch-swiss/dsp-ui'; import { Observable } from 'rxjs'; -import { map, startWith } from 'rxjs/operators'; import { CacheService } from 'src/app/main/cache/cache.service'; import { ErrorHandlerService } from 'src/app/main/error/error-handler.service'; -import { Category, DefaultProperties, PropertyType } from '../default-data/default-properties'; - -// --> TODO should be removed and replaced by AutocompleteItem from dsp-ui -/** - * a list, which is used in the mat-autocomplete form field - * contains objects with id and name. the id is usual the iri - */ -export interface AutocompleteItem { - iri: string; - name: string; - label?: string; -} - -// https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror -// const resolvedPromise = Promise.resolve(null); +import { DefaultProperties, DefaultProperty, PropertyCategory, PropertyInfoObject } from '../default-data/default-properties'; +import { ResourceClassFormService } from '../resource-class-form/resource-class-form.service'; @Component({ selector: 'app-property-form', @@ -41,26 +28,41 @@ export interface AutocompleteItem { }) export class PropertyFormComponent implements OnInit { - @Input() propertyForm: FormGroup; - - @Input() index: number; - - @Input() ontology?: ReadOntology; + /** + * propertyInfo contains default property type information + * and in case of 'edit' mode also the ResourcePropertyDefintion + */ + @Input() propertyInfo: PropertyInfoObject; - @Input() resClassIri?: string; + @Output() closeDialog: EventEmitter = new EventEmitter(); - @Output() deleteProperty: EventEmitter = new EventEmitter(); + /** + * form group, errors and validation messages + */ + propertyForm: FormGroup; + + formErrors = { + 'label': '', + 'guiAttr': '' + }; + + validationMessages = { + 'label': { + 'required': 'Label is required.', + }, + 'guiAttr': { + 'required': 'Gui attribute is required.', + } + }; - iri = new FormControl(); - label = new FormControl(); - type = new FormControl(); - multiple = new FormControl(); - required = new FormControl(); + ontology: ReadOntology; + lastModificationDate: string; // selection of default property types - propertyTypes: Category[] = DefaultProperties.data; + propertyTypes: PropertyCategory[] = DefaultProperties.data; showGuiAttr = false; + guiAttrIcon = 'tune'; // list of project specific lists (TODO: probably we have to add default knora lists?!) lists: ListNodeInfo[]; @@ -68,94 +70,45 @@ export class PropertyFormComponent implements OnInit { // resource classes in this ontology resourceClass: ClassDefinition[] = []; - // list of existing properties - properties: AutocompleteItem[] = []; - - filteredProperties: Observable; - - selectTypeLabel: string; // = this.propertyTypes[0].group + ': ' + this.propertyTypes[0].elements[0].label; - selectedGroup: string; + loading = false; - existingProperty: boolean; + error = false; - loading = false; + labels: StringLiteral[] = []; + comments: StringLiteral[] = []; + guiAttributes: string[] = []; dspConstants = Constants; constructor( @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection, private _cache: CacheService, - private _domSanitizer: DomSanitizer, private _errorHandler: ErrorHandlerService, - private _matIconRegistry: MatIconRegistry - ) { - - // special icons for property type - this._matIconRegistry.addSvgIcon( - 'integer_icon', - this._domSanitizer.bypassSecurityTrustResourceUrl('/assets/images/integer-icon.svg') - ); - this._matIconRegistry.addSvgIcon( - 'decimal_icon', - this._domSanitizer.bypassSecurityTrustResourceUrl('/assets/images/decimal-icon.svg') - ); - } + private _fb: FormBuilder, + private _resourceClassFormService: ResourceClassFormService + ) { } ngOnInit() { - if (this.propertyForm) { - // init list of property types with first element - this.propertyForm.patchValue({ type: this.propertyTypes[0].elements[0] }); - - if (this.propertyForm.value.label) { - - const existingProp: AutocompleteItem = { - iri: this.propertyForm.value.iri, - label: this.propertyForm.value.label, - name: '' - }; - - // edit mode: this prop value exists already - this.loading = true; - this.updateFieldsDependingOnLabel(existingProp); - } - } + this.loading = true; this._cache.get('currentOntology').subscribe( (response: ReadOntology) => { this.ontology = response; + this.lastModificationDate = response.lastModificationDate; // set various lists to select from // a) in case of link value: // set list of resource classes from response; needed for linkValue - const classKeys: string[] = Object.keys(response.classes); - for (const c of classKeys) { - this.resourceClass.push(this.ontology.classes[c]); - } - - // b) in case of already existing label: - // set list of properties from response; needed for autocomplete in label to reuse existing property - const propKeys: string[] = Object.keys(response.properties); - for (const p of propKeys) { - const prop = this.ontology.properties[p]; - if (prop.objectType !== Constants.LinkValue && prop.objectType !== this.resClassIri) { - const existingProperty: AutocompleteItem = { - iri: this.ontology.properties[p].id, - name: this.ontology.properties[p].id.split('#')[1], - label: this.ontology.properties[p].label - }; - - this.properties.push(existingProperty); - } + this.resourceClass = response.getAllClassDefinitions(); - } }, (error: ApiResponseError) => { this._errorHandler.showMessage(error); } ); - // c) in case of list value: + // b) in case of list value: // set list of lists; needed for listValue this._cache.get('currentOntologyLists').subscribe( (response: ListNodeInfo[]) => { @@ -163,11 +116,69 @@ export class PropertyFormComponent implements OnInit { } ); - this.filteredProperties = this.propertyForm.controls['label'].valueChanges - .pipe( - startWith(''), - map(prop => prop.length >= 0 ? this.filter(this.properties, prop) : []) - ); + this.buildForm(); + + } + + buildForm() { + + // if property definition exists + // we are in edit mode: prepare form to edit label and/or comment + if (this.propertyInfo.propDef) { + this.labels = this.propertyInfo.propDef.labels; + this.comments = this.propertyInfo.propDef.comments; + this.guiAttributes = this.propertyInfo.propDef.guiAttributes; + } + + this.propertyForm = this._fb.group({ + 'guiAttr': new FormControl({ + value: this.guiAttributes + }) + }); + + this.updateAttributeField(this.propertyInfo.propType); + + this.propertyForm.valueChanges + .subscribe(data => this.onValueChanged(data)); + } + + /** + * this method is for the form error handling + * + * @param data Data which changed. + */ + onValueChanged(data?: any) { + + if (!this.propertyForm) { + return; + } + + const form = this.propertyForm; + + Object.keys(this.formErrors).map(field => { + this.formErrors[field] = ''; + const control = form.get(field); + if (control && control.dirty && !control.valid) { + const messages = this.validationMessages[field]; + Object.keys(control.errors).map(key => { + this.formErrors[field] += messages[key] + ' '; + }); + + } + }); + } + + handleData(data: StringLiteral[], type: string) { + + switch (type) { + case 'labels': + this.labels = data; + break; + + case 'comments': + this.comments = data; + break; + } } /** @@ -182,104 +193,186 @@ export class PropertyFormComponent implements OnInit { ); } - updateAttributeField(event: MatSelectChange) { + updateAttributeField(type: DefaultProperty) { // reset value of guiAttr this.propertyForm.controls['guiAttr'].setValue(undefined); - // depending on the selected property type, - // we have to define gui element attributes - // e.g. iri of list or connected resource class - switch (event.value.objectType) { - case Constants.ListValue: - case Constants.LinkValue: - this.showGuiAttr = true; - this.propertyForm.controls['guiAttr'].setValidators([ - Validators.required - ]); - this.propertyForm.controls['guiAttr'].updateValueAndValidity(); - break; - - default: - this.propertyForm.controls['guiAttr'].clearValidators(); - this.propertyForm.controls['guiAttr'].updateValueAndValidity(); - this.showGuiAttr = false; - } - - } - - /** - * @param {MatOption} option - */ - updateFieldsDependingOnLabel(option: AutocompleteItem) { - this.propertyForm.controls['iri'].setValue(option.iri); - - // set label and disable the input - this.propertyForm.controls['label'].setValue(option.label); - this.propertyForm.controls['label'].disable(); - - // find corresponding property type - if (this.ontology.properties[option.iri] instanceof ResourcePropertyDefinition) { - const tempProp: any | ResourcePropertyDefinition = this.ontology.properties[option.iri]; - let obj: PropertyType; - // find gui ele from list of default property-types to set type value - for (const group of this.propertyTypes) { - obj = group.elements.find(i => i.guiEle === tempProp.guiElement && (i.objectType === tempProp.objectType || i.subPropOf === tempProp.subPropertyOf[0])); + // set gui attribute value depending on gui element and existing property (edit mode) + if (this.propertyInfo.propDef) { + // the gui attribute can't be changed (at the moment?); + // disable the input and set the validator as not required + this.propertyForm.controls['guiAttr'].disable(); - if (obj) { - this.propertyForm.controls['type'].setValue(obj); - break; - } - } - - switch (tempProp.guiElement) { + switch (type.guiEle) { // prop type is a list case Constants.SalsahGui + Constants.HashDelimiter + 'List': case Constants.SalsahGui + Constants.HashDelimiter + 'Radio': + this.showGuiAttr = true; // gui attribute value for lists looks as follow: hlist= // get index from guiAttr array where value starts with hlist= - const i = tempProp.guiAttributes.findIndex(element => element.includes('hlist')); - + const i = this.guiAttributes.findIndex(element => element.includes('hlist')); // find content beteween pointy brackets to get list iri const re = /\<([^)]+)\>/; - const listIri = tempProp.guiAttributes[i].match(re)[1]; + const listIri = this.guiAttributes[i].match(re)[1]; - this.showGuiAttr = true; this.propertyForm.controls['guiAttr'].setValue(listIri); - this.propertyForm.controls['guiAttr'].disable(); break; // prop type is resource pointer case Constants.SalsahGui + Constants.HashDelimiter + 'Searchbox': + this.showGuiAttr = true; + this.propertyForm.controls['guiAttr'].setValue(this.propertyInfo.propDef.objectType); + break; + default: + this.showGuiAttr = false; + } + + + } else { + // depending on the selected property type, + // we have to define gui element attributes + // e.g. iri of list or connected resource class + switch (type.objectType) { + case Constants.ListValue: + case Constants.LinkValue: this.showGuiAttr = true; - this.propertyForm.controls['guiAttr'].setValue(tempProp.objectType); - this.propertyForm.controls['guiAttr'].disable(); + this.propertyForm.controls['guiAttr'].setValidators([ + Validators.required + ]); + // this.propertyForm.controls['guiAttr'].updateValueAndValidity(); + this.propertyForm.updateValueAndValidity(); break; default: this.showGuiAttr = false; } } - this.propertyForm.controls['type'].disable(); - this.existingProperty = true; + + this.loading = false; + } - resetProperty(ev: Event) { - ev.preventDefault(); - this.existingProperty = false; + submitData() { + // do something with your data + if (this.propertyInfo.propDef) { + // edit mode: res property info (label and comment) + // label + const onto4Label = new UpdateOntology(); + onto4Label.id = this.ontology.id; + onto4Label.lastModificationDate = this.lastModificationDate; + + const updateLabel = new UpdateResourcePropertyLabel(); + updateLabel.id = this.propertyInfo.propDef.id; + updateLabel.labels = this.labels; + onto4Label.entity = updateLabel; + + // comment + const onto4Comment = new UpdateOntology(); + onto4Comment.id = this.ontology.id; + + const updateComment = new UpdateResourcePropertyComment(); + updateComment.id = this.propertyInfo.propDef.id; + updateComment.comments = (this.comments.length ? this.comments : this.labels); + onto4Comment.entity = updateComment; + + this._dspApiConnection.v2.onto.updateResourceProperty(onto4Label).subscribe( + (classLabelResponse: ResourcePropertyDefinitionWithAllLanguages) => { + this.ontology.lastModificationDate = classLabelResponse.lastModificationDate; + onto4Comment.lastModificationDate = this.ontology.lastModificationDate; + + this._dspApiConnection.v2.onto.updateResourceProperty(onto4Comment).subscribe( + (classCommentResponse: ResourcePropertyDefinitionWithAllLanguages) => { + this.ontology.lastModificationDate = classCommentResponse.lastModificationDate; + + // close the dialog box + this.loading = false; + this.closeDialog.emit(); + }, + (error: ApiResponseError) => { + this._errorHandler.showMessage(error); + } + ); + + }, + (error: ApiResponseError) => { + this._errorHandler.showMessage(error); + } + ); - this.propertyForm.controls['iri'].reset(); - this.propertyForm.controls['label'].setValue(''); - this.propertyForm.controls['label'].enable(); - this.propertyForm.controls['type'].setValue(this.propertyTypes[0].elements[0]); - this.propertyForm.controls['type'].enable(); - this.propertyForm.controls['guiAttr'].setValue(undefined); - this.propertyForm.controls['guiAttr'].enable(); + } else { + // create mode: new property incl. gui type and attribute + // submit property + // this.submitProps(this.resourceClassForm.value.properties, this.propertyInfo.propDef.id); + // set resource property name / id: randomized string + const uniquePropName: string = this._resourceClassFormService.setUniqueName(this.ontology.id); + + const onto = new UpdateOntology(); + + onto.id = this.ontology.id; + onto.lastModificationDate = this.lastModificationDate; + + // prepare payload for property + const newResProp = new CreateResourceProperty(); + newResProp.name = uniquePropName; + // --> TODO update prop.label and use StringLiteralInput in property-form + newResProp.label = this.labels; + newResProp.comment = (this.comments.length ? this.comments : this.labels); + const guiAttr = this.propertyForm.controls['guiAttr'].value; + if (guiAttr) { + switch (this.propertyInfo.propType.guiEle) { + + case Constants.SalsahGui + Constants.HashDelimiter + 'Colorpicker': + newResProp.guiAttributes = ['ncolors=' + guiAttr]; + break; + case Constants.SalsahGui + Constants.HashDelimiter + 'List': + case Constants.SalsahGui + Constants.HashDelimiter + 'Pulldown': + case Constants.SalsahGui + Constants.HashDelimiter + 'Radio': + newResProp.guiAttributes = ['hlist=<' + guiAttr + '>']; + break; + case Constants.SalsahGui + Constants.HashDelimiter + 'SimpleText': + // --> TODO could have two guiAttr fields: size and maxlength + // we suggest to use default value for size; we do not support this guiAttr in DSP-App + newResProp.guiAttributes = ['maxlength=' + guiAttr]; + break; + case Constants.SalsahGui + Constants.HashDelimiter + 'Spinbox': + // --> TODO could have two guiAttr fields: min and max + newResProp.guiAttributes = ['min=' + guiAttr, 'max=' + guiAttr]; + break; + case Constants.SalsahGui + Constants.HashDelimiter + 'Textarea': + // --> TODO could have four guiAttr fields: width, cols, rows, wrap + // we suggest to use default values; we do not support this guiAttr in DSP-App + newResProp.guiAttributes = ['width=100%']; + break; + } + } + newResProp.guiElement = this.propertyInfo.propType.guiEle; + newResProp.subPropertyOf = [this.propertyInfo.propType.subPropOf]; + + if (this.propertyInfo.propType.subPropOf === Constants.HasLinkTo) { + newResProp.objectType = guiAttr; + // newResProp.subjectType = classIri; + } else { + newResProp.objectType = this.propertyInfo.propType.objectType; + } - this.propertyForm.controls['multiple'].reset(); - this.propertyForm.controls['required'].reset(); + onto.entity = newResProp; + + this._dspApiConnection.v2.onto.createResourceProperty(onto).subscribe( + (response: ResourcePropertyDefinitionWithAllLanguages) => { + this.lastModificationDate = response.lastModificationDate; + // close the dialog box + this.loading = false; + this.closeDialog.emit(); + }, + (error: ApiResponseError) => { + this._errorHandler.showMessage(error); + } + ); + + } } } diff --git a/src/app/project/ontology/property-info/property-info.component.html b/src/app/project/ontology/property-info/property-info.component.html index fe768acebf..dbae38e25d 100644 --- a/src/app/project/ontology/property-info/property-info.component.html +++ b/src/app/project/ontology/property-info/property-info.component.html @@ -1,26 +1,31 @@ - - {{propCard.guiOrder}}) + + {{propCard.guiOrder}})
- - - - - - {{propType?.icon}} - + + {{propType?.icon}} - {{propDef?.label}} {{propDef.id}} + + {{propDef.label ? propDef.label : propDef.id}} + + {{!propDef.labels.length ? propDef?.label : propDef.labels | + dspStringifyStringLiteral:'all'}} - + +
- {{propInfo.multiple ? 'check_box' : 'check_box_outline_blank' }} multiple - {{propInfo.required ? 'check_box' : 'check_box_outline_blank' }} required + {{propInfo.multiple ? 'check_box' : 'check_box_outline_blank' }} + multiple + {{propInfo.required ? 'check_box' : 'check_box_outline_blank' }} + required @@ -28,7 +33,8 @@ Property is used in: - {{c.label}} + {{c.label}} @@ -38,4 +44,20 @@
+
+
+ + + + +
+
diff --git a/src/app/project/ontology/property-info/property-info.component.scss b/src/app/project/ontology/property-info/property-info.component.scss index 2edc608115..5c102ad138 100644 --- a/src/app/project/ontology/property-info/property-info.component.scss +++ b/src/app/project/ontology/property-info/property-info.component.scss @@ -42,3 +42,7 @@ .not-used { color: $warn; } + +.type { + margin-right: 12px; +} diff --git a/src/app/project/ontology/property-info/property-info.component.spec.ts b/src/app/project/ontology/property-info/property-info.component.spec.ts index f7fefcb12a..56cfa1c95d 100644 --- a/src/app/project/ontology/property-info/property-info.component.spec.ts +++ b/src/app/project/ontology/property-info/property-info.component.spec.ts @@ -1,6 +1,11 @@ +import { OverlayContainer } from '@angular/cdk/overlay'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { Component, DebugElement, ViewChild } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatDialogHarness } from '@angular/material/dialog/testing'; import { MatIconModule } from '@angular/material/icon'; import { MatListModule } from '@angular/material/list'; import { MatSnackBarModule } from '@angular/material/snack-bar'; @@ -8,8 +13,12 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { By } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { Constants, IHasProperty, ListNodeInfo, MockOntology, ReadOntology, ResourcePropertyDefinitionWithAllLanguages } from '@dasch-swiss/dsp-js'; +import { DspActionModule } from '@dasch-swiss/dsp-ui'; import { of } from 'rxjs'; import { CacheService } from 'src/app/main/cache/cache.service'; +import { DialogHeaderComponent } from 'src/app/main/dialog/dialog-header/dialog-header.component'; +import { DialogComponent } from 'src/app/main/dialog/dialog.component'; +import { PropertyFormComponent } from '../property-form/property-form.component'; import { PropertyInfoComponent } from './property-info.component'; /** @@ -154,24 +163,27 @@ describe('PropertyInfoComponent', () => { let listHostComponent: ListHostComponent; let listHostFixture: ComponentFixture; + let rootLoader: HarnessLoader; + let overlayContainer: OverlayContainer; + beforeEach(async(() => { - const dspConnSpy = { - v2: { - ontologyCache: jasmine.createSpyObj('ontologyCache', ['getOntology']), - } - }; const cacheServiceSpy = jasmine.createSpyObj('CacheService', ['get']); TestBed.configureTestingModule({ declarations: [ + DialogComponent, + DialogHeaderComponent, LinkHostComponent, ListHostComponent, SimpleTextHostComponent, - PropertyInfoComponent + PropertyFormComponent, + PropertyInfoComponent, ], imports: [ BrowserAnimationsModule, + DspActionModule, + MatButtonModule, MatDialogModule, MatIconModule, MatListModule, @@ -182,7 +194,15 @@ describe('PropertyInfoComponent', () => { { provide: CacheService, useValue: cacheServiceSpy - } + }, + { + provide: MAT_DIALOG_DATA, + useValue: {} + }, + { + provide: MatDialogRef, + useValue: {} + }, ] }) .compileComponents(); @@ -194,6 +214,13 @@ describe('PropertyInfoComponent', () => { simpleTextHostFixture.detectChanges(); expect(simpleTextHostComponent).toBeTruthy(); + + simpleTextHostComponent.propertyInfoComponent.showActionBubble = true; + simpleTextHostFixture.detectChanges(); + + overlayContainer = TestBed.inject(OverlayContainer); + rootLoader = TestbedHarnessEnvironment.documentRootLoader(simpleTextHostFixture); + }); beforeEach(() => { @@ -215,11 +242,11 @@ describe('PropertyInfoComponent', () => { }); beforeEach(() => { - // mock cache service for currentOntology + // mock cache service for currentOntologyLists const cacheSpy = TestBed.inject(CacheService); (cacheSpy as jasmine.SpyObj).get.and.callFake( - (key = 'currentOntologyLists') => { + () => { const response: ListNodeInfo[] = [{ 'comments': [], 'id': 'http://rdfh.ch/lists/0001/otherTreeList', @@ -272,6 +299,14 @@ describe('PropertyInfoComponent', () => { expect(listHostComponent).toBeTruthy(); }); + afterEach(async () => { + const dialogs = await rootLoader.getAllHarnesses(MatDialogHarness); + await Promise.all(dialogs.map(async d => await d.close())); + + // angular won't call this for us so we need to do it ourselves to avoid leaks. + overlayContainer.ngOnDestroy(); + }); + it('should create an instance', () => { expect(simpleTextHostComponent.propertyInfoComponent).toBeTruthy(); }); diff --git a/src/app/project/ontology/property-info/property-info.component.ts b/src/app/project/ontology/property-info/property-info.component.ts index 8221682a87..e8eae9b05d 100644 --- a/src/app/project/ontology/property-info/property-info.component.ts +++ b/src/app/project/ontology/property-info/property-info.component.ts @@ -1,22 +1,50 @@ -import { AfterContentInit, Component, Input, OnInit } from '@angular/core'; -import { MatIconRegistry } from '@angular/material/icon'; -import { DomSanitizer } from '@angular/platform-browser'; +import { animate, state, style, transition, trigger } from '@angular/animations'; +import { AfterContentInit, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { Constants, IHasProperty, ListNodeInfo, ReadOntology, + ReadProject, ResourceClassDefinitionWithAllLanguages, ResourcePropertyDefinitionWithAllLanguages } from '@dasch-swiss/dsp-js'; import { CacheService } from 'src/app/main/cache/cache.service'; -import { Category, DefaultProperties, PropertyType } from '../default-data/default-properties'; +import { + DefaultProperties, + DefaultProperty, + PropertyCategory, + PropertyInfoObject +} from '../default-data/default-properties'; +import { DefaultClass } from '../default-data/default-resource-classes'; import { Property } from '../resource-class-form/resource-class-form.service'; @Component({ selector: 'app-property-info', templateUrl: './property-info.component.html', - styleUrls: ['./property-info.component.scss'] + styleUrls: ['./property-info.component.scss'], + animations: [ + // the fade-in/fade-out animation. + // https://www.kdechant.com/blog/angular-animations-fade-in-and-fade-out + trigger('simpleFadeAnimation', [ + + // the "in" style determines the "resting" state of the element when it is visible. + state('in', style({ opacity: 1 })), + + // fade in when created. + transition(':enter', [ + // the styles start from this point when the element appears + style({ opacity: 0 }), + // and animate toward the "in" state above + animate(150) + ]), + + // fade out when destroyed. + transition(':leave', + // fading out uses a different syntax, with the "style" being passed into animate() + animate(150, style({ opacity: 0 }))) + ]) + ] }) export class PropertyInfoComponent implements OnInit, AfterContentInit { @@ -24,38 +52,39 @@ export class PropertyInfoComponent implements OnInit, AfterContentInit { @Input() propCard?: IHasProperty; - @Input() projectcode: string; + @Input() projectCode: string; - propInfo: Property = new Property(); + @Output() editResourceProperty: EventEmitter = new EventEmitter(); + @Output() deleteResourceProperty: EventEmitter = new EventEmitter(); - propType: PropertyType; + // submit res class iri ot open res class + @Output() clickedOnClass: EventEmitter = new EventEmitter(); - // list of default property types - propertyTypes: Category[] = DefaultProperties.data; + propInfo: Property = new Property(); + + propType: DefaultProperty; propAttribute: string; + propAttributeComment: string; + + ontology: ReadOntology; + + project: ReadProject; + + // list of default property types + propertyTypes: PropertyCategory[] = DefaultProperties.data; // list of resource classes where the property is used resClasses: ResourceClassDefinitionWithAllLanguages[] = []; + showActionBubble = false; + constructor( - private _cache: CacheService, - private _domSanitizer: DomSanitizer, - private _matIconRegistry: MatIconRegistry - ) { - - // special icons for property type - this._matIconRegistry.addSvgIcon( - 'integer_icon', - this._domSanitizer.bypassSecurityTrustResourceUrl('/assets/images/integer-icon.svg') - ); - this._matIconRegistry.addSvgIcon( - 'decimal_icon', - this._domSanitizer.bypassSecurityTrustResourceUrl('/assets/images/decimal-icon.svg') - ); - } + private _cache: CacheService + ) { } ngOnInit(): void { + // convert cardinality from js-lib convention to app convention // if cardinality is defined; only in resource class view if (this.propCard) { @@ -82,7 +111,10 @@ export class PropertyInfoComponent implements OnInit, AfterContentInit { // find gui ele from list of default property-types to set type value if (this.propDef.guiElement) { for (const group of this.propertyTypes) { - this.propType = group.elements.find(i => i.guiEle === this.propDef.guiElement && (i.objectType === this.propDef.objectType || i.subPropOf === this.propDef.subPropertyOf[0])); + this.propType = group.elements.find(i => + i.guiEle === this.propDef.guiElement && + (i.objectType === this.propDef.objectType || i.subPropOf === this.propDef.subPropertyOf[0]) + ); if (this.propType) { break; @@ -98,10 +130,11 @@ export class PropertyInfoComponent implements OnInit, AfterContentInit { // this property is a link property to another resource class // get current ontology to get linked res class information this._cache.get('currentOntology').subscribe( - (ontology: ReadOntology) => { + (response: ReadOntology) => { + this.ontology = response; // get the base ontology of object type const baseOnto = this.propDef.objectType.split('#')[0]; - if (baseOnto !== ontology.id) { + if (baseOnto !== response.id) { // get class info from another ontology this._cache.get('currentProjectOntologies').subscribe( (ontologies: ReadOntology[]) => { @@ -110,11 +143,13 @@ export class PropertyInfoComponent implements OnInit, AfterContentInit { this.propAttribute = 'Region'; } else { this.propAttribute = onto.classes[this.propDef.objectType].label; + this.propAttributeComment = onto.classes[this.propDef.objectType].comment; } } ); } else { - this.propAttribute = ontology.classes[this.propDef.objectType].label; + this.propAttribute = response.classes[this.propDef.objectType].label; + this.propAttributeComment = response.classes[this.propDef.objectType].comment; } } @@ -128,9 +163,10 @@ export class PropertyInfoComponent implements OnInit, AfterContentInit { (response: ListNodeInfo[]) => { const re = /\<([^)]+)\>/; const listIri = this.propDef.guiAttributes[0].match(re)[1]; - const listUrl = `/project/${this.projectcode}/lists/${encodeURIComponent(listIri)}`; + const listUrl = `/project/${this.projectCode}/lists/${encodeURIComponent(listIri)}`; const list = response.find(i => i.id === listIri); this.propAttribute = `${list.labels[0].value}`; + this.propAttributeComment = (list.comments.length ? list.comments[0].value : null); } ); } @@ -138,8 +174,9 @@ export class PropertyInfoComponent implements OnInit, AfterContentInit { // get all classes where the property is used if (!this.propCard) { this._cache.get('currentOntology').subscribe( - (ontology: ReadOntology) => { - const classes = ontology.getAllClassDefinitions(); + (response: ReadOntology) => { + this.ontology = response; + const classes = response.getAllClassDefinitions(); for (const c of classes) { if (c.propertiesList.find(i => i.propertyIndex === this.propDef.id)) { this.resClasses.push(c as ResourceClassDefinitionWithAllLanguages); @@ -156,4 +193,18 @@ export class PropertyInfoComponent implements OnInit, AfterContentInit { } + /** + * show action bubble with various CRUD buttons when hovered over. + */ + mouseEnter() { + this.showActionBubble = true; + } + + /** + * hide action bubble with various CRUD buttons when not hovered over. + */ + mouseLeave() { + this.showActionBubble = false; + } + } diff --git a/src/app/project/ontology/resource-class-form/resource-class-form.component.html b/src/app/project/ontology/resource-class-form/resource-class-form.component.html index 284ce5293f..2fba513d57 100644 --- a/src/app/project/ontology/resource-class-form/resource-class-form.component.html +++ b/src/app/project/ontology/resource-class-form/resource-class-form.component.html @@ -1,118 +1,135 @@ -
+ +
- - +
+
- - - -
-
- - -
- - - - {{ formErrors.label }} - -
- - -
- -
+ +
+ + + + {{ formErrors.label }} +
- -
- - - - - - - - + +
+
- -
- - - Default language for the labels - - - {{ option.value }} - - - + +
+ + + + + + + + +
+
+ + +
+ + + Default language for the labels + + + {{ option.value }} + + + - -
-
+ +
+
- + -
+
- + - - + + - - -
- +
- -
- - - - - - - - -
+ +
+ + +
+ + + + + + + +
- +
+ + + +

The resource class can't be edited because of missing "lastModificationDate"!

+ + + -
+ + + + + + diff --git a/src/app/project/ontology/resource-class-form/resource-class-form.component.spec.ts b/src/app/project/ontology/resource-class-form/resource-class-form.component.spec.ts index 42e3637304..4667227414 100644 --- a/src/app/project/ontology/resource-class-form/resource-class-form.component.spec.ts +++ b/src/app/project/ontology/resource-class-form/resource-class-form.component.spec.ts @@ -7,11 +7,12 @@ import { MatDividerModule } from '@angular/material/divider'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; +import { MatListModule } from '@angular/material/list'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterTestingModule } from '@angular/router/testing'; -import { KnoraApiConnection } from '@dasch-swiss/dsp-js'; +import { KnoraApiConnection, MockOntology, ReadOntology } from '@dasch-swiss/dsp-js'; import { AppInitService, DspActionModule, @@ -19,6 +20,8 @@ import { DspApiConnectionToken } from '@dasch-swiss/dsp-ui'; import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { CacheService } from 'src/app/main/cache/cache.service'; import { DialogComponent } from 'src/app/main/dialog/dialog.component'; import { ErrorComponent } from 'src/app/main/error/error.component'; import { TestConfig } from 'test.config'; @@ -29,6 +32,8 @@ describe('ResourceClassFormComponent', () => { let component: ResourceClassFormComponent; let fixture: ComponentFixture; + const cacheServiceSpy = jasmine.createSpyObj('CacheService', ['get']); + beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ @@ -45,6 +50,7 @@ describe('ResourceClassFormComponent', () => { MatFormFieldModule, MatIconModule, MatInputModule, + MatListModule, MatOptionModule, MatSelectModule, MatSlideToggleModule, @@ -62,6 +68,10 @@ describe('ResourceClassFormComponent', () => { { provide: DspApiConnectionToken, useValue: new KnoraApiConnection(TestConfig.ApiConfig) + }, + { + provide: CacheService, + useValue: cacheServiceSpy } ] }) @@ -69,6 +79,16 @@ describe('ResourceClassFormComponent', () => { })); beforeEach(() => { + // mock cache service for currentOntology + const cacheSpy = TestBed.inject(CacheService); + + (cacheSpy as jasmine.SpyObj).get.and.callFake( + () => { + const response: ReadOntology = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2'); + return of(response); + } + ); + fixture = TestBed.createComponent(ResourceClassFormComponent); component = fixture.componentInstance; fixture.detectChanges(); diff --git a/src/app/project/ontology/resource-class-form/resource-class-form.component.ts b/src/app/project/ontology/resource-class-form/resource-class-form.component.ts index 2551a57349..3f89b79db3 100644 --- a/src/app/project/ontology/resource-class-form/resource-class-form.component.ts +++ b/src/app/project/ontology/resource-class-form/resource-class-form.component.ts @@ -39,9 +39,9 @@ import { Property, ResourceClassFormService } from './resource-class-form.servic export class ResourceClassFormComponent implements OnInit, OnDestroy, AfterViewChecked { /** - * current project iri + * current project shortcode */ - @Input() projectIri: string; + @Input() projectCode: string; /** * create mode: iri selected resource class is a subclass from knora base (baseClassIri) @@ -51,7 +51,7 @@ export class ResourceClassFormComponent implements OnInit, OnDestroy, AfterViewC @Input() iri: string; /** - * name of resource class e.g. Still image + * name of resource class type e.g. Still image * this will be used to update title of resource class form */ @Input() name: string; diff --git a/src/app/project/ontology/resource-class-form/resource-class-form.service.ts b/src/app/project/ontology/resource-class-form/resource-class-form.service.ts index 8b67cbad75..1ddf9a3057 100644 --- a/src/app/project/ontology/resource-class-form/resource-class-form.service.ts +++ b/src/app/project/ontology/resource-class-form/resource-class-form.service.ts @@ -2,13 +2,13 @@ import { Injectable } from '@angular/core'; import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; import { Cardinality, IHasProperty, PropertyDefinition, ResourceClassDefinition, ResourcePropertyDefinition } from '@dasch-swiss/dsp-js'; import { BehaviorSubject, Observable } from 'rxjs'; -import { PropertyType } from '../default-data/default-properties'; +import { DefaultProperty } from '../default-data/default-properties'; // property data structure export class Property { iri: string; label: string; - type: PropertyType; + type: DefaultProperty; multiple: boolean; required: boolean; guiAttr: string; @@ -194,6 +194,7 @@ export class ResourceClassFormService { this.resourceClassForm.next(currentResourceClass); } + /** * delete property line by index i * diff --git a/src/app/project/ontology/resource-class-info/resource-class-info.component.html b/src/app/project/ontology/resource-class-info/resource-class-info.component.html index 33943ae9be..4838710c07 100644 --- a/src/app/project/ontology/resource-class-info/resource-class-info.component.html +++ b/src/app/project/ontology/resource-class-info/resource-class-info.component.html @@ -47,7 +47,7 @@ && ontology?.properties[prop.propertyIndex].objectType !== 'http://api.knora.org/ontology/knora-api/v2#LinkValue' && !ontology?.properties[prop.propertyIndex].subjectType?.includes('Standoff') && expanded" [propDef]="ontology?.properties[prop.propertyIndex]" - [propCard]="prop" [projectcode]="projectcode"> + [propCard]="prop" [projectCode]="projectCode"> diff --git a/src/app/project/ontology/resource-class-info/resource-class-info.component.ts b/src/app/project/ontology/resource-class-info/resource-class-info.component.ts index 4cdc2bf47e..9de2e4082f 100644 --- a/src/app/project/ontology/resource-class-info/resource-class-info.component.ts +++ b/src/app/project/ontology/resource-class-info/resource-class-info.component.ts @@ -3,7 +3,7 @@ import { Component, OnInit } from '@angular/core'; import { ApiResponseError, ClassDefinition, ReadOntology } from '@dasch-swiss/dsp-js'; import { CacheService } from 'src/app/main/cache/cache.service'; import { ErrorHandlerService } from 'src/app/main/error/error-handler.service'; -import { DefaultInfo, DefaultResourceClasses } from '../default-data/default-resource-classes'; +import { DefaultClass, DefaultResourceClasses } from '../default-data/default-resource-classes'; @Component({ selector: 'app-resource-class-info', @@ -17,16 +17,18 @@ export class ResourceClassInfoComponent implements OnInit { @Input() resourceClass: ClassDefinition; - @Output() editResourceClass: EventEmitter = new EventEmitter(); + @Input() projectCode: string; + + @Output() editResourceClass: EventEmitter = new EventEmitter(); @Output() updateCardinality: EventEmitter = new EventEmitter(); - @Output() deleteResourceClass: EventEmitter = new EventEmitter(); + @Output() deleteResourceClass: EventEmitter = new EventEmitter(); ontology: ReadOntology; subClassOfLabel = ''; // list of default classes - defaultClasses: DefaultInfo[] = DefaultResourceClasses.data; + defaultClasses: DefaultClass[] = DefaultResourceClasses.data; constructor( private _cache: CacheService, diff --git a/src/app/project/ontology/resource-class-property-form/resource-class-property-form.component.html b/src/app/project/ontology/resource-class-property-form/resource-class-property-form.component.html new file mode 100644 index 0000000000..47ce3568e3 --- /dev/null +++ b/src/app/project/ontology/resource-class-property-form/resource-class-property-form.component.html @@ -0,0 +1,115 @@ +
+ {{index + 1}})   + + + + + + + + + + This property already exists. + + + + + + + {{prop.label}} {{ prop?.labels | dspStringifyStringLiteral:'all' }} + + + + + + Property type * + + + + {{propertyForm.controls['type'].value.group}}:  + {{propertyForm.controls['type'].value.label}} + + + + {{ele.icon}} {{ele.label}} + + + + + You can re-use but not edit. + + + + +
+
+
+ + Select list + + + {{item.labels[0].value}} + + + +
+ +
+ + Select resource class + + + {{item.label}} + + + +
+ +
+ + Define range + + + + +
+ +
+
+ +
+ + + + + +
+ + Multiple values? + +
+ +
+ + Required field? + +
+ + +
+ +
+ +
+ +
diff --git a/src/app/project/ontology/resource-class-property-form/resource-class-property-form.component.scss b/src/app/project/ontology/resource-class-property-form/resource-class-property-form.component.scss new file mode 100644 index 0000000000..8ff2ec6949 --- /dev/null +++ b/src/app/project/ontology/resource-class-property-form/resource-class-property-form.component.scss @@ -0,0 +1,38 @@ +:host { + margin: 12px 0 0 20px; + + form { + margin: 0 0 16px 0; + width: 100%; + } +} + +.large-field, +.medium-field, +.small-field, +.x-small-field { + margin: 0 2px; + display: inline-flex; +} + +.index { + color: rgba(0, 0, 0, 0.54); + font-size: small; + margin-left: -12px; + margin-right: 6px; +} + +.empty-index { + display: inline-block; + width: 12px; +} + +.reset-button { + position: absolute; + right: 0; + bottom: 12px; +} + +.hidden { + display: none; +} diff --git a/src/app/project/ontology/resource-class-property-form/resource-class-property-form.component.spec.ts b/src/app/project/ontology/resource-class-property-form/resource-class-property-form.component.spec.ts new file mode 100644 index 0000000000..d1ae7ba8f1 --- /dev/null +++ b/src/app/project/ontology/resource-class-property-form/resource-class-property-form.component.spec.ts @@ -0,0 +1,185 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { Component, OnInit, ViewChild } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormArray, FormBuilder, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatOptionModule } from '@angular/material/core'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { Constants, IHasProperty, KnoraApiConnection, ListNodeInfo, MockOntology, PropertyDefinition, ReadOntology, ResourcePropertyDefinitionWithAllLanguages } from '@dasch-swiss/dsp-js'; +import { AppInitService, DspApiConfigToken, DspApiConnectionToken } from '@dasch-swiss/dsp-ui'; +import { of, Subscription } from 'rxjs'; +import { CacheService } from 'src/app/main/cache/cache.service'; +import { TestConfig } from 'test.config'; +import { Property, ResourceClassFormService } from '../resource-class-form/resource-class-form.service'; +import { ResourceClassPropertyFormComponent } from './resource-class-property-form.component'; + + +/** + * test host component to simulate parent component + * Property is of type simple text + */ +@Component({ + template: '' +}) +class HostComponent implements OnInit { + + @ViewChild('propertyForm') resClassPropertyFormComponent: ResourceClassPropertyFormComponent; + + resourceClassForm: FormGroup; + + resourceClassFormSub: Subscription; + + properties: FormArray; + + resClassIri = Constants.Resource; + + ontology: ReadOntology = MockOntology.mockReadOntology('http://0.0.0.0:3333/ontology/0001/anything/v2'); + + constructor( + private _resourceClassFormService: ResourceClassFormService + ) { + + this._resourceClassFormService.addProperty(); + } + + ngOnInit() { + const ontoProperties: PropertyDefinition[] = this.ontology.getAllPropertyDefinitions(); + + this.resourceClassFormSub = this._resourceClassFormService.resourceClassForm$ + .subscribe(resourceClass => { + this.resourceClassForm = resourceClass; + this.properties = this.resourceClassForm.get('properties') as FormArray; + }); + + } + +} + +xdescribe('ResourceClassPropertyFormComponent', () => { + let component: HostComponent; + let fixture: ComponentFixture; + + const formBuilder: FormBuilder = new FormBuilder(); + + const cacheServiceSpy = jasmine.createSpyObj('CacheService', ['get']); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + HostComponent, + ResourceClassPropertyFormComponent + ], + imports: [ + BrowserAnimationsModule, + HttpClientTestingModule, + MatAutocompleteModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatSlideToggleModule, + MatSnackBarModule, + ReactiveFormsModule + ], + providers: [ + // reference the new instance of formBuilder from above + { provide: FormBuilder, useValue: formBuilder }, + AppInitService, + { + provide: DspApiConfigToken, + useValue: TestConfig.ApiConfig + }, + { + provide: DspApiConnectionToken, + useValue: new KnoraApiConnection(TestConfig.ApiConfig) + }, + { + provide: CacheService, + useValue: cacheServiceSpy + }, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + + // mock cache service for currentOntologyLists + const cacheSpy = TestBed.inject(CacheService); + + (cacheSpy as jasmine.SpyObj).get.and.callFake( + () => { + const response: ListNodeInfo[] = [{ + 'comments': [], + 'id': 'http://rdfh.ch/lists/0001/otherTreeList', + 'isRootNode': true, + 'labels': [{ + 'language': 'en', + 'value': 'Tree list root' + }], + 'projectIri': 'http://rdfh.ch/projects/0001' + }, { + 'comments': [{ + 'language': 'en', + 'value': 'a list that is not in used in ontology or data' + }], + 'id': 'http://rdfh.ch/lists/0001/notUsedList', + 'isRootNode': true, + 'labels': [{ + 'language': 'de', + 'value': 'unbenutzte Liste' + }, { + 'language': 'en', + 'value': 'a list that is not used' + }], + 'name': 'notUsedList', + 'projectIri': 'http://rdfh.ch/projects/0001' + }, { + 'comments': [{ + 'language': 'en', + 'value': 'Anything Tree List' + }], + 'id': 'http://rdfh.ch/lists/0001/treeList', + 'isRootNode': true, + 'labels': [{ + 'language': 'de', + 'value': 'Listenwurzel' + }, { + 'language': 'en', + 'value': 'Tree list root' + }], + 'name': 'treelistroot', + 'projectIri': 'http://rdfh.ch/projects/0001' + }]; + return of(response); + } + ); + + fixture = TestBed.createComponent(HostComponent); + component = fixture.componentInstance; + + // // pass in the form dynamically + // component.propertyForm = formBuilder.group({ + // type: null, + // label: null, + // multiple: null, + // required: null, + // permission: null + // }); + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/project/ontology/resource-class-property-form/resource-class-property-form.component.ts b/src/app/project/ontology/resource-class-property-form/resource-class-property-form.component.ts new file mode 100644 index 0000000000..35dbfcedc2 --- /dev/null +++ b/src/app/project/ontology/resource-class-property-form/resource-class-property-form.component.ts @@ -0,0 +1,245 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { MatSelectChange } from '@angular/material/select'; +import { + ApiResponseError, + ClassDefinition, + Constants, + ListNodeInfo, + ReadOntology, + ResourcePropertyDefinition +} from '@dasch-swiss/dsp-js'; +import { AutocompleteItem } from '@dasch-swiss/dsp-ui'; +import { Observable } from 'rxjs'; +import { map, startWith } from 'rxjs/operators'; +import { CacheService } from 'src/app/main/cache/cache.service'; +import { ErrorHandlerService } from 'src/app/main/error/error-handler.service'; +import { DefaultProperties, DefaultProperty, PropertyCategory } from '../default-data/default-properties'; + +@Component({ + selector: 'app-resource-class-property-form', + templateUrl: './resource-class-property-form.component.html', + styleUrls: ['./resource-class-property-form.component.scss'] +}) +export class ResourceClassPropertyFormComponent implements OnInit { + + @Input() propertyForm: FormGroup; + + @Input() index: number; + + @Input() resClassIri?: string; + + @Output() deleteProperty: EventEmitter = new EventEmitter(); + + ontology: ReadOntology; + // selection of default property types + propertyTypes: PropertyCategory[] = DefaultProperties.data; + + showGuiAttr = false; + + // list of project specific lists (TODO: probably we have to add default knora lists?!) + lists: ListNodeInfo[]; + + // resource classes in this ontology + resourceClass: ClassDefinition[] = []; + + // list of existing properties + properties: AutocompleteItem[] = []; + + filteredProperties: Observable; + + selectTypeLabel: string; // = this.propertyTypes[0].group + ': ' + this.propertyTypes[0].elements[0].label; + selectedGroup: string; + + existingProperty: boolean; + + loading = false; + + dspConstants = Constants; + + constructor( + private _cache: CacheService, + private _errorHandler: ErrorHandlerService + ) { } + + ngOnInit() { + + this._cache.get('currentOntology').subscribe( + (response: ReadOntology) => { + this.ontology = response; + + // set various lists to select from + // a) in case of link value: + // set list of resource classes from response; needed for linkValue + const classKeys: string[] = Object.keys(response.classes); + for (const c of classKeys) { + this.resourceClass.push(this.ontology.classes[c]); + } + + // b) in case of already existing label: + // set list of properties from response; needed for autocomplete in label to reuse existing property + const propKeys: string[] = Object.keys(response.properties); + for (const p of propKeys) { + const prop = this.ontology.properties[p]; + if (prop.objectType !== Constants.LinkValue && prop.objectType !== this.resClassIri) { + const existingProperty: AutocompleteItem = { + iri: this.ontology.properties[p].id, + name: this.ontology.properties[p].id.split('#')[1], + label: this.ontology.properties[p].label + }; + this.properties.push(existingProperty); + } + } + + if (this.propertyForm) { + // init list of property types with first element + this.propertyForm.patchValue({ type: this.propertyTypes[0].elements[0] }); + + if (this.propertyForm.value.label) { + + const existingProp: AutocompleteItem = { + iri: this.propertyForm.value.iri, + label: this.propertyForm.value.label, + name: '' + }; + + // edit mode: this prop value exists already + this.loading = true; + this.updateFieldsDependingOnLabel(existingProp); + } + } + }, + (error: ApiResponseError) => { + this._errorHandler.showMessage(error); + } + ); + + // c) in case of list value: + // set list of lists; needed for listValue + this._cache.get('currentOntologyLists').subscribe( + (response: ListNodeInfo[]) => { + this.lists = response; + } + ); + + this.filteredProperties = this.propertyForm.controls['label'].valueChanges + .pipe( + startWith(''), + map(prop => prop.length >= 0 ? this.filter(this.properties, prop) : []) + ); + } + + /** + * filter a list while typing in auto complete input field + * @param list List of options + * @param label Value to filter by + * @returns Filtered list of options + */ + filter(list: AutocompleteItem[], label: string) { + return list.filter(prop => + prop.label?.toLowerCase().includes(label.toLowerCase()) + ); + } + + updateAttributeField(event: MatSelectChange) { + + // reset value of guiAttr + this.propertyForm.controls['guiAttr'].setValue(undefined); + // depending on the selected property type, + // we have to define gui element attributes + // e.g. iri of list or connected resource class + switch (event.value.objectType) { + case Constants.ListValue: + case Constants.LinkValue: + this.showGuiAttr = true; + this.propertyForm.controls['guiAttr'].setValidators([ + Validators.required + ]); + this.propertyForm.controls['guiAttr'].updateValueAndValidity(); + break; + + default: + this.propertyForm.controls['guiAttr'].clearValidators(); + this.propertyForm.controls['guiAttr'].updateValueAndValidity(); + this.showGuiAttr = false; + } + + } + + /** + * @param {MatOption} option + */ + updateFieldsDependingOnLabel(option: AutocompleteItem) { + this.propertyForm.controls['iri'].setValue(option.iri); + + // set label and disable the input + this.propertyForm.controls['label'].setValue(option.label); + this.propertyForm.controls['label'].disable(); + + // find corresponding property type + + if (this.ontology.properties[option.iri] instanceof ResourcePropertyDefinition) { + const tempProp: any | ResourcePropertyDefinition = this.ontology.properties[option.iri]; + + let obj: DefaultProperty; + // find gui ele from list of default property-types to set type value + for (const group of this.propertyTypes) { + obj = group.elements.find(i => i.guiEle === tempProp.guiElement && (i.objectType === tempProp.objectType || i.subPropOf === tempProp.subPropertyOf[0])); + + if (obj) { + this.propertyForm.controls['type'].setValue(obj); + break; + } + } + + switch (tempProp.guiElement) { + // prop type is a list + case Constants.SalsahGui + Constants.HashDelimiter + 'List': + case Constants.SalsahGui + Constants.HashDelimiter + 'Radio': + // gui attribute value for lists looks as follow: hlist= + // get index from guiAttr array where value starts with hlist= + const i = tempProp.guiAttributes.findIndex(element => element.includes('hlist')); + + // find content beteween pointy brackets to get list iri + const re = /\<([^)]+)\>/; + const listIri = tempProp.guiAttributes[i].match(re)[1]; + + this.showGuiAttr = true; + this.propertyForm.controls['guiAttr'].setValue(listIri); + this.propertyForm.controls['guiAttr'].disable(); + break; + + // prop type is resource pointer + case Constants.SalsahGui + Constants.HashDelimiter + 'Searchbox': + + this.showGuiAttr = true; + this.propertyForm.controls['guiAttr'].setValue(tempProp.objectType); + this.propertyForm.controls['guiAttr'].disable(); + break; + + default: + this.showGuiAttr = false; + } + } + this.propertyForm.controls['type'].disable(); + this.existingProperty = true; + } + + resetProperty(ev: Event) { + ev.preventDefault(); + this.existingProperty = false; + + this.propertyForm.controls['iri'].reset(); + this.propertyForm.controls['label'].setValue(''); + this.propertyForm.controls['label'].enable(); + this.propertyForm.controls['type'].setValue(this.propertyTypes[0].elements[0]); + this.propertyForm.controls['type'].enable(); + this.propertyForm.controls['guiAttr'].setValue(undefined); + this.propertyForm.controls['guiAttr'].enable(); + + this.propertyForm.controls['multiple'].reset(); + this.propertyForm.controls['required'].reset(); + } + + +} diff --git a/src/app/user/membership/membership.component.ts b/src/app/user/membership/membership.component.ts index 89fe4323f3..0cf7e9b8f3 100644 --- a/src/app/user/membership/membership.component.ts +++ b/src/app/user/membership/membership.component.ts @@ -10,9 +10,8 @@ import { ReadUser, UserResponse } from '@dasch-swiss/dsp-js'; -import { DspApiConnectionToken, Session } from '@dasch-swiss/dsp-ui'; +import { AutocompleteItem, DspApiConnectionToken, Session } from '@dasch-swiss/dsp-ui'; import { CacheService } from 'src/app/main/cache/cache.service'; -import { AutocompleteItem } from 'src/app/main/declarations/autocomplete-item'; import { ErrorHandlerService } from 'src/app/main/error/error-handler.service'; // --> TODO replace it by IPermissions from dsp-js diff --git a/src/assets/style/_elements.scss b/src/assets/style/_elements.scss index a78646008c..31c4341947 100644 --- a/src/assets/style/_elements.scss +++ b/src/assets/style/_elements.scss @@ -5,11 +5,10 @@ Every toolbar row uses a flexbox row layout. */ flex: 1 1 auto; } -.space-reducer{ +.space-reducer { max-height: 14px; } - // -------------------------------------- // @@ -134,13 +133,6 @@ a, // // menu -/* -.cdk-overlay-connected-position-bounding-box { - top: 12px !important; - right: 12px !important; -} -*/ - .menu-header, .menu-content, .menu-footer { @@ -386,7 +378,6 @@ $gc-small: $form-width - $gc-large - 4; .form-content { margin: 24px auto; - // padding: 0 20px; width: $form-width; .x-small-field, @@ -429,26 +420,13 @@ $gc-small: $form-width - $gc-large - 4; .string-literal-error { font-size: 75%; color: $warn; - top: -16px; + top: -14px; position: relative; + text-align: left; + left: 64px; + display: block; } -// global drag-n-drop setup, when moving element in list -// at the moment (2019-07) it's used in ontology editor -/* TODO: probabyl this was deleted in a previous PR. I had a conflict on this. -.cdk-drag-preview { - // show the first mat-form-field only; in this case it's the label - .small-field:nth-child(n + 4) { - display: none !important; - } - button { - &.add-new-line, - &.delete-line { - display: none; - } - } -} -*/ // in case of an error: .mat-form-field-invalid { .mat-form-field-label, @@ -545,3 +523,93 @@ $gc-small: $form-width - $gc-large - 4; } // -------------------------------------- + +// +// action bubble +.action-bubble { + position: absolute; + right: 18px; + top: -9px; + border: 1px solid #e4e4e4; + border-radius: 14px; + padding: 0; + background-color: #e4e4e4; + z-index: 2; + @include box-shadow(); + + .button-container { + button { + // cursor: pointer; + border: none; + padding: 2px; + outline: none; + background-color: transparent; + color: #000000; + margin: 0 2px; + border-radius: 50%; + transition: background-color ease-out 0.5s; + min-width: inherit; + line-height: normal; + + &:first-child { + margin: 0 2px 0 -1px; + } + + &:last-child { + margin: 0 -1px 0 2px; + } + + .material-icons { + font-size: 18px; + } + + .mat-icon { + padding: 2px; + width: 18px; + height: 18px; + vertical-align: middle; + } + } + + button.info { + cursor: default; + } + + button:hover { + background-color: #c7c7c7; + } + } +} + +// -------------------------------------- + +// +// nested mat-menu switch arrow to the left in case the menu position is on the right hand side +.switch-nested-menu { + + width: 180px; + + button[mat-menu-item] { + padding-left: 32px; + + &.mat-menu-item-submenu-trigger { + &::after { + border-width: 5px 5px 5px 0; + border-color: transparent currentColor transparent transparent; + right: 0; + left: 16px; + } + } + } +} +.switch-nested-sub-menu { + width: 180px; + margin-left: -360px !important; + + &.mat-menu-panel { + margin-top: 8px; + min-height: 48px !important; + } +} + +// --------------------------------------