diff --git a/packages/solid-crs-manage/lib/features/object/terms/term-search.component.ts b/packages/solid-crs-manage/lib/features/object/terms/term-search.component.ts index d4beb66c..cb3121a2 100644 --- a/packages/solid-crs-manage/lib/features/object/terms/term-search.component.ts +++ b/packages/solid-crs-manage/lib/features/object/terms/term-search.component.ts @@ -1,15 +1,16 @@ -import { CheckboxChecked, CheckboxUnchecked, Dropdown, Empty, Search, Theme } from '@netwerk-digitaal-erfgoed/solid-crs-theme'; +import { CheckboxChecked, CheckboxUnchecked, Dropdown, Empty, Plus, Search, Theme } from '@netwerk-digitaal-erfgoed/solid-crs-theme'; import { html, unsafeCSS, css, TemplateResult, CSSResult, property, query, internalProperty, PropertyValues, queryAll } from 'lit-element'; import { RxLitElement } from 'rx-lit'; -import { Interpreter } from 'xstate'; +import { interpret, Interpreter } from 'xstate'; import { unsafeSVG } from 'lit-html/directives/unsafe-svg'; -import { Alert, FormActors, FormContext, FormEvents } from '@netwerk-digitaal-erfgoed/solid-crs-components'; +import { Alert, FormActors, FormContext, FormEvents, formMachine, FormSubmittedEvent } from '@netwerk-digitaal-erfgoed/solid-crs-components'; import { ArgumentError, Logger, Term, TermSource, Translator } from '@netwerk-digitaal-erfgoed/solid-crs-core'; import { map } from 'rxjs/operators'; import { from } from 'rxjs'; +import { v4 } from 'uuid'; import { AppEvents } from '../../../app.events'; import { ClickedCancelTermEvent } from '../object.events'; -import { ClickedSubmitEvent, ClickedTermEvent, QueryUpdatedEvent } from './term.events'; +import { ClickedAddEvent, ClickedSubmitEvent, ClickedTermEvent, QueryUpdatedEvent } from './term.events'; import { TermContext, TermStates } from './term.machine'; /** @@ -47,6 +48,18 @@ export class TermSearchComponent extends RxLitElement { @internalProperty() public formActor: Interpreter>; + /** + * The form machine used by the form actor + */ + @internalProperty() + formMachineLocalTerm = formMachine(); + + /** + * The actor responsible for form validation in this component. + */ + @internalProperty() + formActorLocalTerm = interpret(this.formMachineLocalTerm, { devTools: true }); + /** * The field that for which Terms are being edited */ @@ -71,12 +84,6 @@ export class TermSearchComponent extends RxLitElement { @internalProperty() selectedTerms: Term[]; - /** - * A the user's query input - */ - @internalProperty() - query: string; - /** * The user's selected sources */ @@ -94,6 +101,9 @@ export class TermSearchComponent extends RxLitElement { @query('nde-form-element.term input') public searchInput: HTMLInputElement; + @query('nde-form-element input') + public localTermInput: HTMLInputElement; + /** * Hook called on every update after connection to the DOM. */ @@ -137,14 +147,14 @@ export class TermSearchComponent extends RxLitElement { groupSearchResults(searchResults: Term[]): { [key: string]: Term[] } { - return searchResults?.reduce<{ [key: string]: Term[] }>((searchResultsMap, term) => { + return searchResults.length > 0 ? searchResults?.reduce<{ [key: string]: Term[] }>((searchResultsMap, term) => { const key = 'source'; (searchResultsMap[term[key]] = searchResultsMap[term[key]] || []).push(term); return searchResultsMap; - }, {}); + }, {}) : {}; } @@ -181,29 +191,50 @@ export class TermSearchComponent extends RxLitElement { // Create an alert components for each alert. const alerts = this.alerts?.map((alert) => html``); - if (this.formActor) { + this.formActor?.onEvent((event) => { - this.formActor.onEvent((event) => { + if (event.type === FormEvents.FORM_VALIDATED) { - if (event.type === FormEvents.FORM_VALIDATED) { + const selectedSources = Array.from(this.sourceCheckboxes) + .filter((checkbox: HTMLInputElement) => checkbox.checked) + .map((checkbox: HTMLInputElement) => checkbox.id); - const selectedSources = Array.from(this.sourceCheckboxes) - .filter((checkbox: HTMLInputElement) => checkbox.checked) - .map((checkbox: HTMLInputElement) => checkbox.id); + this.actor.send(new QueryUpdatedEvent(this.searchInput?.value, selectedSources)); - this.actor.send(new QueryUpdatedEvent(this.searchInput?.value, selectedSources)); + } - } + }); - }); + this.actor?.onEvent((event) => { - } + if (event instanceof ClickedAddEvent){ + + const uri = `#${v4()}`; + + this.formMachineLocalTerm = formMachine().withContext({ + data: { + name: '', + uri, + }, + original: { + name: '', + uri, + }, + }); + + this.formActorLocalTerm = interpret(this.formMachineLocalTerm, { devTools: true }); + this.formActorLocalTerm.onDone((context) => this.actor.send(new ClickedTermEvent(context.data.data))); + this.formActorLocalTerm.start(); + + } + + }); const loading = !this.actor?.state.matches(TermStates.IDLE); return html` - ${ (loading || !this.sources) && !alerts ? html`` : html`` } + ${ (loading || !this.sources) && !alerts ? html`` : '' } ${ alerts } @@ -245,6 +276,28 @@ export class TermSearchComponent extends RxLitElement { + + ${this.translator.translate('term.add-local-term')} + + + ${ this.actor?.state?.matches(TermStates.CREATING) ? html ` + + + + + +
${this.translator.translate('term.description-placeholder')}
+ +
this.formActorLocalTerm.send(new FormSubmittedEvent())}> + ${unsafeSVG(Plus)} +
+
+ `: ''} ${this.selectedTerms?.length > 0 ? html` @@ -264,14 +317,14 @@ export class TermSearchComponent extends RxLitElement { ${unsafeSVG(CheckboxChecked)}
- ${ term.description?.length > 0 ? html`

${this.translator.translate('term.field.description')}: ${ term.description }

` : html``} - ${ term.alternateName?.length > 0 ? html`

${this.translator.translate('term.field.alternateName')}: ${ term.alternateName.join(', ') }

` : html``} - ${ term.broader?.length > 0 ? html`

${this.translator.translate('term.field.broader')}: ${ term.broader.map((broader) => broader.name).join(', ') }

` : html``} - ${ term.narrower?.length > 0 ? html`

${this.translator.translate('term.field.narrower')}: ${ term.narrower.map((narrower) => narrower.name).join(', ') }

` : html``} - ${ term.hiddenName?.length > 0 ? html`

${this.translator.translate('term.field.hiddenName')}: ${ term.hiddenName }

` : html``} + ${ term.description?.length > 0 ? html`

${this.translator.translate('term.field.description')}: ${ term.description }

` : ''} + ${ term.alternateName?.length > 0 ? html`

${this.translator.translate('term.field.alternateName')}: ${ term.alternateName.join(', ') }

` : ''} + ${ term.broader?.length > 0 ? html`

${this.translator.translate('term.field.broader')}: ${ term.broader.map((broader) => broader.name).join(', ') }

` : ''} + ${ term.narrower?.length > 0 ? html`

${this.translator.translate('term.field.narrower')}: ${ term.narrower.map((narrower) => narrower.name).join(', ') }

` : ''} + ${ term.hiddenName?.length > 0 ? html`

${this.translator.translate('term.field.hiddenName')}: ${ term.hiddenName }

` : ''}
`)} - ` : html``} + ` : ''} ${this.searchResultsMap && Object.keys(this.searchResultsMap).length > 0 ? html` @@ -292,17 +345,17 @@ export class TermSearchComponent extends RxLitElement { ${ this.selectedTerms?.find((selected) => selected.uri === term.uri) ? unsafeSVG(CheckboxChecked) : unsafeSVG(CheckboxUnchecked)}
- ${ term.description?.length > 0 ? html`

${this.translator.translate('term.field.description')}: ${ term.description }

` : html``} - ${ term.alternateName?.length > 0 ? html`

${this.translator.translate('term.field.alternateName')}: ${ term.alternateName.join(', ') }

` : html``} - ${ term.broader?.length > 0 ? html`

${this.translator.translate('term.field.broader')}: ${ term.broader.map((broader) => broader.name).join(', ') }

` : html``} - ${ term.narrower?.length > 0 ? html`

${this.translator.translate('term.field.narrower')}: ${ term.narrower.map((narrower) => narrower.name).join(', ') }

` : html``} - ${ term.hiddenName?.length > 0 ? html`

${this.translator.translate('term.field.hiddenName')}: ${ term.hiddenName }

` : html``} + ${ term.description?.length > 0 ? html`

${this.translator.translate('term.field.description')}: ${ term.description }

` : ''} + ${ term.alternateName?.length > 0 ? html`

${this.translator.translate('term.field.alternateName')}: ${ term.alternateName.join(', ') }

` : ''} + ${ term.broader?.length > 0 ? html`

${this.translator.translate('term.field.broader')}: ${ term.broader.map((broader) => broader.name).join(', ') }

` : ''} + ${ term.narrower?.length > 0 ? html`

${this.translator.translate('term.field.narrower')}: ${ term.narrower.map((narrower) => narrower.name).join(', ') }

` : ''} + ${ term.hiddenName?.length > 0 ? html`

${this.translator.translate('term.field.hiddenName')}: ${ term.hiddenName }

` : ''}
`)} `)} - ` : html``} + ` : ''} ${(this.searchResultsMap && Object.keys(this.searchResultsMap).length < 1) ? html` @@ -310,7 +363,7 @@ export class TermSearchComponent extends RxLitElement { ${unsafeSVG(Empty)}

${this.translator?.translate('term.no-search-results')}

- ` : html``} + ` : ''} `; @@ -339,6 +392,11 @@ export class TermSearchComponent extends RxLitElement { :host > * { margin-bottom: var(--gap-large); } + a { + cursor: pointer; + text-decoration: underline; + color: var(--colors-primary-light); + } nde-progress-bar { position: absolute; width: 100%; @@ -392,6 +450,14 @@ export class TermSearchComponent extends RxLitElement { nde-form-element { margin: 0; } + nde-large-card nde-form-element input { + height: var(--gap-normal); + padding: 0; + line-height: var(--gap-normal); + overflow: hidden; + font-weight: var(--font-weight-bold); + font-size: var(--font-size-normal); + } .empty { width: 100%; height: 100%; diff --git a/packages/solid-crs-manage/lib/features/object/terms/term-search.components.spec.ts b/packages/solid-crs-manage/lib/features/object/terms/term-search.components.spec.ts index 162859c1..3e57d9c7 100644 --- a/packages/solid-crs-manage/lib/features/object/terms/term-search.components.spec.ts +++ b/packages/solid-crs-manage/lib/features/object/terms/term-search.components.spec.ts @@ -2,7 +2,7 @@ import { Alert, FormActors, FormUpdatedEvent, FormValidatedEvent, LargeCardCompo import { ArgumentError, Term } from '@netwerk-digitaal-erfgoed/solid-crs-core'; import { interpret, Interpreter } from 'xstate'; import { AppEvents } from '../../../app.events'; -import { ClickedCancelTermEvent, ObjectEvents } from '../object.events'; +import { ClickedCancelTermEvent } from '../object.events'; import { TermSearchComponent } from './term-search.component'; import { ClickedSubmitEvent, ClickedTermEvent, QueryUpdatedEvent } from './term.events'; import { TermContext, termMachine, TermStates } from './term.machine'; @@ -41,6 +41,7 @@ describe('TermSearchComponent', () => { .withContext({ termService: termService as any, selectedTerms: [], + searchResults: [], })); component = window.document.createElement('nde-term-search') as TermSearchComponent; @@ -333,6 +334,30 @@ describe('TermSearchComponent', () => { }); + it('should show new term input field when create button is clicked', async () => { + + machine.start(); + window.document.body.appendChild(component); + await component.updateComplete; + + if (machine.state.matches(TermStates.IDLE)) { + + const button = window.document.body.getElementsByTagName('nde-term-search')[0].shadowRoot.querySelector('#create-term'); + expect(button).toBeTruthy(); + button.click(); + + } + + if (machine.state.matches(TermStates.CREATING)) { + + await component.updateComplete; + const card = window.document.body.getElementsByTagName('nde-term-search')[0].shadowRoot.querySelector('#create-term-card input'); + expect(card).toBeTruthy(); + + } + + }); + describe('groupSearchResults()', () => { it('should group search results', () => { diff --git a/packages/solid-crs-manage/lib/features/object/terms/term.events.ts b/packages/solid-crs-manage/lib/features/object/terms/term.events.ts index 5d89da5b..b55b5c54 100644 --- a/packages/solid-crs-manage/lib/features/object/terms/term.events.ts +++ b/packages/solid-crs-manage/lib/features/object/terms/term.events.ts @@ -7,6 +7,7 @@ import { EventObject } from 'xstate'; export enum TermEvents { CLICKED_SUBMIT = '[TermEvent: Clicked Submit]', CLICKED_TERM = '[TermEvent: Clicked Term]', + CLICKED_ADD = '[TermEvent: Clicked Add]', QUERY_UPDATED = '[TermEvent: Query Updated]', } @@ -29,6 +30,15 @@ export class ClickedTermEvent implements EventObject { } +/** + * Fired when the user clicks a Term search result. + */ +export class ClickedAddEvent implements EventObject { + + public type: TermEvents.CLICKED_ADD = TermEvents.CLICKED_ADD; + +} + /** * Fired when the user changes the input of the Term search inputs. */ @@ -44,4 +54,5 @@ export class QueryUpdatedEvent implements EventObject { */ export type TermEvent = ClickedSubmitEvent | ClickedTermEvent -| QueryUpdatedEvent; +| QueryUpdatedEvent +| ClickedAddEvent; diff --git a/packages/solid-crs-manage/lib/features/object/terms/term.machine.spec.ts b/packages/solid-crs-manage/lib/features/object/terms/term.machine.spec.ts index 372a594b..33a05e97 100644 --- a/packages/solid-crs-manage/lib/features/object/terms/term.machine.spec.ts +++ b/packages/solid-crs-manage/lib/features/object/terms/term.machine.spec.ts @@ -1,5 +1,5 @@ import { interpret, Interpreter } from 'xstate'; -import { ClickedSubmitEvent, ClickedTermEvent, QueryUpdatedEvent } from './term.events'; +import { ClickedAddEvent, ClickedSubmitEvent, ClickedTermEvent, QueryUpdatedEvent } from './term.events'; import { TermContext, termMachine, TermStates } from './term.machine'; describe('TermMachine', () => { @@ -139,4 +139,31 @@ describe('TermMachine', () => { }); + it('should transition back to IDLE when ClickedTermEvent is fired when CREATING', async (done) => { + + let clickedOnce = false; + + machine.onTransition((state) => { + + if (clickedOnce && state.matches(TermStates.IDLE)) { + + done(); + + } else if (state.matches(TermStates.IDLE)) { + + machine.send(new ClickedAddEvent()); + clickedOnce = true; + + } else if (state.matches(TermStates.CREATING)) { + + machine.send(new ClickedTermEvent(term)); + + } + + }); + + machine.start(); + + }); + }); diff --git a/packages/solid-crs-manage/lib/features/object/terms/term.machine.ts b/packages/solid-crs-manage/lib/features/object/terms/term.machine.ts index ad208c07..26dec4bf 100644 --- a/packages/solid-crs-manage/lib/features/object/terms/term.machine.ts +++ b/packages/solid-crs-manage/lib/features/object/terms/term.machine.ts @@ -2,7 +2,7 @@ import { FormActors, formMachine, State } from '@netwerk-digitaal-erfgoed/solid- import { Term, TermService, TermSource } from '@netwerk-digitaal-erfgoed/solid-crs-core'; import { assign, createMachine, sendParent, StateMachine } from 'xstate'; import { AppEvents, ErrorEvent } from '../../../app.events'; -import { TermEvent, TermEvents } from './term.events'; +import { ClickedTermEvent, TermEvent, TermEvents } from './term.events'; /** * The context of the term machine. @@ -52,9 +52,21 @@ export enum TermStates { IDLE = '[TermState: Idle]', QUERYING = '[TermState: Querying]', SUBMITTED = '[TermState: Submitted]', + CREATING = '[TermState: Creating]', LOADING_SOURCES = '[TermState: Loading Sources]', } +/** + * Action which adds/removes a Term from the context + */ +export const toggleTerm = assign( + (context, event) => ({ selectedTerms: !context.selectedTerms?.find((term) => term.uri === event.term.uri) + // add the term if it is not yet selected + ? context.selectedTerms ? [ ...context.selectedTerms, event.term ] : [ event.term ] + // otherwise, remove it from selected terms + : context.selectedTerms?.filter((term) => term.uri !== event.term.uri) }), +); + /** * The term machine. */ @@ -99,15 +111,10 @@ export const termMachine = (): StateMachine ({ selectedTerms: !context.selectedTerms?.find((term) => term.uri === event.term.uri) - // add the term if it is not yet selected - ? context.selectedTerms ? [ ...context.selectedTerms, event.term ] : [ event.term ] - // otherwise, remove it from selected terms - : context.selectedTerms?.filter((term) => term.uri !== event.term.uri) }), - ), + actions: toggleTerm, }, [TermEvents.CLICKED_SUBMIT]: TermStates.SUBMITTED, + [TermEvents.CLICKED_ADD]: TermStates.CREATING, }, }, /** @@ -127,6 +134,17 @@ export const termMachine = (): StateMachine