Repository: geonetwork/geonetwork-ui
Pull request #1237: [Editor]: Change default language of record
Author: login: cmoinier
Created: 2025-05-09T15:26:46Z | Merged: 2025-05-21T20:27:24Z

### Description

This PR introduces a switch between the supported languages of a record to save a new default language, which will be used when the record's form is opened (for the multilingual fields).

:warning: Most of the commits are from https://github.com/geonetwork/geonetwork-ui/pull/1229 which needs merging first. The commits from this PR are the last 4 ones.

### Architectural changes

no changes

### Screenshots

no UI changes

### Quality Assurance Checklist

- [ ] Commit history is devoid of any _merge commits_ and readable to facilitate reviews
- [ ] If **new logic** ⚙️ is introduced: unit tests were added
- [ ] If **new user stories** 🤏 are introduced: E2E tests were added
- [ ] If **new UI components** 🕹️ are introduced: corresponding stories in Storybook were created
- [ ] If **breaking changes** 🪚 are introduced: add the `breaking change` label
- [ ] If **bugs** 🐞 are fixed: add the `backport <release branch>` label
- [ ] The [documentation website](docs) 📚 has received the love it deserves

<!--
Please only check items relevant to your contribution. Thank you very much for your time and efforts!
-->

### How to test

Open a record with existing multilingual (like `edit/8698bf0b-fceb-4f0f-989b-111e7c4af0a4` which is pretty complete). Switch the language, save and reload, and check that the multilingual fields are using the right translation. Mind you, sometimes the translations are the exact same ones no matter the language so make sure to check multiple fields and/or compare with the elastic search response.

:warning: The keywords disappear on reload after switching the language. That's known & a ticket will be created for that.


Diff:
diff --git a/apps/metadata-editor-e2e/src/e2e/edit/multilingual.cy.ts b/apps/metadata-editor-e2e/src/e2e/edit/multilingual.cy.ts
index c03029581f..b5f494b41b 100644
--- a/apps/metadata-editor-e2e/src/e2e/edit/multilingual.cy.ts
+++ b/apps/metadata-editor-e2e/src/e2e/edit/multilingual.cy.ts
@@ -1,90 +1,146 @@
 describe('Multilingual panel', () => {
   let recordUuid: any
+  describe('Add and remove languages', () => {
+    before(() => {
+      cy.editor_createRecordCopy().then((uuid) => {
+        recordUuid = uuid
+      })
+    })
 
-  before(() => {
-    cy.editor_createRecordCopy().then((uuid) => {
-      recordUuid = uuid
+    beforeEach(() => {
+      cy.login('admin', 'admin', false)
+      cy.visit(`/edit/${recordUuid}`)
     })
-  })
 
-  beforeEach(() => {
-    cy.login('admin', 'admin', false)
-    cy.visit(`/edit/${recordUuid}`)
-  })
+    it('Opens the multilingual panel and shows the switch button, no edit menu access', () => {
+      cy.get('gn-ui-multilingual-panel').should('not.exist')
+      cy.get('md-editor-top-toolbar').find('gn-ui-button').eq(1).click()
+      cy.get('gn-ui-multilingual-panel').should('be.visible')
 
-  it('Opens the multilingual panel and shows the switch button, no edit menu access', () => {
-    cy.get('gn-ui-multilingual-panel').should('not.exist')
-    cy.get('md-editor-top-toolbar').find('gn-ui-button').eq(1).click()
-    cy.get('gn-ui-multilingual-panel').should('be.visible')
+      cy.get('gn-ui-multilingual-panel')
+        .find('gn-ui-check-toggle')
+        .should('have.attr', 'ng-reflect-value', 'false')
+      cy.get('[data-test="activateSelection"]').should('not.exist')
+    })
+    describe('Switching to multilingual', () => {
+      describe('Language addition', () => {
+        it('should show the available languages when switching to multilingual', () => {
+          cy.get('md-editor-top-toolbar').find('gn-ui-button').eq(1).click()
+          cy.get('gn-ui-multilingual-panel').find('gn-ui-check-toggle').click()
+          cy.get('gn-ui-multilingual-panel')
+            .find('[data-test="langAvailable"]')
+            .should('have.length', 21)
+        })
+        it('should access the editor menu and persist the new languages selection on reload', () => {
+          cy.get('md-editor-top-toolbar').find('gn-ui-button').eq(1).click()
+          cy.get('gn-ui-multilingual-panel').find('gn-ui-check-toggle').click()
+          cy.editor_addLanguages(recordUuid)
 
-    cy.get('gn-ui-multilingual-panel')
-      .find('gn-ui-check-toggle')
-      .should('have.attr', 'ng-reflect-value', 'false')
-    cy.get('[data-test="activateSelection"]').should('not.exist')
-  })
-  describe('Switching to multilingual', () => {
-    describe('Language addition', () => {
-      it('should show the available languages when switching to multilingual', () => {
-        cy.get('md-editor-top-toolbar').find('gn-ui-button').eq(1).click()
-        cy.get('gn-ui-multilingual-panel').find('gn-ui-check-toggle').click()
-        cy.get('gn-ui-multilingual-panel')
-          .find('[data-test="langAvailable"]')
-          .should('have.length', 21)
+          cy.get('[data-test="langSwitch"]').should('have.length', 3)
+        })
       })
-      it('should access the editor menu and persist the new languages selection on reload', () => {
-        cy.get('md-editor-top-toolbar').find('gn-ui-button').eq(1).click()
-        cy.get('gn-ui-multilingual-panel').find('gn-ui-check-toggle').click()
-        cy.editor_addLanguages(recordUuid)
-
-        cy.get('[data-test="langSwitch"]').should('have.length', 3)
+      describe('Language removal', () => {
+        it('should ask for confirmation when removing languages and keep the selection if cancelled', () => {
+          cy.get('md-editor-top-toolbar').find('gn-ui-button').eq(1).click()
+          cy.get('gn-ui-multilingual-panel')
+            .find('[data-test="activateSelection"]')
+            .click()
+          cy.get('[data-test="langAvailable"]').eq(3).click()
+          cy.get('[data-test="validateSelection"').click()
+          cy.get('gn-ui-confirmation-dialog')
+            .find('gn-ui-button')
+            .first()
+            .click()
+          cy.get('[data-test="langSwitch"]').should('have.length', 3)
+        })
+        it('should remove languages from the record and persist on reload', () => {
+          cy.get('md-editor-top-toolbar').find('gn-ui-button').eq(1).click()
+          cy.get('gn-ui-multilingual-panel')
+            .find('[data-test="activateSelection"]')
+            .click()
+          cy.get('[data-test="langAvailable"]').eq(3).click()
+          cy.editor_wrapPreviousDraft(recordUuid)
+          cy.get('[data-test="validateSelection"').click()
+          cy.get('gn-ui-confirmation-dialog').find('gn-ui-button').eq(1).click()
+          cy.editor_publishAndReload(recordUuid)
+          cy.get('md-editor-top-toolbar').find('gn-ui-button').eq(1).click()
+          cy.get('[data-test="langSwitch"]').should('have.length', 2)
+        })
       })
     })
-    describe('Language removal', () => {
-      it('should ask for confirmation when removing languages and keep the selection if cancelled', () => {
+    describe('Switching back to monolingual', () => {
+      it('should ask for confirmation and keep the selection if cancelled', () => {
         cy.get('md-editor-top-toolbar').find('gn-ui-button').eq(1).click()
+        cy.get('[data-test="langSwitch"]').should('have.length', 2)
         cy.get('gn-ui-multilingual-panel')
           .find('[data-test="activateSelection"]')
           .click()
-        cy.get('[data-test="langAvailable"]').eq(3).click()
-        cy.get('[data-test="validateSelection"').click()
+        cy.get('gn-ui-multilingual-panel').find('gn-ui-check-toggle').click()
         cy.get('gn-ui-confirmation-dialog').find('gn-ui-button').first().click()
-        cy.get('[data-test="langSwitch"]').should('have.length', 3)
+        cy.get('[data-test="langSwitch"]').should('have.length', 2)
       })
-      it('should remove languages from the record and persist on reload', () => {
+      it('should remove all languages', () => {
         cy.get('md-editor-top-toolbar').find('gn-ui-button').eq(1).click()
+        cy.get('[data-test="langSwitch"]').should('have.length', 2)
         cy.get('gn-ui-multilingual-panel')
           .find('[data-test="activateSelection"]')
           .click()
-        cy.get('[data-test="langAvailable"]').eq(3).click()
-        cy.editor_wrapPreviousDraft(recordUuid)
-        cy.get('[data-test="validateSelection"').click()
+        cy.get('gn-ui-multilingual-panel').find('gn-ui-check-toggle').click()
         cy.get('gn-ui-confirmation-dialog').find('gn-ui-button').eq(1).click()
-        cy.editor_publishAndReload(recordUuid)
-        cy.get('md-editor-top-toolbar').find('gn-ui-button').eq(1).click()
-        cy.get('[data-test="langSwitch"]').should('have.length', 2)
+        cy.get('[data-test="langSwitch"]').should('not.exist')
       })
     })
   })
-  describe('Switching back to monolingual', () => {
-    it('should ask for confirmation and keep the selection if cancelled', () => {
+  describe('Switching default language', () => {
+    const multilingualRecord = '8698bf0b-fceb-4f0f-989b-111e7c4af0a4'
+    before(() => {
+      cy.login('admin', 'admin', false)
+      cy.visit(`/edit/${multilingualRecord}`)
       cy.get('md-editor-top-toolbar').find('gn-ui-button').eq(1).click()
-      cy.get('[data-test="langSwitch"]').should('have.length', 2)
+      cy.get('[data-test="langSwitch"]')
+        .eq(2)
+        .find('span')
+        .eq(2)
+        .should('have.text', 'Default')
+      cy.get('[data-test="activateSelection"]').click()
       cy.get('gn-ui-multilingual-panel')
-        .find('[data-test="activateSelection"]')
-        .click()
-      cy.get('gn-ui-multilingual-panel').find('gn-ui-check-toggle').click()
-      cy.get('gn-ui-confirmation-dialog').find('gn-ui-button').first().click()
-      cy.get('[data-test="langSwitch"]').should('have.length', 2)
+        .find('[data-test="langAvailable"]')
+        .eq(9)
+        .find('gn-ui-button')
+        .should('have.attr', 'ng-reflect-disabled', 'true')
+      cy.get('gn-ui-form-field[ng-reflect-model=title] textarea')
+        .invoke('val')
+        .should('eq', 'Alpenkonvention')
+      cy.editor_wrapPreviousDraft(multilingualRecord)
+      cy.get('[data-test="activateSelection"]').click()
+      cy.get('[data-test="langSwitch"]').eq(1).find('button').eq(1).click()
+      cy.get('gn-ui-action-menu').find('gn-ui-button').first().click()
+      cy.editor_publishAndReload(multilingualRecord)
     })
-    it('should remove all languages', () => {
+    beforeEach(() => {
+      cy.login('admin', 'admin', false)
+      cy.visit(`/edit/${multilingualRecord}`)
       cy.get('md-editor-top-toolbar').find('gn-ui-button').eq(1).click()
-      cy.get('[data-test="langSwitch"]').should('have.length', 2)
+    })
+    it('should display the default label next to the new language', () => {
+      cy.get('[data-test="langSwitch"]')
+        .eq(1)
+        .find('span')
+        .eq(2)
+        .should('have.text', 'Default')
+    })
+    it('should not allow to delete the new default language', () => {
+      cy.get('[data-test="activateSelection"]').click()
       cy.get('gn-ui-multilingual-panel')
-        .find('[data-test="activateSelection"]')
-        .click()
-      cy.get('gn-ui-multilingual-panel').find('gn-ui-check-toggle').click()
-      cy.get('gn-ui-confirmation-dialog').find('gn-ui-button').eq(1).click()
-      cy.get('[data-test="langSwitch"]').should('not.exist')
+        .find('[data-test="langAvailable"]')
+        .eq(8)
+        .find('gn-ui-button')
+        .should('have.attr', 'ng-reflect-disabled', 'true')
+    })
+    it('should display the translated fields in the right language', () => {
+      cy.get('gn-ui-form-field[ng-reflect-model=title] textarea')
+        .invoke('val')
+        .should('eq', 'Convention des Alpes')
     })
   })
 })
diff --git a/libs/feature/editor/src/lib/components/multilingual-panel/multilingual-panel.component.html b/libs/feature/editor/src/lib/components/multilingual-panel/multilingual-panel.component.html
index 1a92adb421..2c3b2418eb 100644
--- a/libs/feature/editor/src/lib/components/multilingual-panel/multilingual-panel.component.html
+++ b/libs/feature/editor/src/lib/components/multilingual-panel/multilingual-panel.component.html
@@ -110,7 +110,7 @@
             page="record"
             (delete)="confirmDeleteAction(recordLang)"
             (closeActionMenu)="closeActionMenu()"
-            (switch)="switchDefaultLang(item)"
+            (switch)="switchDefaultLang(recordLang)"
           >
           </gn-ui-action-menu>
         </ng-template>
diff --git a/libs/feature/editor/src/lib/components/multilingual-panel/multilingual-panel.component.spec.ts b/libs/feature/editor/src/lib/components/multilingual-panel/multilingual-panel.component.spec.ts
index 1b5241095c..1e8ca92715 100644
--- a/libs/feature/editor/src/lib/components/multilingual-panel/multilingual-panel.component.spec.ts
+++ b/libs/feature/editor/src/lib/components/multilingual-panel/multilingual-panel.component.spec.ts
@@ -1,4 +1,4 @@
-import { TestBed } from '@angular/core/testing'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
 import { MultilingualPanelComponent } from './multilingual-panel.component'
 import { TranslateModule } from '@ngx-translate/core'
 import { of } from 'rxjs'
@@ -11,6 +11,7 @@ class RecordsRepositoryMock {
 
 describe('MultilingualPanelComponent (logic only)', () => {
   let component: MultilingualPanelComponent
+  let fixture: ComponentFixture<MultilingualPanelComponent>
   let facadeMock: jest.Mocked<EditorFacade>
   let recordsRepository: RecordsRepositoryInterface
 
@@ -24,7 +25,7 @@ describe('MultilingualPanelComponent (logic only)', () => {
       updateRecordField: jest.fn(),
     } as any
 
-    await TestBed.configureTestingModule({
+    TestBed.configureTestingModule({
       imports: [MultilingualPanelComponent, TranslateModule.forRoot()],
       providers: [
         { provide: EditorFacade, useValue: facadeMock },
@@ -35,11 +36,11 @@ describe('MultilingualPanelComponent (logic only)', () => {
       ],
     }).compileComponents()
 
-    const fixture = TestBed.createComponent(MultilingualPanelComponent)
     recordsRepository = TestBed.inject(RecordsRepositoryInterface)
+    fixture = TestBed.createComponent(MultilingualPanelComponent)
     component = fixture.componentInstance
-
     component.record = mockRecord
+    fixture.detectChanges()
   })
 
   describe('initialisation', () => {
@@ -95,6 +96,23 @@ describe('MultilingualPanelComponent (logic only)', () => {
     })
   })
 
+  describe('Default language switching', () => {
+    beforeEach(() => {
+      component.switchDefaultLang('es')
+      fixture.detectChanges()
+    })
+    it('should set the default language and the otherLanguages accordingly', () => {
+      expect(facadeMock.updateRecordField).toHaveBeenCalledWith(
+        'defaultLanguage',
+        'es'
+      )
+      expect(facadeMock.updateRecordField).toHaveBeenCalledWith(
+        'otherLanguages',
+        ['fr', 'en']
+      )
+    })
+  })
+
   describe('language utilities', () => {
     it('should sort languages by translated label', () => {
       const sorted = component.sortLanguages(['fr', 'en', 'es'])
diff --git a/libs/feature/editor/src/lib/components/multilingual-panel/multilingual-panel.component.ts b/libs/feature/editor/src/lib/components/multilingual-panel/multilingual-panel.component.ts
index 215c9cc316..c933ad2e9c 100644
--- a/libs/feature/editor/src/lib/components/multilingual-panel/multilingual-panel.component.ts
+++ b/libs/feature/editor/src/lib/components/multilingual-panel/multilingual-panel.component.ts
@@ -208,6 +208,7 @@ export class MultilingualPanelComponent implements OnDestroy {
       'otherLanguages',
       this.selectedLanguages.filter((lang) => lang !== this.formLanguage)
     )
+    this.closeActionMenu()
   }
 
   confirmDeleteAction(lang?: string[] | string) {


Reviews:
user: login: tkohr
body: Thanks @cmoinier! PR works really well! I've left most comments in the preceding PR. Not much to add here. 

If you do not want all commits to be part of this PR and have a smaller diff you can also set your other branch as base branch for this PR:-)
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-21T14:40:10Z
user: login: cmoinier
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-21T18:59:00Z

Review comments:
diff_hunk: @@ -394,3 +394,13 @@ export function readOtherLanguages(rootEl: XmlElement): LanguageCode[] {
     )
   )(rootEl)
 }
+
+export function readRawLanguageCode(): ChainableFunction<
+  XmlElement,
+  string | null
+> {
+  return pipe(
+    findChildElement('lan:LanguageCode'),
+    readAttribute('codeListValue')
+  )
+}
path: libs/api/metadata-converter/src/lib/iso19115-3/read-parts.ts
user: login: tkohr
body: Same as in the preceding PR, I think we should have unit tests for changes here.
created_at: 2025-05-21T14:40:07Z
author_association: COLLABORATOR
diff_hunk: @@ -394,3 +394,13 @@ export function readOtherLanguages(rootEl: XmlElement): LanguageCode[] {
     )
   )(rootEl)
 }
+
+export function readRawLanguageCode(): ChainableFunction<
+  XmlElement,
+  string | null
+> {
+  return pipe(
+    findChildElement('lan:LanguageCode'),
+    readAttribute('codeListValue')
+  )
+}
path: libs/api/metadata-converter/src/lib/iso19115-3/read-parts.ts
user: login: cmoinier
body: Done in previous PR :) (and actually this function is not there anymore)
created_at: 2025-05-21T18:59:00Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-09T15:28:48Z
body: **Affected libs:**
feature-editor

**Affected apps:**
metadata-editor,demo

- [ ] 🚀 Build and deploy storybook and demo on GitHub Pages
- [ ] 📦 Build and push affected docker images
<!-- thollander/actions-comment-pull-request "build-options" -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-09T15:40:10Z
body: 📷 Screenshots are [here](https://github.com/geonetwork/geonetwork-ui/actions/runs/15170837353/artifacts/3171662429)!
<!-- thollander/actions-comment-pull-request "screenshots-url" -->
author_association: CONTRIBUTOR
user: login: coveralls
created_at: 2025-05-09T15:40:27Z
body: 
[![Coverage Status](https://coveralls.io/builds/73784886/badge)](https://coveralls.io/builds/73784886)

coverage: 81.802% (-2.4%) from 84.251%
when pulling **549374085d107fe65b6b1e8dc032021452ee87c7 on change-default-lang**
into **600519f47df7ff38da561ceee62536d1962339b4 on main**.

author_association: NONE