diff --git a/src/app/features/project/wiki/wiki.component.html b/src/app/features/project/wiki/wiki.component.html index d3b1d94eb..ff8a434b5 100644 --- a/src/app/features/project/wiki/wiki.component.html +++ b/src/app/features/project/wiki/wiki.component.html @@ -36,6 +36,7 @@ [canEdit]="hasWriteAccess()" (createWiki)="onCreateWiki()" (deleteWiki)="onDeleteWiki()" + (renameWiki)="onRenameWiki()" > @if (wikiModes().view) { this.navigateToWiki(this.currentWikiId()))); } diff --git a/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.html b/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.html new file mode 100644 index 000000000..47d0c2771 --- /dev/null +++ b/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.html @@ -0,0 +1,18 @@ +
+ + + +
+ + +
+
diff --git a/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.scss b/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.spec.ts b/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.spec.ts new file mode 100644 index 000000000..56399a64d --- /dev/null +++ b/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.spec.ts @@ -0,0 +1,121 @@ +import { MockComponent, MockProvider } from 'ng-mocks'; + +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ToastService } from '@osf/shared/services/toast.service'; +import { WikiSelectors } from '@osf/shared/stores/wiki'; + +import { TextInputComponent } from '../../text-input/text-input.component'; + +import { RenameWikiDialogComponent } from './rename-wiki-dialog.component'; + +import { TranslateServiceMock } from '@testing/mocks/translate.service.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('RenameWikiDialogComponent', () => { + let component: RenameWikiDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RenameWikiDialogComponent, MockComponent(TextInputComponent)], + providers: [ + TranslateServiceMock, + MockProvider(DynamicDialogRef), + MockProvider(DynamicDialogConfig, { + data: { + resourceId: 'project-123', + wikiName: 'Wiki Name', + }, + }), + MockProvider(ToastService), + provideMockStore({ + selectors: [{ selector: WikiSelectors.getWikiSubmitting, value: false }], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RenameWikiDialogComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize form with current name', () => { + expect(component.renameWikiForm.get('name')?.value).toBe('Wiki Name'); + }); + + it('should have required validation on name field', () => { + const nameControl = component.renameWikiForm.get('name'); + nameControl?.setValue(''); + + expect(nameControl?.hasError('required')).toBe(true); + }); + + it('should validate name field with valid input', () => { + const nameControl = component.renameWikiForm.get('name'); + nameControl?.setValue('Test Wiki Name'); + + expect(nameControl?.valid).toBe(true); + }); + + it('should validate name field with whitespace only', () => { + const nameControl = component.renameWikiForm.get('name'); + nameControl?.setValue(' '); + + expect(nameControl?.hasError('required')).toBe(true); + }); + + it('should validate name field with max length', () => { + const nameControl = component.renameWikiForm.get('name'); + const longName = 'a'.repeat(256); + nameControl?.setValue(longName); + + expect(nameControl?.hasError('maxlength')).toBe(true); + }); + + it('should close dialog on cancel', () => { + const dialogRef = TestBed.inject(DynamicDialogRef); + const closeSpy = jest.spyOn(dialogRef, 'close'); + + dialogRef.close(); + + expect(closeSpy).toHaveBeenCalled(); + }); + + it('should not submit form when invalid', () => { + const dialogRef = TestBed.inject(DynamicDialogRef); + const toastService = TestBed.inject(ToastService); + + const closeSpy = jest.spyOn(dialogRef, 'close'); + const showSuccessSpy = jest.spyOn(toastService, 'showSuccess'); + + component.renameWikiForm.patchValue({ name: '' }); + + component.submitForm(); + + expect(showSuccessSpy).not.toHaveBeenCalled(); + expect(closeSpy).not.toHaveBeenCalled(); + }); + + it('should handle form submission with empty name', () => { + const dialogRef = TestBed.inject(DynamicDialogRef); + const toastService = TestBed.inject(ToastService); + + const closeSpy = jest.spyOn(dialogRef, 'close'); + const showSuccessSpy = jest.spyOn(toastService, 'showSuccess'); + + component.renameWikiForm.patchValue({ name: ' ' }); + + component.submitForm(); + + expect(showSuccessSpy).not.toHaveBeenCalled(); + expect(closeSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.ts b/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.ts new file mode 100644 index 000000000..11d905b65 --- /dev/null +++ b/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.ts @@ -0,0 +1,56 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { InputLimits } from '@osf/shared/constants/input-limits.const'; +import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { RenameWiki, WikiSelectors } from '@osf/shared/stores/wiki'; + +import { TextInputComponent } from '../../text-input/text-input.component'; + +@Component({ + selector: 'osf-rename-wiki-dialog-component', + imports: [Button, ReactiveFormsModule, TranslatePipe, TextInputComponent], + templateUrl: './rename-wiki-dialog.component.html', + styleUrl: './rename-wiki-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RenameWikiDialogComponent { + readonly dialogRef = inject(DynamicDialogRef); + readonly config = inject(DynamicDialogConfig); + private toastService = inject(ToastService); + + actions = createDispatchMap({ renameWiki: RenameWiki }); + isSubmitting = select(WikiSelectors.getWikiSubmitting); + inputLimits = InputLimits; + + renameWikiForm = new FormGroup({ + name: new FormControl(this.config.data.wikiName, { + nonNullable: true, + validators: [CustomValidators.requiredTrimmed(), Validators.maxLength(InputLimits.fullName.maxLength)], + }), + }); + + submitForm(): void { + if (this.renameWikiForm.valid) { + this.actions.renameWiki(this.config.data.wikiId, this.renameWikiForm.value.name ?? '').subscribe({ + next: () => { + this.toastService.showSuccess('project.wiki.renameWikiSuccess'); + this.dialogRef.close(true); + }, + error: (err) => { + if (err?.status === 409) { + this.toastService.showError('project.wiki.renameWikiConflict'); + } + }, + }); + } + } +} diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.html b/src/app/shared/components/wiki/wiki-list/wiki-list.component.html index 22645ceb6..41f09c1c1 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.html +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.html @@ -62,9 +62,20 @@

{{ item.label | translate }}

{{ item.label | translate }} } @default { -
- - {{ item.label }} +
+
+ + {{ item.label }} +
+ + +
} } diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.scss b/src/app/shared/components/wiki/wiki-list/wiki-list.component.scss index 49c80f9ae..74bdcf60c 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.scss +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.scss @@ -7,9 +7,4 @@ min-width: 300px; width: 300px; } - - .active { - background-color: var(--pr-blue-1); - color: var(--white); - } } diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts b/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts index 7c55dec8e..97f1b7baa 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts @@ -14,6 +14,7 @@ import { WikiItemType } from '@osf/shared/models/wiki/wiki-type.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ComponentWiki } from '@osf/shared/stores/wiki'; +import { RenameWikiDialogComponent } from '@shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component'; import { AddWikiDialogComponent } from '../add-wiki-dialog/add-wiki-dialog.component'; @@ -35,6 +36,7 @@ export class WikiListComponent { readonly deleteWiki = output(); readonly createWiki = output(); + readonly renameWiki = output(); private readonly customDialogService = inject(CustomDialogService); private readonly customConfirmationService = inject(CustomConfirmationService); @@ -97,6 +99,19 @@ export class WikiListComponent { .onClose.subscribe(() => this.createWiki.emit()); } + openRenameWikiDialog(wikiId: string, wikiName: string) { + this.customDialogService + .open(RenameWikiDialogComponent, { + header: 'project.wiki.renameWiki', + width: '448px', + data: { + wikiId: wikiId, + wikiName: wikiName, + }, + }) + .onClose.subscribe(() => this.renameWiki.emit()); + } + openDeleteWikiDialog(): void { this.customConfirmationService.confirmDelete({ headerKey: 'project.wiki.deleteWiki', diff --git a/src/app/shared/services/wiki.service.ts b/src/app/shared/services/wiki.service.ts index f2d02a306..0909d7d96 100644 --- a/src/app/shared/services/wiki.service.ts +++ b/src/app/shared/services/wiki.service.ts @@ -64,6 +64,21 @@ export class WikiService { .pipe(map((response) => WikiMapper.fromCreateWikiResponse(response.data))); } + renameWiki(id: string, name: string): Observable { + const body = { + data: { + type: 'wikis', + attributes: { + id, + name, + }, + }, + }; + return this.jsonApiService + .patch(`${this.apiUrl}/wikis/${id}/`, body) + .pipe(map((response) => WikiMapper.fromCreateWikiResponse(response))); + } + deleteWiki(wikiId: string): Observable { return this.jsonApiService.delete(`${this.apiUrl}/wikis/${wikiId}/`); } diff --git a/src/app/shared/stores/wiki/wiki.actions.ts b/src/app/shared/stores/wiki/wiki.actions.ts index c143793fc..3d0ec159d 100644 --- a/src/app/shared/stores/wiki/wiki.actions.ts +++ b/src/app/shared/stores/wiki/wiki.actions.ts @@ -11,6 +11,15 @@ export class CreateWiki { ) {} } +export class RenameWiki { + static readonly type = '[Wiki] Rename Wiki'; + + constructor( + public wikiId: string, + public name: string + ) {} +} + export class DeleteWiki { static readonly type = '[Wiki] Delete Wiki'; constructor(public wikiId: string) {} diff --git a/src/app/shared/stores/wiki/wiki.state.ts b/src/app/shared/stores/wiki/wiki.state.ts index 86fbf7fd4..6c0b109ca 100644 --- a/src/app/shared/stores/wiki/wiki.state.ts +++ b/src/app/shared/stores/wiki/wiki.state.ts @@ -17,6 +17,7 @@ import { GetWikiList, GetWikiVersionContent, GetWikiVersions, + RenameWiki, SetCurrentWiki, ToggleMode, UpdateWikiPreviewContent, @@ -55,6 +56,32 @@ export class WikiState { ); } + @Action(RenameWiki) + renameWiki(ctx: StateContext, action: RenameWiki) { + const state = ctx.getState(); + ctx.patchState({ + wikiList: { + ...state.wikiList, + isSubmitting: true, + }, + }); + return this.wikiService.renameWiki(action.wikiId, action.name).pipe( + tap((wiki) => { + const updatedWiki = wiki.id === action.wikiId ? { ...wiki, name: action.name } : wiki; + const updatedList = state.wikiList.data.map((w) => (w.id === updatedWiki.id ? updatedWiki : w)); + ctx.patchState({ + wikiList: { + ...state.wikiList, + data: [...updatedList], + isSubmitting: false, + }, + currentWikiId: updatedWiki.id, + }); + }), + catchError((error) => this.handleError(ctx, error)) + ); + } + @Action(DeleteWiki) deleteWiki(ctx: StateContext, action: DeleteWiki) { const state = ctx.getState(); diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 11e832513..9e8797629 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -899,10 +899,13 @@ }, "wiki": { "addNewWiki": "Add new wiki page", + "renameWiki": "Rename wiki page", "deleteWiki": "Delete wiki page", "deleteWikiMessage": "Are you sure you want to delete this wiki page?", "addNewWikiPlaceholder": "New wiki name", "addWikiSuccess": "Wiki page has been added successfully", + "renameWikiSuccess": "Wiki page has been renamed successfully", + "renameWikiConflict": "That wiki name already exists.", "view": "View", "edit": "Edit", "compare": "Compare", @@ -1906,8 +1909,7 @@ "emailPlaceholder": "email@example.com" }, "buttons": { - "cancel": "Cancel", - "add": "Add" + "cancel": "Cancel" }, "messages": { "success": "Alternative email added successfully",