diff --git a/apps/metadata-editor-e2e/src/e2e/edit.cy.ts b/apps/metadata-editor-e2e/src/e2e/edit.cy.ts index 4360ee6bd6..7d37930ba8 100644 --- a/apps/metadata-editor-e2e/src/e2e/edit.cy.ts +++ b/apps/metadata-editor-e2e/src/e2e/edit.cy.ts @@ -18,7 +18,7 @@ describe('editor form', () => { return } // there is a copy: delete it - cy.get('[data-test="record-menu-button"]').eq(1).click() + cy.get('[data-test="record-menu-button"]').eq(0).click() cy.get('[data-test="record-menu-delete-button"]').click() cy.get('[data-cy="confirm-button"]').click() cy.log('An existing copy of the test record was found and deleted.') @@ -675,6 +675,9 @@ describe('editor form', () => { }) }) describe('Access and constraints', () => { + beforeEach(() => { + cy.get('@accessContactPageBtn').click() + }) // TEMPORARY - to be removed when the open data switch is back // describe('Open data switch', () => { // beforeEach(() => { @@ -699,9 +702,6 @@ describe('editor form', () => { // }) // }) describe('licenses', () => { - beforeEach(() => { - cy.get('@accessContactPageBtn').click() - }) it('should select a new license and show it on reload', () => { cy.get('gn-ui-form-field-license') .find('button') @@ -727,6 +727,120 @@ describe('editor form', () => { .should('eq', ' Creative Commons CC-0 ') }) }) + describe('constraints', () => { + it('should add a few constraints and show it on reload', () => { + cy.editor_wrapPreviousDraft() + cy.get('[data-cy=legalConstraints]') + .find('gn-ui-button[data-cy=add-constraint-btn] button') + .click() + cy.get('[data-cy=legalConstraints]') + .find('textarea') + .last() + .type('new legal constraint') + + // add from shortcuts + cy.get('[data-cy=constraints-shortcut-btns]') + .find('gn-ui-button') + .eq(1) + .click() + cy.get('[data-cy=securityConstraints]') + .find('textarea') + .last() + .type('new security constraint') + + // add from shortcuts + cy.get('[data-cy=constraints-shortcut-btns]') + .find('gn-ui-button') + .eq(2) + .click() + cy.get('[data-cy=otherConstraints]') + .find('textarea') + .last() + .type('new other constraint') + cy.get('[data-cy=otherConstraints]') + .find('gn-ui-button[data-cy=add-url-btn] button') + .click() + cy.get('[data-cy=otherConstraints]') + .find('gn-ui-url-input') + .last() + .find('input') + .type('http://www.example.com/abcd/1234') + + cy.screenshot({ capture: 'fullPage' }) + cy.editor_publishAndReload() + cy.get('@saveStatus').should('eq', 'record_up_to_date') + cy.get('@accessContactPageBtn').click() + + cy.get('[data-cy=legalConstraints]') + .find('gn-ui-constraint-card') + .should('have.length', 5) + cy.get('[data-cy=legalConstraints]') + .find('textarea') + .last() + .invoke('val') + .should('eq', 'new legal constraint') + + cy.get('[data-cy=securityConstraints]') + .find('gn-ui-constraint-card') + .should('have.length', 1) + cy.get('[data-cy=securityConstraints]') + .find('textarea') + .last() + .invoke('val') + .should('eq', 'new security constraint') + + cy.get('[data-cy=otherConstraints]') + .find('gn-ui-constraint-card') + .should('have.length', 1) + cy.get('[data-cy=otherConstraints]') + .find('textarea') + .last() + .invoke('val') + .should('eq', 'new other constraint') + cy.get('[data-cy=otherConstraints]') + .find('gn-ui-url-input input') + .invoke('val') + .should('eq', 'http://www.example.com/abcd/1234') + }) + + it('should enable "no applicable constraints" and stay enabled', () => { + cy.editor_wrapPreviousDraft() + cy.get('[data-cy=constraints-shortcut-toggles]') + .find('gn-ui-check-toggle label') + .eq(0) + .click() + + cy.editor_publishAndReload() + cy.get('@saveStatus').should('eq', 'record_up_to_date') + cy.get('@accessContactPageBtn').click() + + cy.get('[data-cy=constraints-shortcut-toggles]') + .find('gn-ui-check-toggle input[type=checkbox]') + .eq(0) + .invoke('val') + .should('eq', 'on') + + // constraints are hidden + cy.get('[data-cy=legalConstraints]').should('not.exist') + cy.get('[data-cy=securityConstraints]').should('not.exist') + cy.get('[data-cy=otherConstraints]').should('not.exist') + + // uncheck toggle + cy.get('[data-cy=constraints-shortcut-toggles]') + .find('gn-ui-check-toggle label') + .eq(0) + .click() + + // remaining constraints are shown + cy.get('[data-cy=legalConstraints]').should('not.exist') + cy.get('[data-cy=securityConstraints]') + .find('gn-ui-constraint-card') + .should('have.length', 1) + cy.get('[data-cy=otherConstraints]') + .find('gn-ui-constraint-card') + .should('have.length', 1) + }) + }) }) }) }) diff --git a/libs/api/metadata-converter/src/lib/fixtures/generic-dataset+geo2france-plu.iso19139.xml b/libs/api/metadata-converter/src/lib/fixtures/generic-dataset+geo2france-plu.iso19139.xml index e6696c1808..b48bdadf60 100644 --- a/libs/api/metadata-converter/src/lib/fixtures/generic-dataset+geo2france-plu.iso19139.xml +++ b/libs/api/metadata-converter/src/lib/fixtures/generic-dataset+geo2france-plu.iso19139.xml @@ -398,7 +398,7 @@ Cette section contient des *caractères internationaux* (ainsi que des "caractè - Contains sensitive information related to national defense + Contains sensitive information related to national defense Contains sensitive information related to national defense diff --git a/libs/api/metadata-converter/src/lib/fixtures/generic-dataset+metawal.iso19115-3.xml b/libs/api/metadata-converter/src/lib/fixtures/generic-dataset+metawal.iso19115-3.xml index d7014f9871..af8fe09d93 100644 --- a/libs/api/metadata-converter/src/lib/fixtures/generic-dataset+metawal.iso19115-3.xml +++ b/libs/api/metadata-converter/src/lib/fixtures/generic-dataset+metawal.iso19115-3.xml @@ -514,7 +514,7 @@ Cette section contient des *caractères internationaux* (ainsi que des "caractè codeListValue="restricted"/> - Contains sensitive information related to national defense + Contains sensitive information related to national defense Contains sensitive information related to national defense diff --git a/libs/api/metadata-converter/src/lib/fixtures/generic-dataset.iso19115-3.xml b/libs/api/metadata-converter/src/lib/fixtures/generic-dataset.iso19115-3.xml index 2c77dd9871..27a38de64f 100644 --- a/libs/api/metadata-converter/src/lib/fixtures/generic-dataset.iso19115-3.xml +++ b/libs/api/metadata-converter/src/lib/fixtures/generic-dataset.iso19115-3.xml @@ -377,7 +377,7 @@ Cette section contient des *caractères internationaux* (ainsi que des "caractè codeListValue="restricted"/> - Contains sensitive information related to national defense + Contains sensitive information related to national defense Contains sensitive information related to national defense diff --git a/libs/api/metadata-converter/src/lib/fixtures/generic-dataset.iso19139.xml b/libs/api/metadata-converter/src/lib/fixtures/generic-dataset.iso19139.xml index 2b2471afbb..3ef61f6de0 100644 --- a/libs/api/metadata-converter/src/lib/fixtures/generic-dataset.iso19139.xml +++ b/libs/api/metadata-converter/src/lib/fixtures/generic-dataset.iso19139.xml @@ -316,7 +316,7 @@ Cette section contient des *caractères internationaux* (ainsi que des "caractè - Contains sensitive information related to national defense + Contains sensitive information related to national defense Contains sensitive information related to national defense diff --git a/libs/api/metadata-converter/src/lib/fixtures/generic.records.ts b/libs/api/metadata-converter/src/lib/fixtures/generic.records.ts index f40a9960cc..39bd90b360 100644 --- a/libs/api/metadata-converter/src/lib/fixtures/generic.records.ts +++ b/libs/api/metadata-converter/src/lib/fixtures/generic.records.ts @@ -211,6 +211,7 @@ As such, **it is not very interesting at all.**`, securityConstraints: [ { text: 'Contains sensitive information related to national defense', + url: new URL('https://security.org/document.pdf'), translations: { text: { fr: 'Contient des informations sensibles liées à la défense nationale', diff --git a/libs/api/metadata-converter/src/lib/iso19139/write-parts.spec.ts b/libs/api/metadata-converter/src/lib/iso19139/write-parts.spec.ts index 2e27b4bb69..db356b32a4 100644 --- a/libs/api/metadata-converter/src/lib/iso19139/write-parts.spec.ts +++ b/libs/api/metadata-converter/src/lib/iso19139/write-parts.spec.ts @@ -12,10 +12,13 @@ import { writeContacts, writeContactsForResource, writeKeywords, + writeLegalConstraints, writeOnlineResources, + writeOtherConstraints, writeResourceCreated, writeResourcePublished, writeResourceUpdated, + writeSecurityConstraints, writeSpatialExtents, writeSpatialRepresentation, writeTemporalExtents, @@ -973,6 +976,57 @@ describe('write parts', () => { +`) + }) + }) + + describe('write constraints', () => { + it('writes elements without deleting others, remove empty constraints', () => { + writeSecurityConstraints(datasetRecord, rootEl) + writeLegalConstraints(datasetRecord, rootEl) + writeOtherConstraints(datasetRecord, rootEl) + writeLegalConstraints({ ...datasetRecord, legalConstraints: [] }, rootEl) + writeOtherConstraints( + { + ...datasetRecord, + otherConstraints: [ + { + text: 'new constraint', + }, + ], + }, + rootEl + ) + expect(rootAsString()).toEqual(` + + + + + + + + + Contains sensitive information related to national defense + + + Contains sensitive information related to national defense + + + Contient des informations sensibles liées à la défense nationale + + + + + + + + + new constraint + + + + + `) }) }) diff --git a/libs/api/metadata-converter/src/lib/iso19139/write-parts.ts b/libs/api/metadata-converter/src/lib/iso19139/write-parts.ts index e9bf15412a..bf87b7ba18 100644 --- a/libs/api/metadata-converter/src/lib/iso19139/write-parts.ts +++ b/libs/api/metadata-converter/src/lib/iso19139/write-parts.ts @@ -30,6 +30,7 @@ import { tap, } from '../function-utils' import { + allChildrenElement, appendChildren, createChild, createElement, @@ -53,20 +54,13 @@ import { writeGeometry } from './utils/geometry' import { namePartsToFull } from './utils/individual-name' import { LANG_2_TO_3_MAPPER } from '@geonetwork-ui/util/i18n/language-codes' -export function writeCharacterString( - text: string -): ChainableFunction { - return tap( - pipe(findChildOrCreate('gco:CharacterString'), setTextContent(text)) - ) -} - -export function writeLocalizedCharacterString( +function writeLocalizedElement( + writeFn: ChainableFunction, text: string, translations: FieldTranslation, defaultLanguage: LanguageCode ): ChainableFunction { - if (!translations) return writeCharacterString(text) + if (!translations) return writeFn function createLocalized(lang: LanguageCode, translation: string) { return pipe( createNestedElement('gmd:textGroup', 'gmd:LocalisedCharacterString'), @@ -75,7 +69,7 @@ export function writeLocalizedCharacterString( ) } return pipe( - writeCharacterString(text), + writeFn, removeChildrenByName('gmd:PT_FreeText'), createChild('gmd:PT_FreeText'), appendChildren( @@ -87,6 +81,27 @@ export function writeLocalizedCharacterString( ) } +export function writeCharacterString( + text: string +): ChainableFunction { + return tap( + pipe(findChildOrCreate('gco:CharacterString'), setTextContent(text)) + ) +} + +export function writeLocalizedCharacterString( + text: string, + translations: FieldTranslation, + defaultLanguage: LanguageCode +): ChainableFunction { + return writeLocalizedElement( + writeCharacterString(text), + text, + translations, + defaultLanguage + ) +} + export function writeLinkage( url: URL ): ChainableFunction { @@ -111,6 +126,20 @@ export function writeAnchor( ) } +export function writeLocalizedAnchor( + url: URL, + text: string, + translations: FieldTranslation, + defaultLanguage: LanguageCode +): ChainableFunction { + return writeLocalizedElement( + writeAnchor(url, text), + text, + translations, + defaultLanguage + ) +} + export function writeDateTime( date: Date ): ChainableFunction { @@ -466,11 +495,18 @@ export function createConstraint( ), pipe( createElement('gmd:useLimitation'), - writeLocalizedCharacterString( - constraint.text, - constraint.translations?.text, - defaultLanguage - ) + 'url' in constraint + ? writeLocalizedAnchor( + constraint.url, + constraint.text, + constraint.translations?.text, + defaultLanguage + ) + : writeLocalizedCharacterString( + constraint.text, + constraint.translations?.text, + defaultLanguage + ) ) ) ) @@ -491,38 +527,59 @@ export function createConstraint( ), pipe( createElement('gmd:otherConstraints'), - writeLocalizedCharacterString( - constraint.text, - constraint.translations?.text, - defaultLanguage - ) + 'url' in constraint + ? writeLocalizedAnchor( + constraint.url, + constraint.text, + constraint.translations?.text, + defaultLanguage + ) + : writeLocalizedCharacterString( + constraint.text, + constraint.translations?.text, + defaultLanguage + ) ) ) ) } - + // other return pipe( createNestedElement( 'gmd:resourceConstraints', 'gmd:MD_Constraints', 'gmd:useLimitation' ), - writeLocalizedCharacterString( - constraint.text, - constraint.translations?.text, - defaultLanguage - ) + 'url' in constraint + ? writeLocalizedAnchor( + constraint.url, + constraint.text, + constraint.translations?.text, + defaultLanguage + ) + : writeLocalizedCharacterString( + constraint.text, + constraint.translations?.text, + defaultLanguage + ) ) } export function removeOtherConstraints() { - return removeChildren( + return tap( pipe( findChildrenElement('gmd:resourceConstraints'), - filterArray( - pipe( - findNestedElements('gmd:MD_Constraints', 'gmd:useLimitation'), - (array) => array.length > 0 + mapArray( + removeChildren( + pipe( + findChildrenElement('gmd:MD_Constraints'), + filterArray( + pipe( + findNestedElements('gmd:useLimitation'), + (array) => array.length > 0 + ) + ) + ) ) ) ) @@ -530,13 +587,20 @@ export function removeOtherConstraints() { } export function removeSecurityConstraints() { - return removeChildren( + return tap( pipe( findChildrenElement('gmd:resourceConstraints'), - filterArray( - pipe( - findNestedElements('gmd:MD_SecurityConstraints', 'gmd:useLimitation'), - (array) => array.length > 0 + mapArray( + removeChildren( + pipe( + findChildrenElement('gmd:MD_SecurityConstraints'), + filterArray( + pipe( + findNestedElements('gmd:useLimitation'), + (array) => array.length > 0 + ) + ) + ) ) ) ) @@ -544,25 +608,40 @@ export function removeSecurityConstraints() { } export function removeLegalConstraints() { - return removeChildren( + return tap( pipe( findChildrenElement('gmd:resourceConstraints'), - filterArray( - pipe( - findNestedElements( - 'gmd:MD_LegalConstraints', - 'gmd:accessConstraints', - 'gmd:MD_RestrictionCode' - ), - mapArray(readAttribute('codeListValue')), - (restrictionCodes) => - restrictionCodes.every((code) => code !== 'license') + mapArray( + removeChildren( + pipe( + findChildrenElement('gmd:MD_LegalConstraints'), + filterArray( + pipe( + findNestedElements( + 'gmd:accessConstraints', + 'gmd:MD_RestrictionCode' + ), + mapArray(readAttribute('codeListValue')), + (restrictionCodes) => + restrictionCodes.every((code) => code !== 'license') + ) + ) + ) ) ) ) ) } +export function removeEmptyResourceConstraints() { + return removeChildren( + pipe( + findChildrenElement('gmd:resourceConstraints'), + filterArray(pipe(allChildrenElement, (array) => array.length === 0)) + ) + ) +} + export function removeLicenses() { return removeChildren( pipe( @@ -884,6 +963,7 @@ export function writeLegalConstraints( pipe( findOrCreateIdentification(), removeLegalConstraints(), + removeEmptyResourceConstraints(), appendChildren( ...record.legalConstraints.map((c) => createConstraint(c, 'legal', record.defaultLanguage) @@ -899,6 +979,7 @@ export function writeSecurityConstraints( pipe( findOrCreateIdentification(), removeSecurityConstraints(), + removeEmptyResourceConstraints(), appendChildren( ...record.securityConstraints.map((c) => createConstraint(c, 'security', record.defaultLanguage) @@ -914,6 +995,7 @@ export function writeOtherConstraints( pipe( findOrCreateIdentification(), removeOtherConstraints(), + removeEmptyResourceConstraints(), appendChildren( ...record.otherConstraints.map((c) => createConstraint(c, 'other', record.defaultLanguage) diff --git a/libs/feature/editor/src/lib/+state/editor.actions.ts b/libs/feature/editor/src/lib/+state/editor.actions.ts index 451ae15e3b..85e02ce54e 100644 --- a/libs/feature/editor/src/lib/+state/editor.actions.ts +++ b/libs/feature/editor/src/lib/+state/editor.actions.ts @@ -1,6 +1,7 @@ import { createAction, props } from '@ngrx/store' import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' import { SaveRecordError } from './editor.models' +import { EditorFieldIdentification } from '../models' export const openRecord = createAction( '[Editor] Open record', @@ -35,3 +36,8 @@ export const setCurrentPage = createAction( '[Editor] Set current page', props<{ page: number }>() ) + +export const setFieldVisibility = createAction( + '[Editor] Set field visibility', + props<{ field: EditorFieldIdentification; visible: boolean }>() +) diff --git a/libs/feature/editor/src/lib/+state/editor.facade.ts b/libs/feature/editor/src/lib/+state/editor.facade.ts index 898f41314a..1b3f6dfb69 100644 --- a/libs/feature/editor/src/lib/+state/editor.facade.ts +++ b/libs/feature/editor/src/lib/+state/editor.facade.ts @@ -5,6 +5,7 @@ import * as EditorSelectors from './editor.selectors' import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' import { filter } from 'rxjs' import { Actions, ofType } from '@ngrx/effects' +import { EditorFieldIdentification } from '../models' @Injectable() export class EditorFacade { @@ -58,4 +59,8 @@ export class EditorFacade { setCurrentPage(page: number) { this.store.dispatch(EditorActions.setCurrentPage({ page })) } + + setFieldVisibility(field: EditorFieldIdentification, visible: boolean) { + this.store.dispatch(EditorActions.setFieldVisibility({ field, visible })) + } } diff --git a/libs/feature/editor/src/lib/+state/editor.reducer.ts b/libs/feature/editor/src/lib/+state/editor.reducer.ts index d2b4e96a5c..29848bb9cd 100644 --- a/libs/feature/editor/src/lib/+state/editor.reducer.ts +++ b/libs/feature/editor/src/lib/+state/editor.reducer.ts @@ -83,6 +83,27 @@ const reducer = createReducer( on(EditorActions.setCurrentPage, (state, { page }) => ({ ...state, currentPage: page, + })), + on(EditorActions.setFieldVisibility, (state, { field, visible }) => ({ + ...state, + editorConfig: { + ...state.editorConfig, + pages: state.editorConfig.pages.map((page) => ({ + ...page, + sections: page.sections.map((section) => ({ + ...section, + fields: section.fields.map((f) => { + if (f.model === field.model) { + return { + ...f, + hidden: !visible, + } + } + return f + }), + })), + })), + }, })) ) diff --git a/libs/feature/editor/src/lib/components/constraint-card/constraint-card.component.css b/libs/feature/editor/src/lib/components/constraint-card/constraint-card.component.css new file mode 100644 index 0000000000..295ae848bf --- /dev/null +++ b/libs/feature/editor/src/lib/components/constraint-card/constraint-card.component.css @@ -0,0 +1,4 @@ +:host { + --gn-ui-button-padding: 8px 8px; + --gn-ui-button-rounded: 8px; +} diff --git a/libs/feature/editor/src/lib/components/constraint-card/constraint-card.component.html b/libs/feature/editor/src/lib/components/constraint-card/constraint-card.component.html new file mode 100644 index 0000000000..c789c63c97 --- /dev/null +++ b/libs/feature/editor/src/lib/components/constraint-card/constraint-card.component.html @@ -0,0 +1,28 @@ +
+ + +
+ +   + input.image.displayUrlInput + +
+ + +
diff --git a/libs/feature/editor/src/lib/components/constraint-card/constraint-card.component.spec.ts b/libs/feature/editor/src/lib/components/constraint-card/constraint-card.component.spec.ts new file mode 100644 index 0000000000..ef6377e2a7 --- /dev/null +++ b/libs/feature/editor/src/lib/components/constraint-card/constraint-card.component.spec.ts @@ -0,0 +1,44 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { ConstraintCardComponent } from './constraint-card.component' +import { importProvidersFrom } from '@angular/core' +import { TranslateModule } from '@ngx-translate/core' + +describe('ConstraintCardComponent', () => { + let component: ConstraintCardComponent + let fixture: ComponentFixture + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ConstraintCardComponent], + providers: [importProvidersFrom(TranslateModule.forRoot())], + }) + fixture = TestBed.createComponent(ConstraintCardComponent) + component = fixture.componentInstance + component.constraint = { + text: 'This is a multiline and **formatted** constraint text.', + } + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) + + describe('showUrlInput', () => { + it('returns true if url is not nullish', () => { + component.constraint = { + text: 'abcd', + url: new URL('https://example.com/my-license.pdf'), + } + expect(component.showUrl).toBe(true) + }) + it('returns true if showUrl button was clicked once', () => { + component.showUrl = true + expect(component.showUrl).toBe(true) + }) + it('returns false otherwise', () => { + expect(component.showUrl).toBe(false) + }) + }) +}) diff --git a/libs/feature/editor/src/lib/components/constraint-card/constraint-card.component.stories.ts b/libs/feature/editor/src/lib/components/constraint-card/constraint-card.component.stories.ts new file mode 100644 index 0000000000..8694065cd5 --- /dev/null +++ b/libs/feature/editor/src/lib/components/constraint-card/constraint-card.component.stories.ts @@ -0,0 +1,68 @@ +import { + applicationConfig, + Meta, + moduleMetadata, + StoryObj, +} from '@storybook/angular' +import { ConstraintCardComponent } from './constraint-card.component' +import { BrowserAnimationsModule } from '@angular/platform-browser/animations' +import { importProvidersFrom } from '@angular/core' +import { CommonModule } from '@angular/common' +import { TranslateModule } from '@ngx-translate/core' +import { UtilI18nModule } from '@geonetwork-ui/util/i18n' + +export default { + title: 'Elements/ConstraintCardComponent', + component: ConstraintCardComponent, + decorators: [ + moduleMetadata({ + imports: [CommonModule, ConstraintCardComponent], + }), + applicationConfig({ + providers: [ + importProvidersFrom(BrowserAnimationsModule), + importProvidersFrom(UtilI18nModule), + importProvidersFrom(TranslateModule.forRoot()), + ], + }), + ], + argTypes: { + constraintChange: { + action: 'constraintChange', + }, + }, +} as Meta + +type ConstraintCardComponentProps = { + label: string + constraint: { + text: string + url: string + } +} + +export const WithUrl: StoryObj = { + args: { + label: 'My constraint', + constraint: { + text: `This is a multiline and **formatted** constraint text. + +## introduction + +It covers: +- things +- other things + +_and it's great_.`, + url: new URL('https://example.com/my-license.pdf'), + }, + }, +} +export const WithoutUrl: StoryObj = { + args: { + label: 'My constraint', + constraint: { + text: `This is a multiline and **formatted** constraint text.`, + }, + }, +} diff --git a/libs/feature/editor/src/lib/components/constraint-card/constraint-card.component.ts b/libs/feature/editor/src/lib/components/constraint-card/constraint-card.component.ts new file mode 100644 index 0000000000..5340646b7b --- /dev/null +++ b/libs/feature/editor/src/lib/components/constraint-card/constraint-card.component.ts @@ -0,0 +1,71 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, +} from '@angular/core' +import { CommonModule } from '@angular/common' +import { MarkdownEditorComponent } from '@geonetwork-ui/ui/elements' +import { FormFieldRichComponent } from '../record-form/form-field/form-field-rich/form-field-rich.component' +import { ButtonComponent, UrlInputComponent } from '@geonetwork-ui/ui/inputs' +import { MatIconModule } from '@angular/material/icon' +import { TranslateModule } from '@ngx-translate/core' +import { Constraint } from '@geonetwork-ui/common/domain/model/record' +import { + NgIconComponent, + provideIcons, + provideNgIconsConfig, +} from '@ng-icons/core' +import { iconoirPlus } from '@ng-icons/iconoir' +import { marker } from '@biesbjerg/ngx-translate-extract-marker' + +@Component({ + selector: 'gn-ui-constraint-card', + standalone: true, + imports: [ + CommonModule, + MarkdownEditorComponent, + FormFieldRichComponent, + UrlInputComponent, + ButtonComponent, + MatIconModule, + TranslateModule, + NgIconComponent, + ], + providers: [ + provideIcons({ iconoirPlus }), + provideNgIconsConfig({ + size: '1.5rem', + }), + ], + templateUrl: './constraint-card.component.html', + styleUrls: ['./constraint-card.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConstraintCardComponent { + @Input() label: string + constraint_: Constraint + @Input() set constraint(v: Constraint) { + this.constraint_ = v + this.showUrl = this.showUrl || !!v.url + } + @Output() constraintChange = new EventEmitter() + + hint = marker('editor.record.form.constraint.markdown.placeholder') + showUrl = false + + handleConstraintTextChange(text: string) { + this.constraintChange.emit({ + ...this.constraint_, + text, + }) + } + + handleURLChange(url: string | null) { + this.constraintChange.emit({ + text: this.constraint_.text, + ...(url && { url: new URL(url) }), + }) + } +} diff --git a/libs/feature/editor/src/lib/components/import-record/import-record.component.html b/libs/feature/editor/src/lib/components/import-record/import-record.component.html index b4c0fb2ec0..57c694e6ce 100644 --- a/libs/feature/editor/src/lib/components/import-record/import-record.component.html +++ b/libs/feature/editor/src/lib/components/import-record/import-record.component.html @@ -38,7 +38,7 @@ {{ externalImportBackLabel }} diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/constraints.utils.spec.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/constraints.utils.spec.ts new file mode 100644 index 0000000000..b43b5f794d --- /dev/null +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/constraints.utils.spec.ts @@ -0,0 +1,103 @@ +import { + matchesNoApplicableConstraint, + matchesNoKnownConstraint, + NOT_APPLICABLE_CONSTRAINT, + NOT_KNOWN_CONSTRAINT, +} from './constraints.utils' + +describe('constraints utils', () => { + describe('matchesNoApplicableConstraint', () => { + it('matches based on url', () => { + expect( + matchesNoApplicableConstraint({ + text: 'hello world', + url: NOT_APPLICABLE_CONSTRAINT.url, + }) + ).toBe(true) + }) + it('matches based on text (1)', () => { + expect( + matchesNoApplicableConstraint({ text: ' No conditions apply ' }) + ).toBe(true) + }) + it('matches based on text (2)', () => { + expect( + matchesNoApplicableConstraint({ + text: ' NO conditions apply to access and use ', + }) + ).toBe(true) + }) + it('matches based on text (FR)', () => { + expect( + matchesNoApplicableConstraint({ + text: "aucune condition ne s'applique ", + }) + ).toBe(true) + }) + it('matches based on text translation', () => { + expect( + matchesNoApplicableConstraint({ + text: ' bonjour monde ', + translations: { + text: { en: 'no conditions apply', de: 'hallo welt' }, + }, + }) + ).toBe(true) + }) + it('returns false otherwise', () => { + expect( + matchesNoApplicableConstraint({ + text: ' bonjour monde ', + translations: { + text: { en: 'hello world', de: 'hallo welt' }, + }, + url: new URL('https://some.licence.org/abc.pdf'), + }) + ).toBe(false) + }) + }) + + describe('matchesNoKnownConstraint', () => { + it('matches based on url', () => { + expect( + matchesNoKnownConstraint({ + text: 'hello world', + url: NOT_KNOWN_CONSTRAINT.url, + }) + ).toBe(true) + }) + it('matches based on text', () => { + expect(matchesNoKnownConstraint({ text: ' Conditions unknown ' })).toBe( + true + ) + }) + it('matches based on text (FR)', () => { + expect( + matchesNoKnownConstraint({ + text: 'CONDITIONS inconnues ', + }) + ).toBe(true) + }) + it('matches based on text translation', () => { + expect( + matchesNoKnownConstraint({ + text: ' bonjour monde ', + translations: { + text: { en: 'Conditions unknown', de: 'hallo welt' }, + }, + }) + ).toBe(true) + }) + it('returns false otherwise', () => { + expect( + matchesNoKnownConstraint({ + text: ' bonjour monde ', + translations: { + text: { en: 'hello world', de: 'hallo welt' }, + }, + url: new URL('https://some.licence.org/abc.pdf'), + }) + ).toBe(false) + }) + }) +}) diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/constraints.utils.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/constraints.utils.ts new file mode 100644 index 0000000000..932977abc0 --- /dev/null +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/constraints.utils.ts @@ -0,0 +1,42 @@ +import { Constraint } from '@geonetwork-ui/common/domain/model/record' + +export const NOT_APPLICABLE_CONSTRAINT: Constraint = { + text: 'No conditions apply to access and use', + url: new URL( + 'http://inspire.ec.europa.eu/metadata-codelist/ConditionsApplyingToAccessAndUse/noConditionsApply' + ), +} +export const NOT_KNOWN_CONSTRAINT: Constraint = { + text: 'Conditions unknown', + url: new URL( + 'http://inspire.ec.europa.eu/metadata-codelist/ConditionsApplyingToAccessAndUse/conditionsUnknown' + ), +} + +export function matchesNoApplicableConstraint(constraint: Constraint): boolean { + if (constraint.url?.toString() === NOT_APPLICABLE_CONSTRAINT.url.toString()) { + return true + } + const match = (text: string) => { + if (!text) return false + const trimmed = text.toLowerCase().trim() + return ( + trimmed == 'no conditions apply' || + trimmed == 'no conditions apply to access and use' || + trimmed == "aucune condition ne s'applique" + ) + } + return match(constraint.text) || match(constraint.translations?.text?.en) +} + +export function matchesNoKnownConstraint(constraint: Constraint): boolean { + if (constraint.url?.toString() === NOT_KNOWN_CONSTRAINT.url.toString()) { + return true + } + const match = (text: string) => { + if (!text) return false + const trimmed = text.toLowerCase().trim() + return trimmed == 'conditions unknown' || trimmed == 'conditions inconnues' + } + return match(constraint.text) || match(constraint.translations?.text?.en) +} diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/form-field-constraints-shortcuts.component.css b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/form-field-constraints-shortcuts.component.css new file mode 100644 index 0000000000..295ae848bf --- /dev/null +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/form-field-constraints-shortcuts.component.css @@ -0,0 +1,4 @@ +:host { + --gn-ui-button-padding: 8px 8px; + --gn-ui-button-rounded: 8px; +} diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/form-field-constraints-shortcuts.component.html b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/form-field-constraints-shortcuts.component.html new file mode 100644 index 0000000000..4f4ff0aa21 --- /dev/null +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/form-field-constraints-shortcuts.component.html @@ -0,0 +1,34 @@ +
+ + + + +
+ +
+ + + +   + {{ + 'editor.record.form.constraint.' + constraint | translate + }} + + +
diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/form-field-constraints-shortcuts.component.spec.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/form-field-constraints-shortcuts.component.spec.ts new file mode 100644 index 0000000000..b2f16b48db --- /dev/null +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/form-field-constraints-shortcuts.component.spec.ts @@ -0,0 +1,225 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { FormFieldConstraintsShortcutsComponent } from './form-field-constraints-shortcuts.component' +import { MockBuilder, MockProvider } from 'ng-mocks' +import { EditorFacade } from '../../../../+state/editor.facade' +import { importProvidersFrom } from '@angular/core' +import { TranslateModule } from '@ngx-translate/core' +import { BehaviorSubject, firstValueFrom } from 'rxjs' +import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' +import { datasetRecordsFixture } from '@geonetwork-ui/common/fixtures' +import { + NOT_APPLICABLE_CONSTRAINT, + NOT_KNOWN_CONSTRAINT, +} from './constraints.utils' + +describe('FormFieldConstraintsShortcutsComponent', () => { + let component: FormFieldConstraintsShortcutsComponent + let fixture: ComponentFixture + let editorFacade: EditorFacade + let sampleRecord: CatalogRecord + let sampleRecord$: BehaviorSubject + + beforeEach(() => MockBuilder(FormFieldConstraintsShortcutsComponent)) + + beforeEach(() => { + sampleRecord = datasetRecordsFixture()[0] + sampleRecord$ = new BehaviorSubject(sampleRecord) + + TestBed.configureTestingModule({ + providers: [ + MockProvider(EditorFacade, { + record$: sampleRecord$, + }), + importProvidersFrom(TranslateModule.forRoot()), + ], + }) + editorFacade = TestBed.inject(EditorFacade) + fixture = TestBed.createComponent(FormFieldConstraintsShortcutsComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) + + describe('noApplicableConstraints$', () => { + it('should emit true if "no conditions apply" is present', async () => { + sampleRecord$.next({ + ...sampleRecord, + legalConstraints: [{ text: 'no conditions apply' }], + }) + const result = await firstValueFrom(component.noApplicableConstraint$) + expect(result).toBe(true) + }) + + it('should emit false if "no conditions apply" is not present', async () => { + sampleRecord$.next({ + ...sampleRecord, + legalConstraints: [{ text: 'Any other constraint' }], + }) + const result = await firstValueFrom(component.noApplicableConstraint$) + expect(result).toBe(false) + }) + }) + + describe('noKnownConstraints$', () => { + it('should emit true if "unknown conditions" is present', async () => { + sampleRecord$.next({ + ...sampleRecord, + legalConstraints: [{ text: 'conditions unknown' }], + }) + const result = await firstValueFrom(component.noKnownConstraint$) + expect(result).toBe(true) + }) + + it('should emit false if "unknown conditions" is not present', async () => { + sampleRecord$.next({ + ...sampleRecord, + legalConstraints: [{ text: 'any other constraint' }], + }) + const result = await firstValueFrom(component.noKnownConstraint$) + expect(result).toBe(false) + }) + }) + + describe('onToggleChange', () => { + beforeEach(() => { + sampleRecord$.next({ + ...sampleRecord, + legalConstraints: [ + { + text: 'no known', + url: NOT_KNOWN_CONSTRAINT.url, + }, + { + text: 'another constraint', + }, + { + text: 'no applicable', + url: NOT_APPLICABLE_CONSTRAINT.url, + }, + ], + }) + }) + it('should update legal constraints and hide all sections when noApplicableConstraint toggled on', () => { + component.onToggleChange('noApplicableConstraint', true) + expect(editorFacade.updateRecordField).toHaveBeenCalledWith( + 'legalConstraints', + [NOT_APPLICABLE_CONSTRAINT] + ) + expect(editorFacade.setFieldVisibility).toHaveBeenCalledWith( + { model: 'legalConstraints' }, + false + ) + expect(editorFacade.setFieldVisibility).toHaveBeenCalledWith( + { model: 'securityConstraints' }, + false + ) + expect(editorFacade.setFieldVisibility).toHaveBeenCalledWith( + { model: 'otherConstraints' }, + false + ) + }) + it('should update legal constraints and hide all sections when noKnownConstraint toggled on', () => { + jest.spyOn(component, 'hideAllConstraintSections') + component.onToggleChange('noKnownConstraint', true) + expect(editorFacade.updateRecordField).toHaveBeenCalledWith( + 'legalConstraints', + [NOT_KNOWN_CONSTRAINT] + ) + expect(editorFacade.setFieldVisibility).toHaveBeenCalledWith( + { model: 'legalConstraints' }, + false + ) + expect(editorFacade.setFieldVisibility).toHaveBeenCalledWith( + { model: 'securityConstraints' }, + false + ) + expect(editorFacade.setFieldVisibility).toHaveBeenCalledWith( + { model: 'otherConstraints' }, + false + ) + }) + it('should remove all legal constraints matching "no applicable" when toggled off', async () => { + await component.onToggleChange('noApplicableConstraint', false) + expect(editorFacade.updateRecordField).toHaveBeenCalledWith( + 'legalConstraints', + [ + { + text: 'no known', + url: NOT_KNOWN_CONSTRAINT.url, + }, + { + text: 'another constraint', + }, + ] + ) + }) + it('should remove all legal constraints matching "no known" when toggled off', async () => { + await component.onToggleChange('noKnownConstraint', false) + expect(editorFacade.updateRecordField).toHaveBeenCalledWith( + 'legalConstraints', + [ + { + text: 'another constraint', + }, + { + text: 'no applicable', + url: NOT_APPLICABLE_CONSTRAINT.url, + }, + ] + ) + }) + }) + + describe('fields visibility change', () => { + function getLastCallForField(model: string) { + const calls = ( + editorFacade.setFieldVisibility as jest.Mock + ).mock.calls.filter(([field]) => field.model === model) + return calls[calls.length - 1] + } + + describe.each([ + 'legalConstraints', + 'securityConstraints', + 'otherConstraints', + ])('for field %s', (fieldName) => { + it('is visible if not empty at first and no toggles activated', () => { + sampleRecord$.next({ + ...sampleRecord, + [fieldName]: [{ text: 'some constraint' }], + }) + fixture.detectChanges() + expect(getLastCallForField(fieldName)).toEqual([ + { model: fieldName }, + true, + ]) + }) + it('is hidden if not empty at first and NOT_APPLICABLE_CONSTRAINT present', () => { + sampleRecord$.next({ + ...sampleRecord, + [fieldName]: [{ text: 'some constraint' }], + legalConstraints: [NOT_APPLICABLE_CONSTRAINT], + }) + fixture.detectChanges() + expect(getLastCallForField(fieldName)).toEqual([ + { model: fieldName }, + false, + ]) + }) + it('is hidden if field is empty', () => { + sampleRecord$.next({ + ...sampleRecord, + [fieldName]: [], + }) + fixture.detectChanges() + expect(getLastCallForField(fieldName)).toEqual([ + { model: fieldName }, + false, + ]) + }) + }) + }) +}) diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/form-field-constraints-shortcuts.component.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/form-field-constraints-shortcuts.component.ts new file mode 100644 index 0000000000..769271538c --- /dev/null +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/form-field-constraints-shortcuts.component.ts @@ -0,0 +1,210 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + OnInit, +} from '@angular/core' +import { CommonModule } from '@angular/common' +import { EditorFacade } from '../../../../+state/editor.facade' +import { ButtonComponent, CheckToggleComponent } from '@geonetwork-ui/ui/inputs' +import { TranslateModule } from '@ngx-translate/core' +import { + combineLatest, + distinctUntilChanged, + firstValueFrom, + map, + Observable, + Subject, + takeUntil, +} from 'rxjs' +import { marker } from '@biesbjerg/ngx-translate-extract-marker' +import { Constraint } from '@geonetwork-ui/common/domain/model/record' +import { + matchesNoApplicableConstraint, + matchesNoKnownConstraint, + NOT_APPLICABLE_CONSTRAINT, + NOT_KNOWN_CONSTRAINT, +} from './constraints.utils' +import { + NgIconComponent, + provideIcons, + provideNgIconsConfig, +} from '@ng-icons/core' +import { iconoirPlus } from '@ng-icons/iconoir' + +marker('editor.record.form.constraint.legalConstraints') +marker('editor.record.form.constraint.securityConstraints') +marker('editor.record.form.constraint.otherConstraints') + +export type ConstraintChoice = + | 'legalConstraints' + | 'securityConstraints' + | 'otherConstraints' + +/** + * This component offers two toggles to easily define common constraints (no applicable constraint + * and no known constraint) and shows and hides the various constraints fields accordingly using + * the facade + */ +@Component({ + selector: 'gn-ui-form-field-constraints-shortcuts', + standalone: true, + imports: [ + CommonModule, + CheckToggleComponent, + ButtonComponent, + TranslateModule, + NgIconComponent, + ], + providers: [ + provideIcons({ iconoirPlus }), + provideNgIconsConfig({ + size: '1.5rem', + }), + ], + templateUrl: './form-field-constraints-shortcuts.component.html', + styleUrls: ['./form-field-constraints-shortcuts.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FormFieldConstraintsShortcutsComponent + implements OnInit, OnDestroy +{ + legalConstraints$ = this.editorFacade.record$.pipe( + map((record) => record?.legalConstraints ?? []) + ) + securityConstraints$ = this.editorFacade.record$.pipe( + map((record) => record?.securityConstraints ?? []) + ) + otherConstraints$ = this.editorFacade.record$.pipe( + map((record) => record?.otherConstraints ?? []) + ) + + noApplicableConstraint$: Observable = this.legalConstraints$.pipe( + map((constraints) => + constraints.some((constraint) => + matchesNoApplicableConstraint(constraint) + ) + ) + ) + noKnownConstraint$: Observable = this.legalConstraints$.pipe( + map((constraints) => + constraints.some((constraint) => matchesNoKnownConstraint(constraint)) + ) + ) + anyToggleActivated$ = combineLatest([ + this.noApplicableConstraint$, + this.noKnownConstraint$, + ]).pipe( + map( + ([noApplicableConstraint, noKnownConstraint]) => + noApplicableConstraint || noKnownConstraint + ) + ) + + constraintButtonChoices: ConstraintChoice[] = [ + 'legalConstraints', + 'securityConstraints', + 'otherConstraints', + ] + + onDestroy$ = new Subject() + + constructor(private editorFacade: EditorFacade) {} + + ngOnInit(): void { + // hide all constraints if any toggle is activated + this.anyToggleActivated$ + .pipe(takeUntil(this.onDestroy$), distinctUntilChanged()) + .subscribe((anyToggleActivated) => { + if (anyToggleActivated) { + this.hideAllConstraintSections() + } + }) + + // also hide constraints which are empty arrays + const hideEmptyConstraints = ( + constraints$: Observable, + model: ConstraintChoice + ) => { + const isConstraintNotEmpty$ = constraints$.pipe( + takeUntil(this.onDestroy$), + map((c) => c.length > 0), + distinctUntilChanged() + ) + combineLatest([ + isConstraintNotEmpty$, + this.anyToggleActivated$, + ]).subscribe(([isNotEmpty, anyToggleActivated]) => { + const visible = isNotEmpty && !anyToggleActivated + this.editorFacade.setFieldVisibility({ model }, visible) + }) + } + hideEmptyConstraints(this.legalConstraints$, 'legalConstraints') + hideEmptyConstraints(this.securityConstraints$, 'securityConstraints') + hideEmptyConstraints(this.otherConstraints$, 'otherConstraints') + } + + ngOnDestroy() { + this.onDestroy$.next() + } + + hideAllConstraintSections() { + this.editorFacade.setFieldVisibility({ model: 'legalConstraints' }, false) + this.editorFacade.setFieldVisibility( + { model: 'securityConstraints' }, + false + ) + this.editorFacade.setFieldVisibility({ model: 'otherConstraints' }, false) + } + + async onToggleChange( + toggleName: 'noApplicableConstraint' | 'noKnownConstraint', + value: boolean + ) { + if (value) { + const presetConstraint = + toggleName === 'noApplicableConstraint' + ? NOT_APPLICABLE_CONSTRAINT + : NOT_KNOWN_CONSTRAINT + this.editorFacade.updateRecordField('legalConstraints', [ + presetConstraint, + ]) + this.hideAllConstraintSections() + } else { + const matcher = + toggleName === 'noApplicableConstraint' + ? matchesNoApplicableConstraint + : matchesNoKnownConstraint + // if the toggle is turned off, remove all matching constraints + const constraints = await firstValueFrom(this.legalConstraints$) + this.editorFacade.updateRecordField( + 'legalConstraints', + constraints.filter((c) => !matcher(c)) + ) + } + } + + isConstraintButtonDisabled$( + constraintSection: ConstraintChoice + ): Observable { + switch (constraintSection) { + case 'legalConstraints': + return this.legalConstraints$.pipe( + map((constraints) => constraints.length > 0) + ) + case 'securityConstraints': + return this.securityConstraints$.pipe( + map((constraints) => constraints.length > 0) + ) + case 'otherConstraints': + return this.otherConstraints$.pipe( + map((constraints) => constraints.length > 0) + ) + } + } + + addConstraintSectionToDisplay(constraintSection: ConstraintChoice) { + this.editorFacade.updateRecordField(constraintSection, [{ text: '' }]) + this.editorFacade.setFieldVisibility({ model: constraintSection }, true) + } +} diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints/form-field-constraints.component.css b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints/form-field-constraints.component.css new file mode 100644 index 0000000000..295ae848bf --- /dev/null +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints/form-field-constraints.component.css @@ -0,0 +1,4 @@ +:host { + --gn-ui-button-padding: 8px 8px; + --gn-ui-button-rounded: 8px; +} diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints/form-field-constraints.component.html b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints/form-field-constraints.component.html new file mode 100644 index 0000000000..9dc0786b78 --- /dev/null +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints/form-field-constraints.component.html @@ -0,0 +1,24 @@ +
+ + {{ constraintsHeader | translate }} + + +  {{ additionalConstraintsButtonLabel | translate }} + + + + + +
diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints/form-field-constraints.component.spec.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints/form-field-constraints.component.spec.ts new file mode 100644 index 0000000000..a92a9096e6 --- /dev/null +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints/form-field-constraints.component.spec.ts @@ -0,0 +1,98 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { FormFieldConstraintsComponent } from './form-field-constraints.component' +import { MockBuilder, MockInstance, MockProvider } from 'ng-mocks' +import { EditorFacade } from '../../../../+state/editor.facade' +import { BehaviorSubject, of } from 'rxjs' +import { datasetRecordsFixture } from '@geonetwork-ui/common/fixtures' +import { importProvidersFrom } from '@angular/core' +import { TranslateModule } from '@ngx-translate/core' + +const mockLegalConstraints = [...datasetRecordsFixture()[0].legalConstraints] + +const mockConstraints = new BehaviorSubject([ + { + legalConstraints: mockLegalConstraints, + securityConstraints: [], + otherConstraints: [], + }, +]) + +describe('FormFieldConstraintsComponent', () => { + MockInstance.scope() + let component: FormFieldConstraintsComponent + let fixture: ComponentFixture + const constraintType = 'legalConstraints' + + beforeEach(() => { + return MockBuilder(FormFieldConstraintsComponent) + }) + + beforeEach(() => + MockInstance(EditorFacade, 'record$', jest.fn(), 'get').mockReturnValue( + of(mockConstraints) + ) + ) + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [FormFieldConstraintsComponent], + providers: [ + MockProvider(EditorFacade), + importProvidersFrom(TranslateModule.forRoot()), + ], + }) + fixture = TestBed.createComponent(FormFieldConstraintsComponent) + component = fixture.componentInstance + component.label = 'Constraints' + component.value = datasetRecordsFixture()[0].legalConstraints + component.constraintType = constraintType + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) + + it('should use the correct translation keys', () => { + expect(component.additionalConstraintsButtonLabel).toEqual( + `editor.record.form.constraint.add.${constraintType}` + ) + expect(component.constraintsHeader).toEqual( + `editor.record.form.constraint.header.${constraintType}` + ) + }) + + it('#handleConstraintChange should emit the new value', () => { + jest.spyOn(component.valueChange, 'emit') + const newConstraint = { text: 'aaa', url: new URL('http://example.com') } + component.handleConstraintChange(newConstraint, 0) + expect(component.valueChange.emit).toHaveBeenCalledWith([ + newConstraint, + mockLegalConstraints[1], + ]) + }) + + it('#handleConstraintsOrderChange should emit the new value', () => { + jest.spyOn(component.valueChange, 'emit') + const newConstraints = [...datasetRecordsFixture()[0].legalConstraints] + newConstraints.push({ text: 'New constraint' }) + + component.handleConstraintsOrderChange(newConstraints) + + fixture.detectChanges() + expect(component.valueChange.emit).toHaveBeenCalledWith(newConstraints) + }) + + it('#addConstraintSectionToDisplay should add a new constraint', () => { + jest.spyOn(component.valueChange, 'emit') + const initialConstraints = [...component.value] + component.addConstraintSectionToDisplay() + + fixture.detectChanges() + expect(component.valueChange.emit).toHaveBeenCalledWith([ + ...initialConstraints, + { text: '' }, + ]) + }) +}) diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints/form-field-constraints.component.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints/form-field-constraints.component.ts new file mode 100644 index 0000000000..cb19d05bfe --- /dev/null +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints/form-field-constraints.component.ts @@ -0,0 +1,85 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core' +import { CommonModule } from '@angular/common' +import { SortableListComponent } from '@geonetwork-ui/ui/layout' +import { ConstraintCardComponent } from '../../../constraint-card/constraint-card.component' +import { + CatalogRecordKeys, + Constraint, +} from '@geonetwork-ui/common/domain/model/record' +import { ButtonComponent, UiInputsModule } from '@geonetwork-ui/ui/inputs' +import { marker } from '@biesbjerg/ngx-translate-extract-marker' +import { TranslateModule } from '@ngx-translate/core' +import { + NgIconComponent, + provideIcons, + provideNgIconsConfig, +} from '@ng-icons/core' +import { iconoirPlus } from '@ng-icons/iconoir' + +marker('editor.record.form.constraint.add.legalConstraints') +marker('editor.record.form.constraint.add.securityConstraints') +marker('editor.record.form.constraint.add.otherConstraints') +marker('editor.record.form.constraint.header.legalConstraints') +marker('editor.record.form.constraint.header.securityConstraints') +marker('editor.record.form.constraint.header.otherConstraints') + +@Component({ + selector: 'gn-ui-form-field-constraints', + imports: [ + CommonModule, + SortableListComponent, + ConstraintCardComponent, + UiInputsModule, + ButtonComponent, + TranslateModule, + NgIconComponent, + ], + providers: [ + provideIcons({ iconoirPlus }), + provideNgIconsConfig({ + size: '1.5rem', + }), + ], + templateUrl: './form-field-constraints.component.html', + styleUrls: ['./form-field-constraints.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, +}) +export class FormFieldConstraintsComponent implements OnInit { + @Input() label: string + @Input() value: Constraint[] + @Input() constraintType: CatalogRecordKeys + @Output() valueChange = new EventEmitter() + + constraintsHeader = '' + additionalConstraintsButtonLabel = '' + + ngOnInit() { + this.additionalConstraintsButtonLabel = `editor.record.form.constraint.add.${this.constraintType}` + this.constraintsHeader = `editor.record.form.constraint.header.${this.constraintType}` + } + + handleConstraintChange(constraint: Constraint, index: number) { + const newValue = [...this.value] + newValue[index] = constraint + this.valueChange.emit(newValue) + } + + handleConstraintsOrderChange(constraints: Constraint[]) { + const updatedConstraints = [...constraints] + this.valueChange.emit(updatedConstraints) + } + + addConstraintSectionToDisplay() { + const updatedConstraints = [...this.value] + updatedConstraints.push({ text: '' }) // url? + this.valueChange.emit(updatedConstraints) + } +} diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-license/form-field-license.component.html b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-license/form-field-license.component.html index 8467e043d1..833e5f015e 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-license/form-field-license.component.html +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-license/form-field-license.component.html @@ -1,8 +1,10 @@ - - +
+ + +
diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-license/form-field-license.component.spec.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-license/form-field-license.component.spec.ts index e6f262a0e9..9b808cf4c4 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-license/form-field-license.component.spec.ts +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-license/form-field-license.component.spec.ts @@ -15,7 +15,7 @@ describe('FormFieldLicenseComponent', () => { fixture = TestBed.createComponent(FormFieldLicenseComponent) component = fixture.componentInstance component.label = 'License' // TODO: translate - component.value = [{ text: 'cc-by' }] + component.recordConstraints = [{ text: 'cc-by' }] fixture.detectChanges() }) @@ -24,13 +24,13 @@ describe('FormFieldLicenseComponent', () => { }) describe('#selected', () => { it('should get the selected value', () => { - expect(component.selected).toBe('cc-by') + expect(component.selectedLicence).toBe('cc-by') }) }) describe('#onSelectValue', () => { it('should emit the selected value', () => { - const spy = jest.spyOn(component.valueChange, 'emit') - component.onSelectValue('cc-by-sa') + const spy = jest.spyOn(component.recordConstraintsChange, 'emit') + component.handleLicenceSelection('cc-by-sa') expect(spy).toHaveBeenCalledWith([{ text: 'cc-by-sa' }]) }) }) diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-license/form-field-license.component.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-license/form-field-license.component.ts index 3f00272845..99766f3f01 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-license/form-field-license.component.ts +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-license/form-field-license.component.ts @@ -3,11 +3,18 @@ import { Component, EventEmitter, Input, + OnInit, Output, } from '@angular/core' import { marker } from '@biesbjerg/ngx-translate-extract-marker' import { Constraint } from '@geonetwork-ui/common/domain/model/record' import { DropdownSelectorComponent } from '@geonetwork-ui/ui/inputs' +import { AVAILABLE_LICENSES } from '../../../../fields.config' + +type Licence = { + label: string + value: string +} @Component({ selector: 'gn-ui-form-field-license', @@ -17,56 +24,37 @@ import { DropdownSelectorComponent } from '@geonetwork-ui/ui/inputs' standalone: true, imports: [DropdownSelectorComponent], }) -export class FormFieldLicenseComponent { +export class FormFieldLicenseComponent implements OnInit { @Input() label: string - @Input() value: Array + @Input() recordConstraints: Constraint[] = [] + @Output() recordConstraintsChange: EventEmitter = + new EventEmitter() - @Output() valueChange: EventEmitter> = new EventEmitter() + selectedLicence: string - get selected() { - return this.value[0]?.text + ngOnInit(): void { + // get the licence from the record constraints if it is one of the open data licence list + this.selectedLicence = this.recordConstraints.find((constraint) => { + return this.licenceOptions.find((licence) => { + return licence.value === constraint.text + }) + })?.text + // otherwise pre-select the first licence option + if (this.selectedLicence === undefined) { + this.selectedLicence = this.licenceOptions[0].value // cannot select 'etalab' as default as this would toggle the OpenData Toggle + this.recordConstraintsChange.emit([{ text: this.selectedLicence }]) + } } - onSelectValue(value: unknown) { - this.valueChange.emit([{ text: value as string }]) + get licenceOptions(): Licence[] { + return AVAILABLE_LICENSES.map((license) => ({ + label: marker(`editor.record.form.license.${license}`), + value: license, + })) } - choices = [ - { - value: 'cc-by', - label: marker('editor.record.form.license.cc-by'), - }, - { - value: 'cc-by-sa', - label: marker('editor.record.form.license.cc-by-sa'), - }, - { - value: 'cc-zero', - label: marker('editor.record.form.license.cc-zero'), - }, - { - value: 'etalab', - label: marker('editor.record.form.license.etalab'), - }, - { - value: 'etalab-v2', - label: marker('editor.record.form.license.etalab-v2'), - }, - { - value: 'odbl', - label: marker('editor.record.form.license.odbl'), - }, - { - value: 'odc-by', - label: marker('editor.record.form.license.odc-by'), - }, - { - value: 'pddl', - label: marker('editor.record.form.license.pddl'), - }, - { - value: 'unknown', - label: marker('editor.record.form.license.unknown'), - }, - ] + handleLicenceSelection(licenceValue: string) { + this.selectedLicence = licenceValue + this.recordConstraintsChange.emit([{ text: licenceValue }]) + } } diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-online-link-resources/form-field-online-link-resources.component.html b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-online-link-resources/form-field-online-link-resources.component.html index 6bd1486741..7631601886 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-online-link-resources/form-field-online-link-resources.component.html +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-online-link-resources/form-field-online-link-resources.component.html @@ -38,6 +38,7 @@

class="w-full" [disabled]="true" [value]="onlineResource.url" + [showUploadButton]="false" > diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-online-resources/form-field-online-resources.component.html b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-online-resources/form-field-online-resources.component.html index 6d22cfe826..d8c6a7367b 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-online-resources/form-field-online-resources.component.html +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-online-resources/form-field-online-resources.component.html @@ -24,7 +24,7 @@
@@ -66,6 +66,7 @@

class="w-full" [disabled]="true" [value]="onlineResource.url" + [showUploadButton]="false" > diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-open-data/form-field-open-data.component.html b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-open-data/form-field-open-data.component.html index c3779ee58d..6e9934b27e 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-open-data/form-field-open-data.component.html +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-open-data/form-field-open-data.component.html @@ -1,6 +1,6 @@ diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-open-data/form-field-open-data.component.spec.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-open-data/form-field-open-data.component.spec.ts index 7a80d0d2fb..a72eea5c22 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-open-data/form-field-open-data.component.spec.ts +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-open-data/form-field-open-data.component.spec.ts @@ -4,7 +4,7 @@ import { FormFieldOpenDataComponent } from './form-field-open-data.component' jest.mock('./../../../../fields.config', () => { return { - OPEN_DATA_LICENSES: ['CC-BY'], + OPEN_DATA_LICENSE: 'CC-BY', } }) diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-open-data/form-field-open-data.component.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-open-data/form-field-open-data.component.ts index af9b6cad40..9d6e109150 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-open-data/form-field-open-data.component.ts +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-open-data/form-field-open-data.component.ts @@ -9,7 +9,7 @@ import { import { Constraint } from '@geonetwork-ui/common/domain/model/record' import { CheckToggleComponent } from '@geonetwork-ui/ui/inputs' import { TranslateModule } from '@ngx-translate/core' -import { OPEN_DATA_LICENSES } from './../../../../fields.config' +import { OPEN_DATA_LICENSE } from './../../../../fields.config' @Component({ selector: 'gn-ui-form-field-open-data', @@ -24,25 +24,28 @@ export class FormFieldOpenDataComponent implements OnChanges { @Output() valueChange = new EventEmitter>() @Output() openDataChange = new EventEmitter() - openData = false - - get config() { - return OPEN_DATA_LICENSES + get isOpenDataLicense(): boolean { + return !!this.value.find( + (constraint) => constraint.text === OPEN_DATA_LICENSE + ) } ngOnChanges() { - if (this.value && this.value.length > 0) { - this.openData = this.config.includes(this.value[0].text) - } else { - this.openData = false - } - this.openDataChange.emit(this.openData) + this.openDataChange.emit(this.isOpenDataLicense) } onOpenDataToggled(openData: boolean) { this.openDataChange.emit(openData) if (openData) { - this.valueChange.emit([{ text: this.config[0] }]) + this.valueChange.emit([ + { + text: OPEN_DATA_LICENSE, + }, + ]) + } else { + this.valueChange.emit( + this.value.filter((constraint) => constraint.text !== OPEN_DATA_LICENSE) + ) } } } diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.html b/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.html index ffa4569b7f..60344f8c67 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.html +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.html @@ -3,7 +3,7 @@ -->
@@ -92,10 +92,29 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.ts index f49da4237b..79167142ed 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.ts +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.ts @@ -27,7 +27,11 @@ import { FormFieldLicenseComponent, FormFieldTemporalExtentsComponent, } from '.' -import { FieldModelSpecifier, FormFieldConfig } from '../../../models' +import { + FieldModelSpecifier, + FormFieldComponentName, + FormFieldConfig, +} from '../../../models' import { FormFieldArrayComponent } from './form-field-array/form-field-array.component' import { FormFieldContactsForResourceComponent } from './form-field-contacts-for-resource/form-field-contacts-for-resource.component' import { FormFieldContactsComponent } from './form-field-contacts/form-field-contacts.component' @@ -42,6 +46,8 @@ import { FormFieldRichComponent } from './form-field-rich/form-field-rich.compon import { FormFieldSimpleComponent } from './form-field-simple/form-field-simple.component' import { FormFieldSpatialExtentComponent } from './form-field-spatial-extent/form-field-spatial-extent.component' import { FormFieldUpdateFrequencyComponent } from './form-field-update-frequency/form-field-update-frequency.component' +import { FormFieldConstraintsShortcutsComponent } from './form-field-constraints-shortcuts/form-field-constraints-shortcuts.component' +import { FormFieldConstraintsComponent } from './form-field-constraints/form-field-constraints.component' @Component({ selector: 'gn-ui-form-field', @@ -72,27 +78,41 @@ import { FormFieldUpdateFrequencyComponent } from './form-field-update-frequency FormFieldOnlineResourcesComponent, FormFieldOnlineLinkResourcesComponent, FormFieldContactsComponent, + FormFieldConstraintsComponent, + FormFieldConstraintsShortcutsComponent, ], }) export class FormFieldComponent { @Input() uniqueIdentifier: string @Input() model: CatalogRecordKeys @Input() modelSpecifier: FieldModelSpecifier + @Input() componentName: FormFieldComponentName + @Input() config: FormFieldConfig @Input() value: unknown @Output() valueChange: EventEmitter = new EventEmitter() @ViewChild('titleInput') titleInput: ElementRef + isOpenData = false - isHidden = false + toggleIsOpenData(event: boolean) { + this.isOpenData = event + } focusTitleInput() { this.titleInput.nativeElement.children[0].focus() } get withoutWrapper() { - return this.model === 'title' || this.model === 'abstract' + return ( + this.model === 'title' || + this.model === 'abstract' || + this.model === 'legalConstraints' || + this.model === 'securityConstraints' || + this.model === 'otherConstraints' || + this.componentName === 'form-field-constraints-shortcuts' + ) } get valueAsString() { diff --git a/libs/feature/editor/src/lib/components/record-form/record-form.component.html b/libs/feature/editor/src/lib/components/record-form/record-form.component.html index 84e01238b0..81694a100b 100644 --- a/libs/feature/editor/src/lib/components/record-form/record-form.component.html +++ b/libs/feature/editor/src/lib/components/record-form/record-form.component.html @@ -45,6 +45,7 @@ [modelSpecifier]="field.config.modelSpecifier!" [config]="field.config.formFieldConfig" [value]="field.value" + [componentName]="field.config.componentName" (valueChange)=" handleFieldValueChange(field.config.model!, $event) " diff --git a/libs/feature/editor/src/lib/fields.config.ts b/libs/feature/editor/src/lib/fields.config.ts index e787e34bc0..d64e1eb89f 100644 --- a/libs/feature/editor/src/lib/fields.config.ts +++ b/libs/feature/editor/src/lib/fields.config.ts @@ -26,6 +26,32 @@ export const RECORD_UNIQUE_IDENTIFIER_FIELD: EditorField = { hidden: true, } +export const CONSTRAINTS_SHORTCUTS: EditorField = { + componentName: 'form-field-constraints-shortcuts', + formFieldConfig: { + labelKey: marker('editor.record.form.field.constraintsShortcuts'), + }, +} + +export const LEGAL_CONSTRAINTS_FIELD: EditorField = { + model: 'legalConstraints', + formFieldConfig: { + labelKey: marker('editor.record.form.field.legalConstraints'), + }, +} +export const SECURITY_CONSTRAINTS_FIELD: EditorField = { + model: 'securityConstraints', + formFieldConfig: { + labelKey: marker('editor.record.form.field.securityConstraints'), + }, +} +export const OTHER_CONSTRAINTS_FIELD: EditorField = { + model: 'otherConstraints', + formFieldConfig: { + labelKey: marker('editor.record.form.field.otherConstraints'), + }, +} + export const RECORD_LICENSE_FIELD: EditorField = { model: 'licenses', formFieldConfig: { @@ -191,7 +217,13 @@ export const CLASSIFICATION_SECTION: EditorSection = { export const USE_AND_ACCESS_CONDITIONS_SECTION: EditorSection = { labelKey: marker('editor.record.form.section.useAndAccessConditions.label'), hidden: false, - fields: [RECORD_LICENSE_FIELD], + fields: [ + RECORD_LICENSE_FIELD, + CONSTRAINTS_SHORTCUTS, + LEGAL_CONSTRAINTS_FIELD, + SECURITY_CONSTRAINTS_FIELD, + OTHER_CONSTRAINTS_FIELD, + ], } export const DATA_MANAGERS_SECTION: EditorSection = { @@ -240,14 +272,30 @@ export const DEFAULT_CONFIGURATION: EditorConfig = { *************** LICENSES ************** ************************************************************ */ -export const OPEN_DATA_LICENSES: string[] = [ +export const AVAILABLE_LICENSES: string[] = [ + 'cc-by', + 'cc-by-sa', + 'cc-zero', 'etalab', 'etalab-v2', 'odbl', 'odc-by', 'pddl', + 'unknown', ] +export const OPEN_DATA_LICENSE = 'etalab' + +marker('editor.record.form.license.cc-by') +marker('editor.record.form.license.cc-by-sa') +marker('editor.record.form.license.cc-zero') +marker('editor.record.form.license.etalab') +marker('editor.record.form.license.etalab-v2') +marker('editor.record.form.license.odbl') +marker('editor.record.form.license.odc-by') +marker('editor.record.form.license.pddl') +marker('editor.record.form.license.unknown') + export const MAX_UPLOAD_SIZE_MB = 10 /************************************************************ *************** SPATIAL SCOPE ************ diff --git a/libs/feature/editor/src/lib/models/editor-config.model.ts b/libs/feature/editor/src/lib/models/editor-config.model.ts index 90ba1f2097..d4fcc83afe 100644 --- a/libs/feature/editor/src/lib/models/editor-config.model.ts +++ b/libs/feature/editor/src/lib/models/editor-config.model.ts @@ -23,14 +23,21 @@ export type FieldModelSpecifier = | OnlineLinkResourceSpecifier | DatasetDistributionsSpecifier -export interface EditorField { - // configuration of the form field used as presentation - formFieldConfig: FormFieldConfig +export type FormFieldComponentName = 'form-field-constraints-shortcuts' +export interface EditorFieldIdentification { // name of the target field in the record; will not change the record directly if not defined model?: CatalogRecordKeys modelSpecifier?: FieldModelSpecifier + // if no model is given, a component can be shown instead + componentName?: FormFieldComponentName +} + +export interface EditorField extends EditorFieldIdentification { + // configuration of the form field used as presentation + formFieldConfig: FormFieldConfig + // grid column span; if unspecified, full width will be used gridColumnSpan?: number diff --git a/libs/ui/inputs/src/lib/check-toggle/check-toggle.component.html b/libs/ui/inputs/src/lib/check-toggle/check-toggle.component.html index 02f47bcf0b..66d6a41b88 100644 --- a/libs/ui/inputs/src/lib/check-toggle/check-toggle.component.html +++ b/libs/ui/inputs/src/lib/check-toggle/check-toggle.component.html @@ -1,4 +1,7 @@ -
diff --git a/libs/ui/inputs/src/lib/file-input/file-input.component.ts b/libs/ui/inputs/src/lib/file-input/file-input.component.ts index 88efe3b12f..225b1e1f3b 100644 --- a/libs/ui/inputs/src/lib/file-input/file-input.component.ts +++ b/libs/ui/inputs/src/lib/file-input/file-input.component.ts @@ -89,7 +89,8 @@ export class FileInputComponent { this.handleDropFiles(Array.from((event.target as HTMLInputElement).files)) } - handleUrlChange(url: string) { + handleUrlChange(url: string | null) { + if (!url) return this.urlChange.emit(url) } diff --git a/libs/ui/inputs/src/lib/image-input/image-input.component.html b/libs/ui/inputs/src/lib/image-input/image-input.component.html index c943a01a9d..f17df3c221 100644 --- a/libs/ui/inputs/src/lib/image-input/image-input.component.html +++ b/libs/ui/inputs/src/lib/image-input/image-input.component.html @@ -140,7 +140,7 @@ diff --git a/libs/ui/inputs/src/lib/url-input/url-input.component.html b/libs/ui/inputs/src/lib/url-input/url-input.component.html index 87f9a3879e..5d31d70a4e 100644 --- a/libs/ui/inputs/src/lib/url-input/url-input.component.html +++ b/libs/ui/inputs/src/lib/url-input/url-input.component.html @@ -4,9 +4,9 @@ class="gn-ui-text-input px-[var(--text-padding)]" [ngClass]="extraClass" type="url" - [value]="value" - (input)="handleInput()" - (keydown.enter)="handleChange(input)" + [value]="inputValue" + (input)="handleInput($event)" + (keydown.enter)="handleUpload(input)" [placeholder]="placeholder" [attr.aria-label]="placeholder" [disabled]="disabled" @@ -21,14 +21,11 @@ diff --git a/libs/ui/inputs/src/lib/url-input/url-input.component.spec.ts b/libs/ui/inputs/src/lib/url-input/url-input.component.spec.ts index ba96445896..722e8c6b5b 100644 --- a/libs/ui/inputs/src/lib/url-input/url-input.component.spec.ts +++ b/libs/ui/inputs/src/lib/url-input/url-input.component.spec.ts @@ -34,72 +34,138 @@ describe('UrlInputComponent', () => { inputEl = fixture.nativeElement.querySelector('input') button = fixture.debugElement.query(By.directive(ButtonComponent)) }) - it('emits the value on a button click event', () => { - let emitted - component.valueChange.subscribe((v) => (emitted = v)) - inputEl.value = 'Aaabcd' - button.triggerEventHandler('buttonClick', null) - expect(emitted).toBe('Aaabcd') - }) - it('does not the value on an input event', () => { - let emitted = null - component.valueChange.subscribe((v) => (emitted = v)) - inputEl.value = 'Aaabcd' - inputEl.dispatchEvent(new Event('input')) - expect(emitted).toBe(null) - }) - it('emits the value on a enter press event', () => { - let emitted - component.valueChange.subscribe((v) => (emitted = v)) - inputEl.value = 'Aaabcd' - inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'enter' })) - expect(emitted).toBe('Aaabcd') + + it('shows an empty input if given a nullish url', () => { + component.value = null + fixture.detectChanges() + expect(inputEl.value).toEqual('') }) - it('can emit multiple equal values', () => { - let emittedCount = 0 - component.valueChange.subscribe(() => emittedCount++) - inputEl.value = 'http://bla' - inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'enter' })) - inputEl.value = 'http://bla' - inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'enter' })) - inputEl.value = 'http://bla' - inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'enter' })) - expect(emittedCount).toBe(3) + + describe('uploadClick', () => { + it('emits the value on a button click event', () => { + let emitted + component.uploadClick.subscribe((v) => (emitted = v)) + inputEl.value = 'http://aaa.com/bcd' + button.triggerEventHandler('buttonClick', null) + expect(emitted).toBe('http://aaa.com/bcd') + }) + it('does not emit the value on an input event', () => { + let emitted = null + component.uploadClick.subscribe((v) => (emitted = v)) + inputEl.value = 'http://aaa.com/bcd' + inputEl.dispatchEvent(new Event('input')) + expect(emitted).toBe(null) + }) + it('emits the value on a enter press event', () => { + let emitted + component.uploadClick.subscribe((v) => (emitted = v)) + inputEl.value = 'http://aaa.com/bcd' + inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'enter' })) + expect(emitted).toBe('http://aaa.com/bcd') + }) + it('can emit multiple equal values', () => { + let emittedCount = 0 + component.uploadClick.subscribe(() => emittedCount++) + inputEl.value = 'http://bla' + inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'enter' })) + inputEl.value = 'http://bla' + inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'enter' })) + inputEl.value = 'http://bla' + inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'enter' })) + expect(emittedCount).toBe(3) + }) + it('does not emit empty values', () => { + let emitted = null + component.uploadClick.subscribe((v) => (emitted = v)) + inputEl.value = '' + inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'enter' })) + expect(emitted).toBe(null) + inputEl.value = 'http://bla' + inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'enter' })) + expect(emitted).toBe('http://bla') + }) }) - it('does not emit empty values', () => { - let emitted = null - component.valueChange.subscribe((v) => (emitted = v)) - inputEl.value = '' - inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'enter' })) - expect(emitted).toBe(null) - inputEl.value = 'http://bla' - inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'enter' })) - expect(emitted).toBe('http://bla') + describe('valueChange', () => { + it('does not the value on a button click event', () => { + let emitted = null + component.valueChange.subscribe((v) => (emitted = v)) + inputEl.value = 'http://aaa.com/bcd' + button.triggerEventHandler('buttonClick', null) + expect(emitted).toBe(null) + }) + it('emits the value on an input event', () => { + let emitted = null + component.valueChange.subscribe((v) => (emitted = v)) + inputEl.value = 'http://aaa.com/bcd' + inputEl.dispatchEvent(new Event('input')) + expect(emitted).toBe('http://aaa.com/bcd') + }) + it('does not emit the value if not a valid URL', () => { + let emitted = null + component.valueChange.subscribe((v) => (emitted = v)) + inputEl.value = 'blargz' + inputEl.dispatchEvent(new Event('input')) + expect(emitted).toBe(null) + }) + it('emits null if the input is cleared', () => { + let emitted = undefined + component.valueChange.subscribe((v) => (emitted = v)) + inputEl.value = '' + inputEl.dispatchEvent(new Event('input')) + expect(emitted).toBe(null) + }) }) describe('button', () => { it('is disabled if parent set it as disabled', () => { component.disabled = true inputEl.value = '' + inputEl.dispatchEvent(new Event('input')) fixture.detectChanges() expect(button.componentInstance.disabled).toBe(true) }) it('is disabled if value is empty', () => { inputEl.value = '' + inputEl.dispatchEvent(new Event('input')) fixture.detectChanges() expect(button.componentInstance.disabled).toBe(true) }) - it('is disabled if asking for parseable URL and value is not an URL', () => { - component.urlCanParse = true + it('is disabled if value is not an URL', () => { inputEl.value = 'hello' + inputEl.dispatchEvent(new Event('input')) fixture.detectChanges() expect(button.componentInstance.disabled).toBe(true) }) it('is not disabled otherwise', () => { - inputEl.value = 'hello' + inputEl.value = 'http://hello.org' + inputEl.dispatchEvent(new Event('input')) fixture.detectChanges() expect(button.componentInstance.disabled).toBeFalsy() }) }) + + describe('input value', () => { + it('changes if the component input resolves to a different url', () => { + inputEl.value = 'http://aaa.com/1234' + inputEl.dispatchEvent(new Event('input')) + component.value = 'http://aaa.com/bcd' + fixture.detectChanges() + expect(inputEl.value).toEqual('http://aaa.com/bcd') + }) + it('does not change if the component input is different that the current value but resolves to the same url', () => { + inputEl.value = 'http://aaa.com/1234 5678' + inputEl.dispatchEvent(new Event('input')) + component.value = 'http://aaa.com/1234%205678' + fixture.detectChanges() + expect(inputEl.value).toEqual('http://aaa.com/1234 5678') + }) + it('does not change if both the component input and the current input are not valid urls', () => { + inputEl.value = 'blargz' + inputEl.dispatchEvent(new Event('input')) + component.value = undefined + fixture.detectChanges() + expect(inputEl.value).toEqual('blargz') + }) + }) }) }) diff --git a/libs/ui/inputs/src/lib/url-input/url-input.component.stories.ts b/libs/ui/inputs/src/lib/url-input/url-input.component.stories.ts index 47e8e6be1e..c19b4e7de5 100644 --- a/libs/ui/inputs/src/lib/url-input/url-input.component.stories.ts +++ b/libs/ui/inputs/src/lib/url-input/url-input.component.stories.ts @@ -5,37 +5,54 @@ export default { title: 'Inputs/UrlInputComponent', component: UrlInputComponent, decorators: [], + argTypes: { + valueChange: { + action: 'valueChange', + }, + uploadClick: { + action: 'uploadClick', + }, + }, } as Meta export const Primary: StoryObj = { args: { - value: '', + value: 'http://aaa.org/bbb', + placeholder: 'write a URL here', disabled: false, - }, - argTypes: { - valueChange: { - action: 'valueChange', - }, + showUploadButton: true, }, } export const WithCustomStyle: StoryObj = { args: { - value: '', + value: 'http://aaa.org/bbb', disabled: false, placeholder: 'https://mysite.org/file', }, - argTypes: { - valueChange: { - action: 'valueChange', - }, - }, render: (args) => ({ props: args, template: `
- +
`, }), } + +export const WithoutUploadButton: StoryObj = { + args: { + value: null, + disabled: false, + placeholder: 'https://mysite.org/file', + showUploadButton: false, + }, + render: (args) => ({ + props: args, + template: ` + + `, + }), +} diff --git a/libs/ui/inputs/src/lib/url-input/url-input.component.ts b/libs/ui/inputs/src/lib/url-input/url-input.component.ts index 6a7a4a609b..d725c4bc1b 100644 --- a/libs/ui/inputs/src/lib/url-input/url-input.component.ts +++ b/libs/ui/inputs/src/lib/url-input/url-input.component.ts @@ -1,15 +1,13 @@ import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, + EventEmitter, Input, - OnChanges, Output, - SimpleChanges, } from '@angular/core' import { CommonModule } from '@angular/common' import { ButtonComponent } from '../button/button.component' -import { filter } from 'rxjs/operators' -import { Subject } from 'rxjs' import { NgIconComponent, provideIcons, @@ -29,34 +27,54 @@ import { iconoirArrowUp, iconoirLink } from '@ng-icons/iconoir' size: '1.5em', }), ], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class UrlInputComponent implements OnChanges { - @Input() value = '' +export class UrlInputComponent { + @Input() set value(v: string) { + // we're making sure to only update the input if the URL representation of it has changed; otherwise we keep it identical + // to avoid glitches when starting to write a URL and having some characters added/replaced automatically + if (!v || !this.isValidUrl(v)) return + if ( + this.isValidUrl(this.inputValue) && + new URL(v).toString() === new URL(this.inputValue).toString() + ) + return + this.inputValue = v + this.cd.markForCheck() + } @Input() extraClass = '' @Input() placeholder = 'https://' @Input() disabled: boolean - @Input() urlCanParse?: boolean - rawChange = new Subject() - @Output() valueChange = this.rawChange.pipe(filter((v) => !!v)) + @Input() showUploadButton = true + + /** + * This will emit null if the field is emptied + */ + @Output() valueChange = new EventEmitter() + @Output() uploadClick = new EventEmitter() + + inputValue = '' constructor(private cd: ChangeDetectorRef) {} - ngOnChanges(changes: SimpleChanges): void { - if (changes.value) { - console.log('changes.value', changes.value) + handleInput(event: Event) { + const value = (event.target as HTMLInputElement).value + this.inputValue = value + if (!value || !this.isValidUrl(value)) { + this.valueChange.next(null) + return } - } - - handleInput() { this.cd.markForCheck() + this.valueChange.next(value) } - handleChange(element: HTMLInputElement) { + handleUpload(element: HTMLInputElement) { const value = element.value - this.rawChange.next(value) + if (!value || !this.isValidUrl(value)) return + this.uploadClick.next(value) } - URLcanParse(url: string): boolean { + isValidUrl(url: string): boolean { try { new URL(url) return true diff --git a/libs/ui/layout/src/lib/form-field-wrapper/form-field-wrapper.component.html b/libs/ui/layout/src/lib/form-field-wrapper/form-field-wrapper.component.html index cf21fabf64..1004d61e6d 100644 --- a/libs/ui/layout/src/lib/form-field-wrapper/form-field-wrapper.component.html +++ b/libs/ui/layout/src/lib/form-field-wrapper/form-field-wrapper.component.html @@ -1,6 +1,6 @@
- {{ label }} + {{ label }}