diff --git a/assets/admin/pages/CatalogCategoriesPage.test.tsx b/assets/admin/pages/CatalogCategoriesPage.test.tsx index 7076f7d9..ff061842 100644 --- a/assets/admin/pages/CatalogCategoriesPage.test.tsx +++ b/assets/admin/pages/CatalogCategoriesPage.test.tsx @@ -4,7 +4,12 @@ import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../test/utils'; vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); -vi.mock('../hooks/usePermissions', () => ({ usePermissions: () => ({ can: () => true }) })); +// per-action قابل تنظیم: گِیتِ ServiceCatalogController هم per-action است، پس تست باید +// بتواند create/update/delete را جدا خاموش کند. +let granted = new Set(['view', 'create', 'update', 'delete']); +vi.mock('../hooks/usePermissions', () => ({ + usePermissions: () => ({ can: (_r: string, a: string) => granted.has(a) }), +})); const create = { mutate: vi.fn(), isPending: false }; const update = { mutate: vi.fn(), isPending: false }; @@ -34,6 +39,7 @@ import CatalogCategoriesPage from './CatalogCategoriesPage'; describe('CatalogCategoriesPage', () => { beforeEach(() => { includes = []; + granted = new Set(['view', 'create', 'update', 'delete']); vi.clearAllMocks(); }); @@ -77,4 +83,60 @@ describe('CatalogCategoriesPage', () => { expect(removeEdge.mutate).toHaveBeenCalledWith({ uuid: 'c-whole', childUuid: 'c-hand' }); }); + // ── هم‌ترازی با گِیتِ per-action سمت API ───────────────────────────────── + // یک توگلِ واحد یعنی دکمه‌ای که کاربر می‌بیند و سرور ۴۰۳ می‌دهد. + + it('با فقط update، دکمهٔ افزودن نیست ولی ویرایش هست', () => { + granted = new Set(['view', 'update']); + renderWithProviders(); + + expect(screen.queryByRole('button', { name: /افزودن دسته‌بندی/ })).not.toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'ویرایش' }).length).toBeGreaterThan(0); + }); + + it('با فقط create، دکمهٔ افزودن هست ولی ویرایش و حذف نیست', () => { + granted = new Set(['view', 'create']); + renderWithProviders(); + + expect(screen.getByRole('button', { name: /افزودن دسته‌بندی/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'ویرایش' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'حذف' })).not.toBeInTheDocument(); + }); + + it('با فقط delete، دکمهٔ حذف هست ولی ویرایش نیست', () => { + granted = new Set(['view', 'delete']); + renderWithProviders(); + + expect(screen.getAllByRole('button', { name: 'حذف' }).length).toBeGreaterThan(0); + expect(screen.queryByRole('button', { name: 'ویرایش' })).not.toBeInTheDocument(); + }); + + it('با فقط view، هیچ دکمهٔ نوشتنی نیست', () => { + granted = new Set(['view']); + renderWithProviders(); + + expect(screen.getByText('تمام بدن')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /افزودن دسته‌بندی/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'ویرایش' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'حذف' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'شامل' })).not.toBeInTheDocument(); + }); + + it('مودالِ «شامل» دکمهٔ حذف یال را فقط با delete نشان می‌دهد', async () => { + includes = [{ uuid: 'c-hand', name: 'دست' }]; + granted = new Set(['view', 'delete']); + const withDelete = renderWithProviders(); + + await userEvent.click(screen.getAllByRole('button', { name: 'شامل' })[0]); + expect(await screen.findByRole('button', { name: 'حذف دست' })).toBeInTheDocument(); + + withDelete.unmount(); + + granted = new Set(['view', 'create']); + renderWithProviders(); + + await userEvent.click(screen.getAllByRole('button', { name: 'شامل' })[0]); + expect(screen.queryByRole('button', { name: 'حذف دست' })).not.toBeInTheDocument(); + }); + }); diff --git a/assets/admin/pages/CatalogCategoriesPage.tsx b/assets/admin/pages/CatalogCategoriesPage.tsx index c5248b06..afec00e8 100644 --- a/assets/admin/pages/CatalogCategoriesPage.tsx +++ b/assets/admin/pages/CatalogCategoriesPage.tsx @@ -30,7 +30,11 @@ function flatten(nodes: CatalogCategory[], depth = 0): Row[] { export default function CatalogCategoriesPage() { const { tree, loading, create, update, remove } = useCatalogCategories(); const { can } = usePermissions(); + // per-action، هم‌تراز با گِیتِ ServiceCatalogController؛ یک توگلِ واحد یعنی دکمه‌ای + // که کاربر می‌بیند ولی سرور ۴۰۳ می‌دهد. + const canCreate = can('services', 'create'); const canUpdate = can('services', 'update'); + const canDelete = can('services', 'delete'); const [urlState, setUrlState] = useUrlState({ search: '' }); const [editing, setEditing] = useState<{ open: boolean; category: CatalogCategory | null }>({ open: false, category: null }); @@ -67,7 +71,7 @@ export default function CatalogCategoriesPage() { description="دسته‌بندی سراسری کلینیک؛ یک بار تعریف می‌شود و سرویس‌ها و منابع از همین‌ها انتخاب می‌کنند." backTo="/admin/settings-menu" action={ - canUpdate ? ( + canCreate ? ( @@ -84,18 +88,25 @@ export default function CatalogCategoriesPage() { searchPlaceholder="جستجو در دسته‌بندی‌ها..." emptyMessage="هنوز دسته‌بندی‌ای تعریف نشده است" actions={ - canUpdate + canUpdate || canCreate || canDelete ? (c) => (
- - - + {canUpdate && ( + + )} + {/* مودالِ «شامل» یال اضافه/حذف می‌کند: create یا delete. */} + {(canCreate || canDelete) && ( + + )} + {canDelete && ( + + )}
) : undefined @@ -122,6 +133,8 @@ export default function CatalogCategoriesPage() { setIncludesFor(null)} /> @@ -231,10 +244,12 @@ function CategoryFormModal({ /** «این دسته شامل کدام دسته‌هاست» — گراف، جدا از درختِ منو. */ function CategoryIncludesModal({ - category, all, onClose, + category, all, canCreate, canDelete, onClose, }: { category: CatalogCategory | null; all: Row[]; + canCreate: boolean; + canDelete: boolean; onClose: () => void; }) { const { includes, add, remove } = useCategoryIncludes(category?.uuid); @@ -257,21 +272,23 @@ function CategoryIncludesModal({ {includes.map((child) => (
{child.name} - + {canDelete && ( + + )}
))} )} - {available.length > 0 && ( + {canCreate && available.length > 0 && (