diff --git a/config/config.example.yml b/config/config.example.yml index 8b56711c7d2..ff94087cf44 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -418,7 +418,8 @@ info: enablePrivacyStatement: true # Whether to enable Markdown (https://commonmark.org/) and MathJax (https://www.mathjax.org/) -# display in supported metadata fields. By default, only dc.description.abstract is supported. +# display in supported metadata fields. By default, allowlisted description fields +# (description, dc.description, dc.description.abstract) are supported. markdown: enabled: false mathjax: false diff --git a/config/config.yml b/config/config.yml index 9257bd2b09d..fd5f8200d98 100644 --- a/config/config.yml +++ b/config/config.yml @@ -171,3 +171,7 @@ languages: - code: uk label: Yкраї́нська active: false + +markdown: + enabled: true + mathjax: false diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html index 9f74216d54f..46cf12e6d44 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html @@ -3,6 +3,8 @@ { }); }); + describe('isLocalDescriptionUseMarkdownEnabled', () => { + it('should return false when local.description.usemarkdown is missing', () => { + expect(component.isLocalDescriptionUseMarkdownEnabled()).toBeFalse(); + }); + + it('should return true when local.description.usemarkdown is yes', () => { + form = new DsoEditMetadataForm({ + ...dso.metadata, + 'local.description.usemarkdown': [ + Object.assign(new MetadataValue(), { + value: 'yes', + language: 'en', + place: 0, + }), + ], + }); + component.form = form; + + expect(component.isLocalDescriptionUseMarkdownEnabled()).toBeTrue(); + }); + + it('should return true when local.description.usemarkdown is YES', () => { + form = new DsoEditMetadataForm({ + ...dso.metadata, + 'local.description.usemarkdown': [ + Object.assign(new MetadataValue(), { + value: 'YES', + language: 'en', + place: 0, + }), + ], + }); + component.form = form; + + expect(component.isLocalDescriptionUseMarkdownEnabled()).toBeTrue(); + }); + + it('should return true when local.description.usemarkdown is True', () => { + form = new DsoEditMetadataForm({ + ...dso.metadata, + 'local.description.usemarkdown': [ + Object.assign(new MetadataValue(), { + value: 'True', + language: 'en', + place: 0, + }), + ], + }); + component.form = form; + + expect(component.isLocalDescriptionUseMarkdownEnabled()).toBeTrue(); + }); + + it('should return true when local.description.usemarkdown is boolean true', () => { + form = new DsoEditMetadataForm({ + ...dso.metadata, + 'local.description.usemarkdown': [ + Object.assign(new MetadataValue(), { + value: true, + language: 'en', + place: 0, + }), + ], + }); + component.form = form; + + expect(component.isLocalDescriptionUseMarkdownEnabled()).toBeTrue(); + }); + + it('should return true when local.description.usemarkdown is object map with truthy entry', () => { + form = new DsoEditMetadataForm({ + ...dso.metadata, + 'local.description.usemarkdown': [ + Object.assign(new MetadataValue(), { + value: { + local_description_usemarkdown_yes: false, + another_option: true, + }, + language: 'en', + place: 0, + }), + ], + }); + component.form = form; + + expect(component.isLocalDescriptionUseMarkdownEnabled()).toBeTrue(); + }); + + it('should return false when local.description.usemarkdown object map is fully falsy', () => { + form = new DsoEditMetadataForm({ + ...dso.metadata, + 'local.description.usemarkdown': [ + Object.assign(new MetadataValue(), { + value: { + local_description_usemarkdown_yes: false, + another_option: false, + }, + language: 'en', + place: 0, + }), + ], + }); + component.form = form; + + expect(component.isLocalDescriptionUseMarkdownEnabled()).toBeFalse(); + }); + }); + describe('dropping a value on a different index', () => { beforeEach(() => { component.drop(Object.assign({ diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts index 2702e0ba1f8..7320e52dfd8 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.ts @@ -14,6 +14,8 @@ import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; * Component displaying table rows for each value for a certain metadata field within a form */ export class DsoEditMetadataFieldValuesComponent { + protected readonly localDescriptionUseMarkdownMetadataKey = 'local.description.usemarkdown'; + /** * The parent {@link DSpaceObject} to display a metadata form for * Also used to determine metadata-representations in case of virtual metadata @@ -57,6 +59,47 @@ export class DsoEditMetadataFieldValuesComponent { */ public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType; + /** + * Returns whether local.description.usemarkdown exists in form metadata and explicitly enables markdown. + */ + isLocalDescriptionUseMarkdownEnabled(): boolean { + const useMarkdownValues = this.form?.fields?.[this.localDescriptionUseMarkdownMetadataKey]; + + if (!Array.isArray(useMarkdownValues) || useMarkdownValues.length === 0) { + return false; + } + + return useMarkdownValues.some((metadataValue: DsoEditMetadataValue) => { + const value = metadataValue?.newValue?.value; + return this.isUseMarkdownValueEnabled(value); + }); + } + + private isUseMarkdownValueEnabled(value: any): boolean { + if (value === null || value === undefined) { + return false; + } + + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + const normalizedValue = value.toLowerCase(); + return normalizedValue === 'yes' || normalizedValue === 'true'; + } + + if (Array.isArray(value)) { + return value.some((entry) => this.isUseMarkdownValueEnabled(entry)); + } + + if (typeof value === 'object') { + return Object.values(value).some((entry) => this.isUseMarkdownValueEnabled(entry)); + } + + return false; + } + /** * Drop a value into a new position * Update the form's value array for the current field to match the dropped position diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html index f96759a75ef..f02dd0d39c5 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html @@ -1,11 +1,26 @@
-
+
{{ mdValue.newValue.value }}
- +
+
+
{{ mdRepresentationName$ | async }} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.scss b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.scss index e5551913005..47defd6cb6d 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.scss +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.scss @@ -14,3 +14,13 @@ .cdk-drag-placeholder { opacity: 0; } + +.ds-markdown-mode-toggle { + margin-bottom: var(--ds-spacer-1); +} + +.ds-markdown-preview-container { + min-height: 9rem; + overflow-y: auto; + white-space: normal; +} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts index e90e7391f87..8a7bd6c89c2 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts @@ -12,6 +12,8 @@ import { MetadataValue, VIRTUAL_METADATA_PREFIX } from '../../../core/shared/met import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form'; import { By } from '@angular/platform-browser'; import {BtnDisabledDirective} from '../../../shared/btn-disabled.directive'; +import { APP_CONFIG } from '../../../../config/app-config.interface'; +import { environment } from '../../../../environments/environment'; const EDIT_BTN = 'edit'; const CONFIRM_BTN = 'confirm'; @@ -55,6 +57,7 @@ describe('DsoEditMetadataValueComponent', () => { providers: [ { provide: RelationshipDataService, useValue: relationshipService }, { provide: DSONameService, useValue: dsoNameService }, + { provide: APP_CONFIG, useValue: environment }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -64,10 +67,64 @@ describe('DsoEditMetadataValueComponent', () => { fixture = TestBed.createComponent(DsoEditMetadataValueComponent); component = fixture.componentInstance; component.mdValue = editMetadataValue; + component.mdField = 'dc.description'; + component.markdownEnabledForForm = true; component.saving$ = of(false); fixture.detectChanges(); }); + describe('markdown preview toggle', () => { + let appConfig: any; + let originalMarkdownEnabled: boolean; + + beforeEach(() => { + editMetadataValue.editing = true; + appConfig = TestBed.inject(APP_CONFIG) as any; + originalMarkdownEnabled = appConfig.markdown.enabled; + appConfig.markdown.enabled = true; + fixture.detectChanges(); + }); + + afterEach(() => { + appConfig.markdown.enabled = originalMarkdownEnabled; + }); + + it('should show toggle for description fields when metadata markdown is enabled', () => { + expect(component.canShowMarkdownPreviewToggle()).toBeTrue(); + }); + + it('should hide toggle when markdown metadata gate is disabled', () => { + component.markdownEnabledForForm = false; + + expect(component.canShowMarkdownPreviewToggle()).toBeFalse(); + }); + + it('should hide toggle when global markdown is disabled', () => { + appConfig.markdown.enabled = false; + + expect(component.canShowMarkdownPreviewToggle()).toBeFalse(); + }); + + it('should hide toggle when value is not in editing mode', () => { + component.mdValue.editing = false; + + expect(component.canShowMarkdownPreviewToggle()).toBeFalse(); + }); + + it('should enable preview mode and return current value', () => { + component.setMarkdownPreviewMode(true); + + expect(component.isMarkdownPreviewModeEnabled()).toBeTrue(); + expect(component.getMarkdownPreviewValue()).toBe('Regular Name'); + }); + + it('should hide toggle for non-description metadata fields', () => { + component.mdField = 'dc.title'; + + expect(component.canShowMarkdownPreviewToggle()).toBeFalse(); + }); + }); + it('should not show a badge', () => { expect(fixture.debugElement.query(By.css('ds-themed-type-badge'))).toBeNull(); }); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts index 3fdcd381abc..a7b38955545 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core'; import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form'; import { Observable } from 'rxjs/internal/Observable'; import { @@ -12,6 +12,8 @@ import { map } from 'rxjs/operators'; import { getItemPageRoute } from '../../../item-page/item-page-routing-paths'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { EMPTY } from 'rxjs/internal/observable/empty'; +import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface'; +import { MARKDOWN_DESCRIPTION_METADATA_ALLOW_LIST } from '../../../shared/form/builder/constants/markdown-description-metadata-allow-list'; @Component({ selector: 'ds-dso-edit-metadata-value', @@ -22,6 +24,8 @@ import { EMPTY } from 'rxjs/internal/observable/empty'; * Component displaying a single editable row for a metadata value */ export class DsoEditMetadataValueComponent implements OnInit { + protected readonly markdownDescriptionMetadataAllowList: string[] = MARKDOWN_DESCRIPTION_METADATA_ALLOW_LIST; + /** * The parent {@link DSpaceObject} to display a metadata form for * Also used to determine metadata-representations in case of virtual metadata @@ -33,6 +37,16 @@ export class DsoEditMetadataValueComponent implements OnInit { */ @Input() mdValue: DsoEditMetadataValue; + /** + * Metadata field this value belongs to + */ + @Input() mdField: string; + + /** + * Whether local.description.usemarkdown is available in the form and enabled + */ + @Input() markdownEnabledForForm = false; + /** * Type of DSO we're displaying values for * Determines i18n messages @@ -97,8 +111,14 @@ export class DsoEditMetadataValueComponent implements OnInit { */ mdRepresentationName$: Observable; + /** + * Whether markdown preview mode is enabled while editing this value + */ + isMarkdownPreviewMode = false; + constructor(protected relationshipService: RelationshipDataService, - protected dsoNameService: DSONameService) { + protected dsoNameService: DSONameService, + @Inject(APP_CONFIG) protected appConfig: AppConfig) { } ngOnInit(): void { @@ -123,4 +143,47 @@ export class DsoEditMetadataValueComponent implements OnInit { map((mdRepresentation: ItemMetadataRepresentation) => mdRepresentation ? this.dsoNameService.getName(mdRepresentation) : null), ); } + + /** + * Returns whether markdown toggle should be shown for this value row. + */ + canShowMarkdownPreviewToggle(): boolean { + return this.mdValue?.editing + && this.appConfig?.markdown?.enabled + && this.markdownEnabledForForm + && this.isDescriptionField(); + } + + /** + * Returns whether preview mode is currently active. + */ + isMarkdownPreviewModeEnabled(): boolean { + return this.canShowMarkdownPreviewToggle() && this.isMarkdownPreviewMode; + } + + /** + * Enable or disable markdown preview mode. + */ + setMarkdownPreviewMode(enabled: boolean): void { + this.isMarkdownPreviewMode = enabled; + } + + /** + * Returns current editable metadata value as markdown preview text. + */ + getMarkdownPreviewValue(): string { + const value = this.mdValue?.newValue?.value; + return typeof value === 'string' ? value : ''; + } + + /** + * Returns true when this row belongs to a supported description field. + */ + protected isDescriptionField(): boolean { + if (typeof this.mdField !== 'string') { + return false; + } + + return this.markdownDescriptionMetadataAllowList.includes(this.mdField); + } } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html index 1598558e513..d0755447e29 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html @@ -41,6 +41,8 @@ { + const value = metadataValue?.newValue?.value; + + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + const normalizedValue = value.toLowerCase(); + return normalizedValue === 'yes' || normalizedValue === 'true'; + } + + return value === true; + }); + } + /** * Submit the current changes to the form by retrieving json PATCH operations from the form and sending it to the * DSpaceObject's data-service diff --git a/src/app/item-page/simple/field-components/clarin-description-item-field/clarin-description-item-field.component.html b/src/app/item-page/simple/field-components/clarin-description-item-field/clarin-description-item-field.component.html index fc0946b71e0..c581ddc2ab6 100644 --- a/src/app/item-page/simple/field-components/clarin-description-item-field/clarin-description-item-field.component.html +++ b/src/app/item-page/simple/field-components/clarin-description-item-field/clarin-description-item-field.component.html @@ -1 +1,2 @@ -
+
+
diff --git a/src/app/item-page/simple/field-components/clarin-description-item-field/clarin-description-item-field.component.spec.ts b/src/app/item-page/simple/field-components/clarin-description-item-field/clarin-description-item-field.component.spec.ts index 9eb04c1b3ae..cec72e9f52c 100644 --- a/src/app/item-page/simple/field-components/clarin-description-item-field/clarin-description-item-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/clarin-description-item-field/clarin-description-item-field.component.spec.ts @@ -3,6 +3,9 @@ import { ClarinDescriptionItemFieldComponent } from './clarin-description-item-f import { Item } from '../../../../core/shared/item.model'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; import { createPaginatedList } from '../../../../shared/testing/utils.test'; +import { APP_CONFIG } from '../../../../../config/app-config.interface'; +import { environment } from '../../../../../environments/environment'; +import { MarkdownPipe } from '../../../../shared/utils/markdown.pipe'; describe('ClarinDescriptionItemFieldComponent', () => { let component: ClarinDescriptionItemFieldComponent; @@ -22,7 +25,10 @@ describe('ClarinDescriptionItemFieldComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ ClarinDescriptionItemFieldComponent ] + declarations: [ ClarinDescriptionItemFieldComponent, MarkdownPipe ], + providers: [ + { provide: APP_CONFIG, useValue: environment } + ] }) .compileComponents(); diff --git a/src/app/item-page/simple/field-components/clarin-description-item-field/clarin-description-item-field.component.ts b/src/app/item-page/simple/field-components/clarin-description-item-field/clarin-description-item-field.component.ts index da8c8d4a98b..95281e7600d 100644 --- a/src/app/item-page/simple/field-components/clarin-description-item-field/clarin-description-item-field.component.ts +++ b/src/app/item-page/simple/field-components/clarin-description-item-field/clarin-description-item-field.component.ts @@ -1,6 +1,7 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Inject, Input, OnInit } from '@angular/core'; import { Item } from '../../../../core/shared/item.model'; import { makeLinks } from '../../../../shared/clarin-shared-util'; +import { APP_CONFIG, AppConfig } from '../../../../../config/app-config.interface'; @Component({ selector: 'ds-clarin-description-item-field', @@ -9,6 +10,8 @@ import { makeLinks } from '../../../../shared/clarin-shared-util'; }) export class ClarinDescriptionItemFieldComponent implements OnInit { + constructor(@Inject(APP_CONFIG) private appConfig: AppConfig) {} + /** * The item to display metadata for */ @@ -24,15 +27,30 @@ export class ClarinDescriptionItemFieldComponent implements OnInit { */ validTextMetadata: string; + /** + * This variable will be true if {@link appConfig.markdown.enabled} is true. + */ + renderMarkdown: boolean; + ngOnInit(): void { + this.renderMarkdown = !!this.appConfig.markdown.enabled && this.markdownEnabled(); + // Store all description metadata values let updatedMVs = []; this.item.allMetadataValues(this.fields).forEach((value) => { - updatedMVs.push(makeLinks(value)); + updatedMVs.push(this.renderMarkdown ? value : makeLinks(value)); }); // Join the metadata values with a line break this.validTextMetadata = updatedMVs.join('
'); } + /** + * Check if the item uses Markdown to render description text. + * */ + private markdownEnabled() { + const useMarkdown = this.item.metadata?.['local.description.usemarkdown']?.[0]?.value; + return useMarkdown !== undefined && useMarkdown.toLowerCase() === 'yes'; + } + } diff --git a/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.html b/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.html index 50066833e28..2f0601e27e4 100644 --- a/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.html +++ b/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.html @@ -28,7 +28,22 @@
{{'item.view.box.description.message' | translate}}
-
{{itemDescription}}
+ +
{{itemDescription}}
+
+
+
+
+
+
+
diff --git a/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.spec.ts b/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.spec.ts index 7892741bf29..de8c5c7c6ae 100644 --- a/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.spec.ts +++ b/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.spec.ts @@ -14,6 +14,9 @@ import { ClarinLicenseDataService } from 'src/app/core/data/clarin/clarin-licens import { ClarinDateService } from '../clarin-date.service'; import { DomSanitizer } from '@angular/platform-browser'; import { DSONameServiceMock } from '../mocks/dso-name.service.mock'; +import { MarkdownPipe } from '../utils/markdown.pipe'; +import { APP_CONFIG } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment'; describe('ClarinItemBoxViewComponent', () => { let component: ClarinItemBoxViewComponent; @@ -59,7 +62,7 @@ describe('ClarinItemBoxViewComponent', () => { }), StoreModule.forRoot(), ], - declarations: [ClarinItemBoxViewComponent], + declarations: [ClarinItemBoxViewComponent, MarkdownPipe], providers: [ { provide: CollectionDataService, useValue: collectionDataServiceMock }, { provide: BundleDataService, useValue: bundleDataServiceMock }, @@ -74,6 +77,7 @@ describe('ClarinItemBoxViewComponent', () => { { provide: ClarinDateService, useValue: clarinDateServiceMock }, { provide: DSONameService, useValue: new DSONameServiceMock() }, { provide: DomSanitizer, useValue: sanitizerStub }, + { provide: APP_CONFIG, useValue: environment }, provideMockStore({ initialState }), ], }).compileComponents(); diff --git a/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.ts b/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.ts index 566c2bf8dc3..118cb1c3a15 100644 --- a/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.ts +++ b/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line max-classes-per-file -import { Component, Input, OnInit } from '@angular/core'; +import {Component, Inject, Input, OnInit} from '@angular/core'; import { Item } from '../../core/shared/item.model'; import { CollectionDataService } from '../../core/data/collection-data.service'; import { @@ -32,6 +32,7 @@ import { FindListOptions } from '../../core/data/find-list-options.model'; import { ClarinDateService } from '../clarin-date.service'; import { AUTHOR_METADATA_FIELDS } from '../../core/shared/clarin/constants'; import {RequestParam} from '../../core/cache/models/request-param.model'; +import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface'; /** * Show item on the Home/Search page in the customized box with Item's information. @@ -124,7 +125,13 @@ export class ClarinItemBoxViewComponent implements OnInit { */ licenseLabelIcons: BehaviorSubject = new BehaviorSubject([]); - constructor(protected collectionService: CollectionDataService, + /** + * This variable will be true if {@link appConfig.markdown.enabled} is true. + */ + renderMarkdown: boolean; + + constructor(@Inject(APP_CONFIG) private appConfig: AppConfig, + protected collectionService: CollectionDataService, protected bundleService: BundleDataService, protected dsoNameService: DSONameService, protected configurationService: ConfigurationDataService, @@ -133,6 +140,7 @@ export class ClarinItemBoxViewComponent implements OnInit { private clarinDateService: ClarinDateService) { } async ngOnInit(): Promise { + if (this.object instanceof Item) { this.item = this.object; } else if (this.object instanceof ItemSearchResult) { @@ -141,6 +149,8 @@ export class ClarinItemBoxViewComponent implements OnInit { return; } + this.renderMarkdown = !!this.appConfig.markdown.enabled && this.markdownEnabled(); + // Load Items metadata this.itemType = this.item?.firstMetadataValue('dc.type'); this.itemName = this.item?.firstMetadataValue('dc.title'); @@ -261,6 +271,17 @@ export class ClarinItemBoxViewComponent implements OnInit { return this.itemCountOfFiles.value > 1; } + /** + * Check if the item uses Markdown to render description text. + * */ + private markdownEnabled() { + if (isNull(this.item)) { + return false; + } + const useMarkdown = this.item.metadata?.['local.description.usemarkdown']?.[0]?.value; + return useMarkdown !== undefined && useMarkdown.toLowerCase() === 'yes'; + } + handleImageError(event) { const imgElement = event.target as HTMLImageElement; imgElement.src = diff --git a/src/app/shared/form/builder/constants/markdown-description-metadata-allow-list.ts b/src/app/shared/form/builder/constants/markdown-description-metadata-allow-list.ts new file mode 100644 index 00000000000..a6440d27558 --- /dev/null +++ b/src/app/shared/form/builder/constants/markdown-description-metadata-allow-list.ts @@ -0,0 +1,5 @@ +export const MARKDOWN_DESCRIPTION_METADATA_ALLOW_LIST: string[] = [ + 'description', + 'dc.description', + 'dc.description.abstract' +]; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html index 606db29db37..3f4e7997a21 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html @@ -12,10 +12,32 @@
-
+
+ + +
+ +
+
+ diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.scss index 4e58759f4e7..adbb2171e75 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.scss @@ -14,3 +14,17 @@ -moz-appearance: none; appearance: none; } + +.markdown-preview-toggle { + .btn.active { + pointer-events: none; + } +} + +.markdown-preview-box { + max-height: 24rem; + min-height: 3rem; + overflow: auto; + padding: 0.75rem; + white-space: pre-wrap; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts index 355e10b9a09..be9251b5d20 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts @@ -377,4 +377,275 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { expect(testFn(formModel[25])).toEqual(DsDynamicFormGroupComponent); }); + it('should show markdown preview toggle for eligible textarea when markdown is enabled', () => { + const appConfig = TestBed.inject(APP_CONFIG) as any; + const previousMarkdownEnabled = appConfig.markdown.enabled; + appConfig.markdown.enabled = true; + + const textareaModel: any = formModel[14]; + textareaModel.supportsMarkdownPreview = true; + textareaModel.metadataFields = ['dc.description']; + + formGroup.addControl('local_description_usemarkdown', new UntypedFormControl('yes')); + + component.model = textareaModel; + component.group = formGroup; + component.ngOnChanges({ + group: new SimpleChange(null, component.group, false), + model: new SimpleChange(null, component.model, false) + }); + + expect(component.canShowMarkdownPreviewToggle()).toBe(true); + + appConfig.markdown.enabled = previousMarkdownEnabled; + }); + + it('should hide markdown preview toggle when markdown is disabled', () => { + const appConfig = TestBed.inject(APP_CONFIG) as any; + const previousMarkdownEnabled = appConfig.markdown.enabled; + appConfig.markdown.enabled = false; + + const textareaModel: any = formModel[14]; + textareaModel.supportsMarkdownPreview = true; + textareaModel.metadataFields = ['dc.description']; + + formGroup.addControl('local_description_usemarkdown', new UntypedFormControl('yes')); + + component.model = textareaModel; + component.group = formGroup; + component.ngOnChanges({ + group: new SimpleChange(null, component.group, false), + model: new SimpleChange(null, component.model, false) + }); + + expect(component.canShowMarkdownPreviewToggle()).toBe(false); + + appConfig.markdown.enabled = previousMarkdownEnabled; + }); + + it('should switch to markdown preview mode and return current textarea value', () => { + const appConfig = TestBed.inject(APP_CONFIG) as any; + const previousMarkdownEnabled = appConfig.markdown.enabled; + appConfig.markdown.enabled = true; + + const textareaModel: any = formModel[14]; + textareaModel.supportsMarkdownPreview = true; + textareaModel.metadataFields = ['dc.description']; + + formGroup.addControl('local_description_usemarkdown', new UntypedFormControl('yes')); + + component.model = textareaModel; + component.group = formGroup; + component.ngOnChanges({ + group: new SimpleChange(null, component.group, false), + model: new SimpleChange(null, component.model, false) + }); + + component.control.setValue('# Title'); + component.setMarkdownPreviewMode(true); + + expect(component.isMarkdownPreviewModeEnabled()).toBe(true); + expect(component.getMarkdownPreviewValue()).toBe('# Title'); + + appConfig.markdown.enabled = previousMarkdownEnabled; + }); + + it('should hide markdown preview toggle when local.description.usemarkdown is set to no', () => { + const appConfig = TestBed.inject(APP_CONFIG) as any; + const previousMarkdownEnabled = appConfig.markdown.enabled; + appConfig.markdown.enabled = true; + + const textareaModel: any = formModel[14]; + textareaModel.supportsMarkdownPreview = true; + textareaModel.metadataFields = ['dc.description']; + + formGroup.addControl('local_description_usemarkdown', new UntypedFormControl('no')); + + component.model = textareaModel; + component.group = formGroup; + component.ngOnChanges({ + group: new SimpleChange(null, component.group, false), + model: new SimpleChange(null, component.model, false) + }); + + expect(component.canShowMarkdownPreviewToggle()).toBe(false); + + appConfig.markdown.enabled = previousMarkdownEnabled; + }); + + it('should hide markdown preview toggle when local.description.usemarkdown control is missing', () => { + const appConfig = TestBed.inject(APP_CONFIG) as any; + const previousMarkdownEnabled = appConfig.markdown.enabled; + appConfig.markdown.enabled = true; + + const textareaModel: any = formModel[14]; + textareaModel.supportsMarkdownPreview = true; + textareaModel.metadataFields = ['dc.description']; + + component.model = textareaModel; + component.group = formGroup; + component.ngOnChanges({ + group: new SimpleChange(null, component.group, false), + model: new SimpleChange(null, component.model, false) + }); + + expect(component.canShowMarkdownPreviewToggle()).toBe(false); + + appConfig.markdown.enabled = previousMarkdownEnabled; + }); + + it('should show markdown preview toggle when usemarkdown is in a sibling row-group with object value', () => { + const appConfig = TestBed.inject(APP_CONFIG) as any; + const previousMarkdownEnabled = appConfig.markdown.enabled; + appConfig.markdown.enabled = true; + + const textareaModel: any = new DynamicTextAreaModel({ id: 'dc_description' }); + textareaModel.supportsMarkdownPreview = true; + textareaModel.metadataFields = ['dc.description']; + + const descriptionGroup = new UntypedFormGroup({ + dc_description: new UntypedFormControl('# something') + }); + const markdownGroup = new UntypedFormGroup({ + local_description_usemarkdown: new UntypedFormControl({ local_description_usemarkdown_yes: true }) + }); + const rootFormGroup = new UntypedFormGroup({ + 'df-row-group-config-22': descriptionGroup, + 'df-row-group-config-23': markdownGroup + }); + + component.model = textareaModel; + component.group = descriptionGroup; + component.formGroup = rootFormGroup; + component.ngOnChanges({ + group: new SimpleChange(null, component.group, false), + model: new SimpleChange(null, component.model, false) + }); + + expect(component.canShowMarkdownPreviewToggle()).toBe(true); + + appConfig.markdown.enabled = previousMarkdownEnabled; + }); + + it('should show markdown preview toggle when local.description.usemarkdown is YES', () => { + const appConfig = TestBed.inject(APP_CONFIG) as any; + const previousMarkdownEnabled = appConfig.markdown.enabled; + appConfig.markdown.enabled = true; + + const textareaModel: any = formModel[14]; + textareaModel.supportsMarkdownPreview = true; + textareaModel.metadataFields = ['dc.description']; + + formGroup.addControl('local_description_usemarkdown', new UntypedFormControl('YES')); + + component.model = textareaModel; + component.group = formGroup; + component.ngOnChanges({ + group: new SimpleChange(null, component.group, false), + model: new SimpleChange(null, component.model, false) + }); + + expect(component.canShowMarkdownPreviewToggle()).toBe(true); + + appConfig.markdown.enabled = previousMarkdownEnabled; + }); + + it('should show markdown preview toggle when local.description.usemarkdown is True', () => { + const appConfig = TestBed.inject(APP_CONFIG) as any; + const previousMarkdownEnabled = appConfig.markdown.enabled; + appConfig.markdown.enabled = true; + + const textareaModel: any = formModel[14]; + textareaModel.supportsMarkdownPreview = true; + textareaModel.metadataFields = ['dc.description']; + + formGroup.addControl('local_description_usemarkdown', new UntypedFormControl('True')); + + component.model = textareaModel; + component.group = formGroup; + component.ngOnChanges({ + group: new SimpleChange(null, component.group, false), + model: new SimpleChange(null, component.model, false) + }); + + expect(component.canShowMarkdownPreviewToggle()).toBe(true); + + appConfig.markdown.enabled = previousMarkdownEnabled; + }); + + it('should show markdown preview toggle when local.description.usemarkdown is boolean true', () => { + const appConfig = TestBed.inject(APP_CONFIG) as any; + const previousMarkdownEnabled = appConfig.markdown.enabled; + appConfig.markdown.enabled = true; + + const textareaModel: any = formModel[14]; + textareaModel.supportsMarkdownPreview = true; + textareaModel.metadataFields = ['dc.description']; + + formGroup.addControl('local_description_usemarkdown', new UntypedFormControl(true)); + + component.model = textareaModel; + component.group = formGroup; + component.ngOnChanges({ + group: new SimpleChange(null, component.group, false), + model: new SimpleChange(null, component.model, false) + }); + + expect(component.canShowMarkdownPreviewToggle()).toBe(true); + + appConfig.markdown.enabled = previousMarkdownEnabled; + }); + + it('should show markdown preview toggle when local.description.usemarkdown is an object map with a truthy value', () => { + const appConfig = TestBed.inject(APP_CONFIG) as any; + const previousMarkdownEnabled = appConfig.markdown.enabled; + appConfig.markdown.enabled = true; + + const textareaModel: any = formModel[14]; + textareaModel.supportsMarkdownPreview = true; + textareaModel.metadataFields = ['dc.description']; + + formGroup.addControl('local_description_usemarkdown', new UntypedFormControl({ + local_description_usemarkdown_yes: false, + another_option: true + })); + + component.model = textareaModel; + component.group = formGroup; + component.ngOnChanges({ + group: new SimpleChange(null, component.group, false), + model: new SimpleChange(null, component.model, false) + }); + + expect(component.canShowMarkdownPreviewToggle()).toBe(true); + + appConfig.markdown.enabled = previousMarkdownEnabled; + }); + + it('should hide markdown preview toggle when local.description.usemarkdown object map is fully falsy', () => { + const appConfig = TestBed.inject(APP_CONFIG) as any; + const previousMarkdownEnabled = appConfig.markdown.enabled; + appConfig.markdown.enabled = true; + + const textareaModel: any = formModel[14]; + textareaModel.supportsMarkdownPreview = true; + textareaModel.metadataFields = ['dc.description']; + + formGroup.addControl('local_description_usemarkdown', new UntypedFormControl({ + local_description_usemarkdown_yes: false, + another_option: false + })); + + component.model = textareaModel; + component.group = formGroup; + component.ngOnChanges({ + group: new SimpleChange(null, component.group, false), + model: new SimpleChange(null, component.model, false) + }); + + expect(component.canShowMarkdownPreviewToggle()).toBe(false); + + appConfig.markdown.enabled = previousMarkdownEnabled; + }); + }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index f2ea9c3baf2..068eb3f2ad7 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -17,7 +17,7 @@ import { ViewChild, ViewContainerRef } from '@angular/core'; -import { UntypedFormArray, UntypedFormGroup } from '@angular/forms'; +import { AbstractControl, UntypedFormArray, UntypedFormGroup } from '@angular/forms'; import { DYNAMIC_FORM_CONTROL_TYPE_ARRAY, @@ -125,6 +125,8 @@ import { DYNAMIC_FORM_CONTROL_TYPE_AUTOCOMPLETE } from './models/autocomplete/ds import { DsDynamicSponsorAutocompleteComponent } from './models/sponsor-autocomplete/ds-dynamic-sponsor-autocomplete.component'; import { SPONSOR_METADATA_NAME } from './models/ds-dynamic-complex.model'; import { DsDynamicSponsorScrollableDropdownComponent } from './models/sponsor-scrollable-dropdown/dynamic-sponsor-scrollable-dropdown.component'; +import { DsDynamicTextAreaModel } from './models/ds-dynamic-textarea.model'; +import { MARKDOWN_DESCRIPTION_METADATA_ALLOW_LIST } from '../constants/markdown-description-metadata-allow-list'; export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type | null { switch (model.type) { @@ -210,6 +212,8 @@ export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type< changeDetection: ChangeDetectionStrategy.Default }) export class DsDynamicFormControlContainerComponent extends DynamicFormControlContainerComponent implements OnInit, OnChanges, OnDestroy { + protected readonly markdownDescriptionMetadataAllowList: string[] = MARKDOWN_DESCRIPTION_METADATA_ALLOW_LIST; + @ContentChildren(DynamicTemplateDirective) contentTemplateList: QueryList; // eslint-disable-next-line @angular-eslint/no-input-rename @Input('templates') inputTemplateList: QueryList; @@ -254,6 +258,26 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo */ fetchThumbnail: boolean; + /** + * Whether markdown preview mode is enabled for the current control. + */ + isMarkdownPreviewMode = false; + + /** + * Cached visibility flag for markdown preview toggle. + */ + markdownToggleVisible = false; + + /** + * Cached control reference for local.description.usemarkdown. + */ + private localDescriptionUseMarkdownControl: AbstractControl; + + /** + * Subscription to local.description.usemarkdown control value changes. + */ + private localDescriptionUseMarkdownSubscription: Subscription; + get componentType(): Type | null { return dsDynamicFormControlMapFn(this.model); } @@ -368,7 +392,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo } ngOnChanges(changes: SimpleChanges) { - if (changes && !this.isRelationship && hasValue(this.group.get(this.model.id))) { + if (changes && !this.isRelationship && hasValue(this.group) && hasValue(this.model) && hasValue(this.group.get(this.model.id))) { super.ngOnChanges(changes); if (this.model && this.model.placeholder) { this.model.placeholder = this.translateService.instant(this.model.placeholder); @@ -377,6 +401,8 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo this.subscriptions.push(...this.typeBindRelationService.subscribeRelations(this.model, this.control)); } } + + this.setupMarkdownToggleVisibilityBinding(); } ngDoCheck() { @@ -502,6 +528,11 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo * Unsubscribe from all subscriptions */ ngOnDestroy(): void { + if (hasValue(this.localDescriptionUseMarkdownSubscription)) { + this.localDescriptionUseMarkdownSubscription.unsubscribe(); + this.localDescriptionUseMarkdownSubscription = null; + } + this.subs .filter((sub) => hasValue(sub)) .forEach((sub) => sub.unsubscribe()); @@ -511,6 +542,225 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo return isNotEmpty(this.model.hint) && this.model.hint !== ' '; } + /** + * Checks if markdown preview toggle can be shown for this control. + */ + canShowMarkdownPreviewToggle(): boolean { + return this.markdownToggleVisible; + } + + /** + * Checks if markdown preview should be displayed. + */ + isMarkdownPreviewModeEnabled(): boolean { + return this.markdownToggleVisible && this.isMarkdownPreviewMode; + } + + /** + * Enable/disable markdown preview mode. + */ + setMarkdownPreviewMode(enabled: boolean): void { + this.isMarkdownPreviewMode = enabled; + } + + /** + * Returns current control value as a string for markdown rendering. + */ + getMarkdownPreviewValue(): string { + const value = this.control?.value ?? this.model?.value; + if (typeof value === 'string') { + return value; + } + if (hasValue(value?.value) && typeof value.value === 'string') { + return value.value; + } + if (Array.isArray(value) && value.length > 0) { + return value + .map((entry) => { + if (typeof entry === 'string') { + return entry; + } + if (hasValue(entry?.value) && typeof entry.value === 'string') { + return entry.value; + } + return ''; + }) + .filter((entry: string) => isNotEmpty(entry)) + .join('\n'); + } + return ''; + } + + /** + * Checks if the current control is an eligible textarea for markdown preview. + */ + protected isMarkdownPreviewSupported(): boolean { + if (this.model?.type !== DYNAMIC_FORM_CONTROL_TYPE_TEXTAREA) { + return false; + } + if (!this.appConfig?.markdown?.enabled) { + return false; + } + if (!this.isDescriptionTextareaField()) { + return false; + } + return this.isLocalDescriptionUseMarkdownEnabled(); + } + + /** + * Check whether this textarea is configured for one of the known description metadata fields. + */ + protected isDescriptionTextareaField(): boolean { + const textareaModel = this.model as DsDynamicTextAreaModel; + if (textareaModel?.supportsMarkdownPreview === true) { + return true; + } + + const metadataFields = this.model?.metadataFields || []; + return metadataFields.some((metadataField: string) => this.markdownDescriptionMetadataAllowList.includes(metadataField)); + } + + /** + * Check if local.description.usemarkdown exists in form state and enables markdown rendering. + */ + protected isLocalDescriptionUseMarkdownEnabled(): boolean { + const useMarkdownControl = this.localDescriptionUseMarkdownControl || this.resolveLocalDescriptionUseMarkdownControl(); + this.localDescriptionUseMarkdownControl = useMarkdownControl; + + if (!hasValue(useMarkdownControl)) { + return false; + } + + const rawValue = useMarkdownControl.value?.value ?? useMarkdownControl.value; + return this.isUseMarkdownValueEnabled(rawValue); + } + + private setupMarkdownToggleVisibilityBinding(): void { + if (!this.shouldBindMarkdownToggleVisibility()) { + this.clearMarkdownToggleVisibilityBinding(); + return; + } + + const useMarkdownControl = this.resolveLocalDescriptionUseMarkdownControl(); + const hasSameUseMarkdownControl = useMarkdownControl === this.localDescriptionUseMarkdownControl; + + this.localDescriptionUseMarkdownControl = useMarkdownControl; + this.refreshMarkdownToggleVisibility(); + + if (hasSameUseMarkdownControl) { + return; + } + + if (hasValue(this.localDescriptionUseMarkdownSubscription)) { + this.localDescriptionUseMarkdownSubscription.unsubscribe(); + this.localDescriptionUseMarkdownSubscription = null; + } + + if (!hasValue(useMarkdownControl)) { + return; + } + + this.localDescriptionUseMarkdownSubscription = useMarkdownControl.valueChanges + .pipe(startWith(useMarkdownControl.value)) + .subscribe(() => this.refreshMarkdownToggleVisibility()); + } + + private shouldBindMarkdownToggleVisibility(): boolean { + if (!this.appConfig?.markdown?.enabled) { + return false; + } + + if (this.model?.type !== DYNAMIC_FORM_CONTROL_TYPE_TEXTAREA) { + return false; + } + + if (this.model?.readOnly) { + return false; + } + + return this.isDescriptionTextareaField(); + } + + private clearMarkdownToggleVisibilityBinding(): void { + this.markdownToggleVisible = false; + this.localDescriptionUseMarkdownControl = null; + + if (hasValue(this.localDescriptionUseMarkdownSubscription)) { + this.localDescriptionUseMarkdownSubscription.unsubscribe(); + this.localDescriptionUseMarkdownSubscription = null; + } + } + + private resolveLocalDescriptionUseMarkdownControl(): AbstractControl { + return this.group?.root?.get('local_description_usemarkdown') + || this.formGroup?.root?.get('local_description_usemarkdown') + || this.group?.get('local_description_usemarkdown') + || this.findNestedControlByKey(this.group?.root, 'local_description_usemarkdown') + || this.findNestedControlByKey(this.formGroup?.root, 'local_description_usemarkdown') + || this.findNestedControlByKey(this.group, 'local_description_usemarkdown'); + } + + private refreshMarkdownToggleVisibility(): void { + this.markdownToggleVisible = this.isMarkdownPreviewSupported() && !this.model?.readOnly; + } + + private findNestedControlByKey(control: AbstractControl, key: string): AbstractControl { + if (!hasValue(control)) { + return null; + } + + if (control instanceof UntypedFormGroup) { + if (hasValue(control.controls[key])) { + return control.controls[key]; + } + + for (const childControl of Object.values(control.controls)) { + const matchingControl = this.findNestedControlByKey(childControl, key); + if (hasValue(matchingControl)) { + return matchingControl; + } + } + + return null; + } + + if (control instanceof UntypedFormArray) { + for (const childControl of control.controls) { + const matchingControl = this.findNestedControlByKey(childControl, key); + if (hasValue(matchingControl)) { + return matchingControl; + } + } + } + + return null; + } + + private isUseMarkdownValueEnabled(value: any): boolean { + if (!hasValue(value)) { + return false; + } + + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + const normalizedValue = value.toLowerCase(); + return normalizedValue === 'yes' || normalizedValue === 'true'; + } + + if (Array.isArray(value)) { + return value.some((entry) => this.isUseMarkdownValueEnabled(entry)); + } + + if (typeof value === 'object') { + return Object.values(value).some((entry) => this.isUseMarkdownValueEnabled(entry)); + } + + return false; + } + /** * Initialize this.item$ based on this.model.submissionId */ diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-textarea.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-textarea.model.ts index 00d385edefe..a36addec876 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-textarea.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-textarea.model.ts @@ -5,12 +5,14 @@ export interface DsDynamicTextAreaModelConfig extends DsDynamicInputModelConfig cols?: number; rows?: number; wrap?: string; + supportsMarkdownPreview?: boolean; } export class DsDynamicTextAreaModel extends DsDynamicInputModel { @serializable() cols: number; @serializable() rows: number; @serializable() wrap: string; + @serializable() supportsMarkdownPreview: boolean; @serializable() type = DYNAMIC_FORM_CONTROL_TYPE_TEXTAREA; constructor(config: DsDynamicTextAreaModelConfig, layout?: DynamicFormControlLayout) { @@ -19,6 +21,7 @@ export class DsDynamicTextAreaModel extends DsDynamicInputModel { this.cols = config.cols; this.rows = config.rows; this.wrap = config.wrap; + this.supportsMarkdownPreview = config.supportsMarkdownPreview; } } diff --git a/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts b/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts index 259f8a60e14..643cbdf8c62 100644 --- a/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts @@ -66,4 +66,29 @@ describe('TextareaFieldParser test suite', () => { expect(fieldModel.value).toEqual(expectedValue); }); + it('should enable markdown preview support for description metadata fields', () => { + const parser = new TextareaFieldParser(submissionId, field, initFormValues, parserOptions, translateService); + + const fieldModel = parser.parse(); + + expect(fieldModel.supportsMarkdownPreview).toBe(true); + }); + + it('should disable markdown preview support for non-description metadata fields', () => { + field.selectableMetadata = [ + { + metadata: 'dc.title', + label: 'Title', + controlledVocabulary: null, + closed: false + } + ]; + + const parser = new TextareaFieldParser(submissionId, field, initFormValues, parserOptions, translateService); + + const fieldModel = parser.parse(); + + expect(fieldModel.supportsMarkdownPreview).toBe(false); + }); + }); diff --git a/src/app/shared/form/builder/parsers/textarea-field-parser.ts b/src/app/shared/form/builder/parsers/textarea-field-parser.ts index 548ce567c3e..91eb6682ceb 100644 --- a/src/app/shared/form/builder/parsers/textarea-field-parser.ts +++ b/src/app/shared/form/builder/parsers/textarea-field-parser.ts @@ -6,8 +6,10 @@ import { DsDynamicTextAreaModelConfig } from '../ds-dynamic-form-ui/models/ds-dynamic-textarea.model'; import { environment } from '../../../../../environments/environment'; +import { MARKDOWN_DESCRIPTION_METADATA_ALLOW_LIST } from '../constants/markdown-description-metadata-allow-list'; export class TextareaFieldParser extends FieldParser { + protected readonly markdownDescriptionMetadataAllowList: string[] = MARKDOWN_DESCRIPTION_METADATA_ALLOW_LIST; public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { const textAreaModelConfig: DsDynamicTextAreaModelConfig = this.initModel(null, label); @@ -22,9 +24,17 @@ export class TextareaFieldParser extends FieldParser { textAreaModelConfig.rows = 10; textAreaModelConfig.spellCheck = environment.form.spellCheck; + textAreaModelConfig.supportsMarkdownPreview = this.isDescriptionMetadataField(textAreaModelConfig.metadataFields); this.setValues(textAreaModelConfig, fieldValue); const textAreaModel = new DsDynamicTextAreaModel(textAreaModelConfig, layout); return textAreaModel; } + + /** + * Check if any metadata field used by this textarea represents a description field. + */ + protected isDescriptionMetadataField(metadataFields: string[] = []): boolean { + return metadataFields.some((metadataField: string) => this.markdownDescriptionMetadataAllowList.includes(metadataField)); + } } diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts index dc9bf3c028e..904e181af76 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.spec.ts @@ -242,14 +242,18 @@ describe('SubmissionSectionCcLicensesComponent', () => { fixture.detectChanges(); }); - it('should call the submission cc licenses data service getCcLicenseLink method', () => { - expect(submissionCcLicenseUrlDataService.getCcLicenseLink).toHaveBeenCalledWith( - ccLicence, - new Map([ - [ccLicence.fields[0], ccLicence.fields[0].enums[1]], - [ccLicence.fields[1], ccLicence.fields[1].enums[0]], - ]) - ); + it('should call the submission cc licenses data service getCcLicenseLink method', (done) => { + setTimeout(() => { + fixture.detectChanges(); + expect(submissionCcLicenseUrlDataService.getCcLicenseLink).toHaveBeenCalledWith( + ccLicence, + new Map([ + [ccLicence.fields[0], ccLicence.fields[0].enums[1]], + [ccLicence.fields[1], ccLicence.fields[1].enums[0]], + ]) + ); + done(); + }, 350); }); it('should display a cc license link', (done) => { diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index 635847a464a..3d42ec8852c 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -2657,6 +2657,12 @@ // "form.edit": "Edit", "form.edit": "Upravit", + // "submission.form.markdown.toggle.edit": "Edit", + "submission.form.markdown.toggle.edit": "Upravit", + + // "submission.form.markdown.toggle.preview": "Preview", + "submission.form.markdown.toggle.preview": "Náhled", + // "form.edit-help": "Click here to edit the selected value", "form.edit-help": "Klikněte zde pro úpravu vybrané hodnoty", @@ -3995,6 +4001,96 @@ // "item.preview.oaire.fundingStream": "Funding Stream:", "item.preview.oaire.fundingStream": "Tok finančních prostředků:", + // "item.preview.oairecerif.identifier.url": "URL", + // TODO New key - Add a translation + "item.preview.oairecerif.identifier.url": "URL", + // "item.preview.organization.address.addressCountry": "Country", + // TODO New key - Add a translation + "item.preview.organization.address.addressCountry": "Country", + // "item.preview.organization.foundingDate": "Founding Date", + // TODO New key - Add a translation + "item.preview.organization.foundingDate": "Founding Date", + // "item.preview.organization.identifier.crossrefid": "Crossref ID", + // TODO New key - Add a translation + "item.preview.organization.identifier.crossrefid": "Crossref ID", + // "item.preview.organization.identifier.isni": "ISNI", + // TODO New key - Add a translation + "item.preview.organization.identifier.isni": "ISNI", + // "item.preview.organization.identifier.ror": "ROR ID", + // TODO New key - Add a translation + "item.preview.organization.identifier.ror": "ROR ID", + // "item.preview.organization.legalName": "Legal Name", + // TODO New key - Add a translation + "item.preview.organization.legalName": "Legal Name", + // "item.preview.dspace.entity.type": "Entity Type:", + // TODO New key - Add a translation + "item.preview.dspace.entity.type": "Entity Type:", + // "item.preview.authors.show.everyone": "Show everyone", + "item.preview.authors.show.everyone": "Zobraz všechny autory", + // "item.preview.authors.et.al": " ; et al.", + "item.preview.authors.et.al": "; et al.", + // "item.preview.loading-files": "Loading files... This may take a few seconds as file previews are being generated. If the process takes too long, please contact the system administrator", + "item.preview.loading-files": "Načítání souborů... Může to trvat několik sekund, protože se generují náhledy souborů. Pokud proces trvá příliš dlouho, kontaktujte prosím správce systému", + // "item.preview.no-preview": "The file preview has not been generated yet. Please try again later or contact the system administrator", + "item.preview.no-preview": "Náhled souboru zatím nebyl vygenerován. Zkuste to prosím znovu později, nebo kontaktujte správce systému", + // "item.refbox.modal.copy.instruction": ["Press", "ctrl + c", "to copy"], + "item.refbox.modal.copy.instruction": ["Stiskněte", "ctrl + c", "pro kopírování"], + // "item.refbox.modal.submit": "Ok", + "item.refbox.modal.submit": "Ok", + // "item.refbox.citation.featured-service.message": "Please use the following text to cite this item or export to a predefined format:", + "item.refbox.citation.featured-service.message": "Pro citování této položky použijte následující text nebo ji exportujte do předdefinovaného formátu:", + // "item.refbox.citation.bibtex.button": "bibtex", + "item.refbox.citation.bibtex.button": "bibtex", + // "item.refbox.citation.cmdi.button": "cmdi", + "item.refbox.citation.cmdi.button": "cmdi", + // "item.refbox.featured-service.heading": "This resource is also integrated in following services:", + "item.refbox.featured-service.heading": "Tento zdroj je také integrován do následujících služeb:", + // "item.refbox.featured-service.share.message": "Share", + "item.refbox.featured-service.share.message": "Sdílet", + // "item.matomo-statistics.info.message": "Click on a data point to summarize by year / month.", + "item.matomo-statistics.info.message": "Kliknutím na datový bod provedete shrnutí podle roku/měsíce.", + // "item.view.box.author.message": "Author(s):", + "item.view.box.author.message": "Autoři:", + // "item.view.box.publisher.message": "Publisher:", + "item.view.box.publisher.message": "Nakladatel:", + // "item.view.box.files.message": ["This item contains", "files"], + "item.view.box.files.message": ["Tento záznam obsahuje", "souborů"], + // "item.view.box.one.file.message": "This item contains 1 file", + "item.view.box.one.file.message": "Tento záznam obsahuje 1 soubor", + // "item.view.box.no-files.message": "This item contains no files.", + "item.view.box.no-files.message": "Tento záznam neobsahuje soubory.", + // "item.view.box.markdown.formatted.text": "Markdown formatted text", + "item.view.box.markdown.formatted.text": "Text ve formátu Markdown", + // "item.view.box.description.message": "Description:", + "item.view.box.description.message": "Popis:", + // "item.view.box.author.preview.and": "and:", + "item.view.box.author.preview.and": "a", + // "item.view.box.author.preview.show-everyone": "show everyone:", + "item.view.box.author.preview.show-everyone": "zobraz všechny autory", + // "item.file.description.not.supported.video": "Your browser does not support the video tag.", + "item.file.description.not.supported.video": "Váš prohlížeč nepodporuje videa.", + // "item.file.description.name": "Name", + "item.file.description.name": "Název", + // "item.file.description.size": "Size", + "item.file.description.size": "Velikost", + // "item.file.description.format": "Format", + "item.file.description.format": "Formát", + // "item.file.description.description": "Description", + "item.file.description.description": "Popis", + // "item.file.description.checksum": "MD5", + "item.file.description.checksum": "MD5", + // "item.file.description.download.file": "Download file", + "item.file.description.download.file": "Stáhnout soubor", + // "item.file.description.preview": "Preview", + "item.file.description.preview": "Náhled", + // "item.file.description.file.preview": "File Preview", + "item.file.description.file.preview": "Náhled souboru", + // "item.page.files.head": "Files in this item", + "item.page.files.head": "Soubory tohoto záznamu", + // "item.page.download.button.command.line": "Download instructions for command line", + "item.page.download.button.command.line": "Instrukce pro stažení z příkazové řádky", + // "item.page.download.button.all.files.zip": "Download all files in item", + "item.page.download.button.all.files.zip": "Stáhnout všechny soubory záznamu", // "item.select.confirm": "Confirm selected", "item.select.confirm": "Potvrdit vybrané", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 77e9cb4497d..5bbe4a459a9 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1771,6 +1771,10 @@ "form.edit": "Edit", + "submission.form.markdown.toggle.edit": "Edit", + + "submission.form.markdown.toggle.preview": "Preview", + "form.edit-help": "Click here to edit the selected value", "form.first-name": "First name", @@ -2663,6 +2667,75 @@ "item.preview.oaire.fundingStream": "Funding Stream:", + "item.preview.authors.show.everyone": "Show everyone", + + "item.preview.authors.et.al": " ; et al.", + + "item.preview.loading-files": "Loading files... This may take a few seconds as file previews are being generated. If the process takes too long, please contact the system administrator", + + "item.preview.no-preview": "The file preview has not been generated yet. Please try again later or contact the system administrator", + + "item.refbox.modal.copy.instruction": ["Press", "ctrl + c", "to copy"], + + "item.refbox.modal.submit": "Ok", + + "item.refbox.citation.featured-service.message": "Please use the following text to cite this item or export to a predefined format:", + + "item.refbox.citation.bibtex.button": "bibtex", + + "item.refbox.citation.cmdi.button": "cmdi", + + "item.refbox.featured-service.heading": "This resource is also integrated in following services:", + + "item.refbox.featured-service.share.message": "Share", + + "item.matomo-statistics.info.message": "Click on a data point to summarize by year / month.", + + + "item.view.box.author.message": "Author(s):", + + "item.view.box.publisher.message": "Publisher:", + + "item.view.box.files.message": ["This item contains", "files"], + + "item.view.box.one.file.message": "This item contains 1 file", + + "item.view.box.no-files.message": "This item contains no files.", + + "item.view.box.markdown.formatted.text": "Markdown formatted text", + + "item.view.box.description.message": "Description:", + + "item.view.box.author.preview.and": "and", + + "item.view.box.author.preview.show-everyone": "show everyone", + + + "item.file.description.not.supported.video": "Your browser does not support the video tag.", + + "item.file.description.name": "Name", + + "item.file.description.size": "Size", + + "item.file.description.format": "Format", + + "item.file.description.description": "Description", + + "item.file.description.checksum": "MD5", + + "item.file.description.download.file": "Download file", + + "item.file.description.preview": "Preview", + + "item.file.description.file.preview": "File Preview", + + "item.page.files.head": "Files in this item", + + "item.page.download.button.command.line": "Download instructions for command line", + + "item.page.download.button.all.files.zip": "Download all files in item", + + "item.select.confirm": "Confirm selected", "item.select.empty": "No items to show", diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 38cb942e457..3f6f2b49461 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -409,10 +409,11 @@ export class DefaultAppConfig implements AppConfig { }; // Whether to enable Markdown (https://commonmark.org/) and MathJax (https://www.mathjax.org/) - // display in supported metadata fields. By default, only dc.description.abstract is supported. + // display in supported metadata fields. By default, allowlisted description fields + // (description, dc.description, dc.description.abstract) are supported. markdown: MarkdownConfig = { enabled: false, - mathjax: false, + mathjax: false }; // Which vocabularies should be used for which search filters