Browse Source

fix: menu item modal component problems

pull/24234/head
sumeyye 9 months ago
parent
commit
3c587b24e6
  1. 92
      npm/ng-packs/packages/cms-kit/admin/src/components/menus/menu-item-modal/menu-item-modal.component.html
  2. 375
      npm/ng-packs/packages/cms-kit/admin/src/components/menus/menu-item-modal/menu-item-modal.component.ts
  3. 15
      npm/ng-packs/packages/cms-kit/admin/src/defaults/menus/default-menu-item-create-form-props.ts

92
npm/ng-packs/packages/cms-kit/admin/src/components/menus/menu-item-modal/menu-item-modal.component.html

@ -12,27 +12,99 @@
<ng-template #abpBody>
@if (form) {
<form [formGroup]="form" (ngSubmit)="save()" validateOnSubmit>
<ul ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeTab">
<li ngbNavItem>
<ul
ngbNav
#nav="ngbNav"
class="nav-tabs"
[(activeId)]="activeTab"
(activeIdChange)="onTabChange($event)"
>
<li ngbNavItem="url">
<a ngbNavLink>{{ 'CmsKit::Url' | abpLocalization }}</a>
<ng-template ngbNavContent>
<div class="mt-3">
<label class="form-label">{{ 'CmsKit::Url' | abpLocalization }}</label>
<input type="text" class="form-control" formControlName="url" />
<input
type="text"
class="form-control"
formControlName="url"
[disabled]="isPageSelected"
(input)="onUrlInput()"
/>
</div>
</ng-template>
</li>
<li ngbNavItem>
<li ngbNavItem="page">
<a ngbNavLink>{{ 'CmsKit::Page' | abpLocalization }}</a>
<ng-template ngbNavContent>
<div class="mt-3">
<label class="form-label">{{ 'CmsKit::Page' | abpLocalization }}</label>
<select class="form-control" formControlName="pageId">
<option [ngValue]="null"></option>
@for (page of pages; track page.id) {
<option [ngValue]="page.id">{{ page.title }}</option>
}
</select>
<div
class="position-relative"
ngbDropdown
#pageDropdown="ngbDropdown"
display="static"
(openChange)="onDropdownOpen()"
>
<button
class="form-select form-control text-start d-flex align-items-center justify-content-between"
type="button"
id="pageSelectDropdown"
ngbDropdownToggle
[class.text-muted]="!selectedPage"
>
<span>{{ selectedPage?.title || ('CmsKit::Page' | abpLocalization) }}</span>
</button>
<div
class="dropdown-menu w-100"
ngbDropdownMenu
aria-labelledby="pageSelectDropdown"
style="max-height: 300px; overflow-y: auto"
>
<div class="p-2 border-bottom">
<input
type="text"
class="form-control form-control-sm"
[(ngModel)]="pageSearchText"
[ngModelOptions]="{ standalone: true }"
(input)="onPageSearchChange($any($event.target).value)"
(click)="$event.stopPropagation()"
placeholder="{{ 'AbpUi::Search' | abpLocalization }}"
autocomplete="off"
/>
</div>
@if (filteredPages.length > 0) {
@for (page of filteredPages; track page.id) {
<button
type="button"
class="dropdown-item"
(click)="selectPage(page); pageDropdown.close()"
>
{{ page.title }}
</button>
}
} @else if (pageSearchText && pageSearchText.length > 0) {
<div class="dropdown-item text-muted">
{{ 'No results found' }}
</div>
} @else if (pages.length === 0) {
<div class="dropdown-item text-muted">
{{ 'No pages found' }}
</div>
}
@if (selectedPage) {
<div class="dropdown-divider"></div>
<button
type="button"
class="dropdown-item text-danger"
(click)="clearPageSelection(); pageDropdown.close()"
>
{{ 'AbpUi::Clear' | abpLocalization }}
</button>
}
</div>
<input [formControlName]="'pageId'" type="hidden" />
</div>
</div>
</ng-template>
</li>

375
npm/ng-packs/packages/cms-kit/admin/src/components/menus/menu-item-modal/menu-item-modal.component.ts

@ -1,7 +1,11 @@
import { Component, OnInit, inject, Injector, input, output } from '@angular/core';
import { Component, OnInit, inject, Injector, input, output, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormGroup, FormControl } from '@angular/forms';
import { ReactiveFormsModule, FormGroup, FormControl, FormsModule } from '@angular/forms';
import { NgxValidateCoreModule } from '@ngx-validate/core';
import { NgbNavModule, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
import { forkJoin, Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { LocalizationPipe } from '@abp/ng.core';
import {
ExtensibleFormComponent,
@ -14,6 +18,7 @@ import {
ButtonComponent,
ToasterService,
} from '@abp/ng.theme.shared';
import { dasharize } from '@abp/ng.cms-kit';
import {
MenuItemAdminService,
MenuItemDto,
@ -22,14 +27,21 @@ import {
MenuItemUpdateInput,
PageLookupDto,
} from '@abp/ng.cms-kit/proxy';
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import { forkJoin } from 'rxjs';
export interface MenuItemModalVisibleChange {
visible: boolean;
refresh: boolean;
}
// Constants
const PAGE_LOOKUP_MAX_RESULT = 1000;
const PAGE_SEARCH_MAX_RESULT = 100;
const PAGE_SEARCH_DEBOUNCE_MS = 300;
const TABS = {
URL: 'url',
PAGE: 'page',
} as const;
@Component({
selector: 'abp-menu-item-modal',
templateUrl: './menu-item-modal.component.html',
@ -43,120 +55,345 @@ export interface MenuItemModalVisibleChange {
ModalCloseDirective,
ButtonComponent,
NgbNavModule,
NgbDropdownModule,
FormsModule,
],
styles: [
`
.dropdown-toggle::after {
display: none !important;
}
`,
],
})
export class MenuItemModalComponent implements OnInit {
private menuItemService = inject(MenuItemAdminService);
private injector = inject(Injector);
private toasterService = inject(ToasterService);
// Injected services
private readonly menuItemService = inject(MenuItemAdminService);
private readonly injector = inject(Injector);
private readonly toasterService = inject(ToasterService);
private readonly destroyRef = inject(DestroyRef);
selected = input<MenuItemWithDetailsDto | MenuItemDto>();
parentId = input<string | null>();
visible = input<boolean>(true);
visibleChange = output<MenuItemModalVisibleChange>();
// Inputs/Outputs
readonly selected = input<MenuItemWithDetailsDto | MenuItemDto>();
readonly parentId = input<string | null>();
readonly visible = input<boolean>(true);
readonly visibleChange = output<MenuItemModalVisibleChange>();
// Form state
form: FormGroup;
activeTab: 'url' | 'page' = 'url';
activeTab: string = TABS.URL;
// Page selection state
pages: PageLookupDto[] = [];
selectedPage: PageLookupDto | null = null;
pageSearchText: string = '';
filteredPages: PageLookupDto[] = [];
// Search subject for debouncing
private readonly pageSearchSubject = new Subject<string>();
get isPageSelected(): boolean {
return !!this.form?.get('pageId')?.value;
}
ngOnInit() {
const selectedItem = this.selected();
this.setupPageSearch();
this.initializeComponent();
}
if (selectedItem?.id) {
// Load menu item and pages in parallel
forkJoin({
menuItem: this.menuItemService.get(selectedItem.id),
pages: this.menuItemService.getPageLookup({
maxResultCount: 1000,
/**
* Sets up debounced page search functionality
*/
private setupPageSearch(): void {
this.pageSearchSubject
.pipe(
debounceTime(PAGE_SEARCH_DEBOUNCE_MS),
distinctUntilChanged(),
switchMap(searchText => {
if (!searchText?.trim()) {
// Show all pages when search is cleared
return this.menuItemService.getPageLookup({ maxResultCount: PAGE_LOOKUP_MAX_RESULT });
}
return this.menuItemService.getPageLookup({
filter: searchText.trim(),
maxResultCount: PAGE_SEARCH_MAX_RESULT,
});
}),
}).subscribe(({ menuItem, pages }) => {
this.pages = pages.items || [];
this.buildForm(menuItem);
if (menuItem.pageId) {
this.activeTab = 'page';
this.selectedPage = this.pages.find(p => p.id === menuItem.pageId) || null;
}
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: result => {
this.filteredPages = result.items || [];
},
error: () => {
this.filteredPages = [];
},
});
}
/**
* Initializes the component based on create or edit mode
*/
private initializeComponent(): void {
const selectedItem = this.selected();
if (selectedItem?.id) {
this.loadMenuItemForEdit(selectedItem.id);
} else {
// Load pages for create mode
this.loadPages();
this.loadPagesForCreate();
this.buildForm();
}
}
private loadPages() {
/**
* Loads menu item data and pages for edit mode
*/
private loadMenuItemForEdit(menuItemId: string): void {
forkJoin({
menuItem: this.menuItemService.get(menuItemId),
pages: this.menuItemService.getPageLookup({ maxResultCount: PAGE_LOOKUP_MAX_RESULT }),
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: ({ menuItem, pages }) => {
this.pages = pages.items || [];
this.filteredPages = this.pages;
this.buildForm(menuItem);
this.initializePageSelection(menuItem);
},
error: () => {
this.toasterService.error('AbpUi::ErrorMessage');
},
});
}
/**
* Loads pages for create mode
*/
private loadPagesForCreate(): void {
this.menuItemService
.getPageLookup({
maxResultCount: 1000,
})
.subscribe(result => {
this.pages = result.items || [];
.getPageLookup({ maxResultCount: PAGE_LOOKUP_MAX_RESULT })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: result => {
this.pages = result.items || [];
this.filteredPages = this.pages;
},
error: () => {
this.toasterService.error('AbpUi::ErrorMessage');
},
});
}
private buildForm(menuItem?: MenuItemWithDetailsDto | MenuItemDto) {
/**
* Initializes page selection when editing a menu item with a page
*/
private initializePageSelection(menuItem: MenuItemWithDetailsDto | MenuItemDto): void {
if (menuItem.pageId) {
this.activeTab = TABS.PAGE;
this.selectedPage = this.pages.find(p => p.id === menuItem.pageId) || null;
const url = this.selectedPage
? this.generateUrlFromPage(this.selectedPage)
: menuItem.url || '';
this.form.patchValue({ pageId: menuItem.pageId, url }, { emitEvent: false });
this.pageSearchText = this.selectedPage?.title || '';
} else if (menuItem.url) {
this.activeTab = TABS.URL;
this.form.patchValue({ url: menuItem.url, pageId: null }, { emitEvent: false });
}
}
/**
* Generates a URL from a page's slug or title
*/
private generateUrlFromPage(page: PageLookupDto): string {
if (!page) return '';
const source = page.slug || page.title;
if (!source) return '';
return '/' + dasharize(source);
}
/**
* Handles page search input changes
*/
onPageSearchChange(searchText: string): void {
this.pageSearchText = searchText;
if (!searchText?.trim()) {
this.filteredPages = this.pages;
return;
}
this.pageSearchSubject.next(searchText);
}
/**
* Handles dropdown open event
*/
onDropdownOpen(): void {
if (!this.pageSearchText?.trim()) {
this.filteredPages = this.pages;
}
}
/**
* Handles page selection from dropdown
*/
selectPage(page: PageLookupDto): void {
if (!page) return;
this.selectedPage = page;
const url = this.generateUrlFromPage(page);
this.form.patchValue({ pageId: page.id, url }, { emitEvent: false });
this.pageSearchText = page.title || '';
}
/**
* Clears the selected page
*/
clearPageSelection(): void {
this.form.patchValue({ pageId: null }, { emitEvent: false });
this.selectedPage = null;
this.pageSearchText = '';
this.filteredPages = this.pages;
}
/**
* Builds the reactive form for menu item creation/editing
*/
private buildForm(menuItem?: MenuItemWithDetailsDto | MenuItemDto): void {
const data = new FormPropData(this.injector, menuItem || {});
const baseForm = generateFormFromProps(data);
const parentId = this.parentId() || menuItem?.parentId || null;
this.form = new FormGroup({
...baseForm.controls,
url: new FormControl(menuItem?.url || ''),
pageId: new FormControl(menuItem?.pageId || null),
parentId: new FormControl(parentId),
});
if (parentId === null && !menuItem?.id) {
this.menuItemService.getAvailableMenuOrder().subscribe(order => {
this.form.patchValue({ order });
});
} else if (parentId && !menuItem?.id) {
this.menuItemService.getAvailableMenuOrder(parentId).subscribe(order => {
this.loadAvailableOrder(parentId, menuItem?.id);
}
/**
* Loads the available menu order for new menu items
*/
private loadAvailableOrder(parentId: string | null, menuItemId?: string): void {
if (menuItemId) return; // Only needed for new items
const order$ = parentId
? this.menuItemService.getAvailableMenuOrder(parentId)
: this.menuItemService.getAvailableMenuOrder();
order$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: order => {
this.form.patchValue({ order });
});
}
},
});
}
onVisibleChange(visible: boolean, refresh = false) {
/**
* Handles modal visibility changes
*/
onVisibleChange(visible: boolean, refresh = false): void {
this.visibleChange.emit({ visible, refresh });
}
save() {
/**
* Handles tab changes
*/
onTabChange(activeId: string): void {
this.activeTab = activeId;
}
/**
* Handles URL input changes - clears page selection if URL is manually entered
*/
onUrlInput(): void {
const urlValue = this.form.get('url')?.value;
if (urlValue && this.form.get('pageId')?.value) {
this.clearPageSelection();
if (this.activeTab === TABS.PAGE) {
this.activeTab = TABS.URL;
}
}
}
/**
* Saves the menu item (create or update)
*/
save(): void {
if (!this.form.valid) {
return;
}
const formValue = this.form.value;
const formValue = this.prepareFormValue();
const selectedItem = this.selected();
const isEditMode = !!selectedItem?.id;
// If page is selected, clear URL; if URL is used, clear pageId
if (this.activeTab === 'page' && formValue.pageId) {
formValue.url = '';
} else if (this.activeTab === 'url') {
formValue.pageId = null;
}
let observable$;
const observable$ = isEditMode
? this.updateMenuItem(selectedItem.id, formValue, selectedItem as MenuItemWithDetailsDto)
: this.createMenuItem(formValue);
if (selectedItem?.id) {
const updateInput: MenuItemUpdateInput = {
...formValue,
concurrencyStamp: (selectedItem as MenuItemWithDetailsDto).concurrencyStamp,
};
observable$ = this.menuItemService.update(selectedItem.id, updateInput);
} else {
const createInput: MenuItemCreateInput = {
...formValue,
};
observable$ = this.menuItemService.create(createInput);
}
observable$.subscribe({
observable$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: () => {
this.onVisibleChange(false, true);
this.toasterService.success('AbpUi::SavedSuccessfully');
},
error: () => {
this.toasterService.error('AbpUi::ErrorMessage');
},
});
}
/**
* Prepares form value ensuring mutual exclusivity between pageId and url
*/
private prepareFormValue(): Partial<MenuItemCreateInput | MenuItemUpdateInput> {
const formValue = { ...this.form.value };
if (formValue.pageId) {
// If pageId is set, generate URL from the page
const selectedPage = this.pages.find(p => p.id === formValue.pageId);
if (selectedPage) {
formValue.url = this.generateUrlFromPage(selectedPage);
}
} else if (formValue.url) {
// If URL is manually entered, ensure pageId is cleared
formValue.pageId = null;
}
// Clean up undefined values
return {
...formValue,
url: formValue.url || undefined,
pageId: formValue.pageId || undefined,
};
}
/**
* Creates a new menu item
*/
private createMenuItem(formValue: Partial<MenuItemCreateInput>) {
const createInput: MenuItemCreateInput = formValue as MenuItemCreateInput;
return this.menuItemService.create(createInput);
}
/**
* Updates an existing menu item
*/
private updateMenuItem(
id: string,
formValue: Partial<MenuItemUpdateInput>,
selectedItem: MenuItemWithDetailsDto,
) {
const updateInput: MenuItemUpdateInput = {
...formValue,
concurrencyStamp: selectedItem.concurrencyStamp,
} as MenuItemUpdateInput;
return this.menuItemService.update(id, updateInput);
}
}

15
npm/ng-packs/packages/cms-kit/admin/src/defaults/menus/default-menu-item-create-form-props.ts

@ -3,18 +3,11 @@ import { map } from 'rxjs/operators';
import {
MenuItemCreateInput,
MenuItemAdminService,
PageLookupDto,
PermissionLookupDto,
} from '@abp/ng.cms-kit/proxy';
import { FormProp, ePropType } from '@abp/ng.components/extensible';
export const DEFAULT_MENU_ITEM_CREATE_FORM_PROPS = FormProp.createMany<MenuItemCreateInput>([
{
type: ePropType.String,
name: 'parentId',
displayName: 'CmsKit::Parent',
id: 'parentId',
},
{
type: ePropType.String,
name: 'displayName',
@ -27,7 +20,7 @@ export const DEFAULT_MENU_ITEM_CREATE_FORM_PROPS = FormProp.createMany<MenuItemC
name: 'isActive',
displayName: 'CmsKit::IsActive',
id: 'isActive',
defaultValue: false,
defaultValue: true,
},
{
type: ePropType.String,
@ -35,12 +28,6 @@ export const DEFAULT_MENU_ITEM_CREATE_FORM_PROPS = FormProp.createMany<MenuItemC
displayName: 'CmsKit::Icon',
id: 'icon',
},
{
type: ePropType.Number,
name: 'order',
displayName: 'CmsKit::Order',
id: 'order',
},
{
type: ePropType.String,
name: 'target',

Loading…
Cancel
Save