File: src/app/shared/components/tree-table/tree-table.ts
مكوّن متخصص لعرض بيانات هرمية (Module → Entity → Permission) مع دعم كامل للـ sort، column filters، selection، role assignments، وبديلين للعرض: جدول (list) وكروت (grid). يستخدمه Permissions page كمثال رئيسي.
متى تستخدمه؟ لما عندك بيانات على مستويين أو ثلاثة (parent → children أو parent → children → grandchildren). لو البيانات flat، استخدم
<app-table>بدلاً منه.
// الوحدة الكبيرة (Module) — تجمع مجموعة من الـ Entities
export interface TreeModule {
name: string;
entities: TreeNode[];
}
// العقدة (Entity أو Permission) — مستخدمة في كل المستويات
export interface TreeNode {
key: string; // مُعرّف فريد (e.g. 'entity.users', 'perm.users.view')
data: TreeNodeData; // الحقول المعروضة
children?: TreeNode[]; // موجودة فقط في الـ parent nodes
}
export interface TreeNodeData {
code?: string;
name: string;
assignedRolesCount?: number;
[key: string]: unknown; // أي حقول إضافية
}
// Role — يُمرّر كـ availableRoles
export interface Role {
id: string;
name: string;
type: 'admin' | 'management' | 'content' | 'readonly' | 'audit' | 'support';
}
// Column descriptor — نفس المفهوم زي TableColumn بس للـ tree
export interface TreeTableColumn {
field: string;
columnId?: string; // مُعرّف فريد للـ column filter events
header: string;
sortable?: boolean;
filterable?: boolean;
filterOptions?: { label: string; value: string }[]; // multi-select في الـ popover
minWidth?: string;
cellType?: 'tag' | 'code' | 'roles' | 'text' | 'org-name';
translatePrefix?: string; // لو cellType='tag'، بيترجم القيمة تلقائياً
severityMap?: Record<string, 'success' | 'warn' | 'secondary' | 'danger' | 'info'>;
defaultMatchMode?: 'contains' | 'startsWith' | 'endsWith' | 'equals';
}| Input | Type | Default | What it does |
|---|---|---|---|
modules |
TreeModule[] |
[] |
الداتا الكاملة (بعد الـ filter من الـ parent) |
sortedEntitiesMap |
Map<string, TreeNode[]> |
new Map() |
Entities مرتبة لكل module بعد الـ sort |
roleAssignments |
Map<string, string[]> |
new Map() |
ربط كل permission key بقائمة role IDs |
expandedRows |
{ [key: string]: boolean } |
{} |
الـ rows المفتوحة (two-way بالـ reference) |
selectedEntities |
TreeNode[] |
[] |
الـ entities المحددة |
selectedItems |
TreeNode[] |
[] |
الـ children (permissions) المحددة |
availableRoles |
Role[] |
[] |
قائمة الـ roles لعرضها في الـ cell |
entityColumns |
TreeTableColumn[] |
[] |
أعمدة الـ parent row |
childColumns |
TreeTableColumn[] |
[] |
أعمدة الـ child row |
grandchildColumns |
TreeTableColumn[] |
[] |
أعمدة المستوى الثالث (اختياري) |
grandchildLabel |
string |
'' |
عنوان المستوى الثالث |
groupByModule |
boolean |
false |
يعرض اسم الـ module كـ heading |
showToolbar |
boolean |
true |
يعرض/يخفي التولبار الداخلي |
showLayoutToggle |
boolean |
false |
يعرض زرّي Table/Cards داخل التولبار |
layoutInput |
'list' | 'grid' |
'list' |
يربط الـ layout بـ signal خارجي |
title |
string |
'' |
عنوان التولبار الداخلي (لو showToolbar=true) |
toolbarShowAdd |
boolean |
false |
زرار Add في التولبار الداخلي |
toolbarFilters |
ToolbarFilterDefinition[] |
[] |
فلاتر التولبار الداخلي |
entityActionLabel |
string |
'' |
تسمية action الـ entity في الـ context menu |
showEntityAddChild |
boolean |
false |
يضيف "Add Child" للـ entity menu |
showItemAddChild |
boolean |
false |
يضيف "Add Child" للـ item menu |
nestedConfig |
NestedTableConfig |
undefined |
config يدوي للـ nesting بدل entityColumns/childColumns |
pagination |
PaginationConfig |
undefined |
تفعيل الـ pagination |
| Output | Payload | When fired |
|---|---|---|
entityEdit |
TreeNode |
كليك Edit على entity |
entityDelete |
TreeNode |
كليك Delete على entity |
entityAddChild |
TreeNode |
كليك Add Child على entity |
itemEdit |
string (key) |
كليك Edit على child item |
itemDelete |
string (key) |
كليك Delete على child item |
itemAddChild |
string (key) |
كليك Add Child على item |
grandchildEdit |
string (key) |
كليك Edit على grandchild |
entitySelectionChange |
TreeNode[] |
تغيير selection الـ entities |
itemSelectionChange |
TreeNode[] |
تغيير selection الـ items |
colFilterChanged |
TreeTableColFilterEvent |
تطبيق column filter |
sortChanged |
TreeTableSortEvent |
تغيير الـ sort |
addClicked |
void |
كليك Add في التولبار الداخلي |
// بيتبعث لما المستخدم يفلتر من الـ column header
export interface TreeTableColFilterEvent {
columnId: string;
text?: { matchMode: string; value: string | null } | null; // للـ text columns
opts?: string[]; // للـ multi-select columns
}
// بيتبعث لما المستخدم يضغط sort على أي column
export interface TreeTableSortEvent {
field: string;
order: 1 | -1;
level: 'entity' | 'child'; // هل الـ sort على الـ parent أو الـ child
}| cellType | الاستخدام |
|---|---|
'tag' |
يعرض قيمة كـ p-tag ملوّنة — يحتاج severityMap و translatePrefix |
'roles' |
يعرض عدد الـ roles المعيّنة مع أيقونة |
'code' |
يعرض القيمة بـ monospace (كود) |
'text' |
عرض نصي عادي (الـ default) |
'org-name' |
عرض متخصص لأسماء المنظمات |
افصل بناء الأعمدة في ملف منفصل وادعيها في ngOnInit وعند تغيير اللغة:
// permissions.columns.ts
import { TranslocoService } from '@jsverse/transloco';
import { TreeTableColumn } from '@/app/foundation/shared/components/tree-table/tree-table';
import { FilterOption } from './permission.model';
export function buildEntityColumns(
t: TranslocoService,
catOpts: FilterOption[],
statusOpts: FilterOption[],
): TreeTableColumn[] {
return [
{
field: 'name',
header: t.translate('permissions.entityName') || 'Entity Name',
columnId: 'entity-name', // ← مهم: يُستخدم في colFilterChanged
sortable: true,
filterable: true,
minWidth: '220px',
},
{
field: 'status',
header: t.translate('permissions.status') || 'Status',
columnId: 'entity-status',
sortable: true,
filterable: true,
filterOptions: statusOpts, // ← multi-select في الـ popover
cellType: 'tag',
translatePrefix: 'permissionStatus',
severityMap: { Active: 'success', Pending: 'warn', Inactive: 'secondary' },
minWidth: '130px',
},
{
field: 'assignedRolesCount',
header: t.translate('permissions.assignedRoles') || 'Assigned Roles',
columnId: 'entity-roles',
sortable: true,
filterable: false, // ← الأرقام عادةً مش فيها filter popover
minWidth: '140px',
},
];
}
export function buildPermissionColumns(
t: TranslocoService,
statusOpts: FilterOption[],
actionOpts: FilterOption[],
): TreeTableColumn[] {
return [
{
field: 'action',
header: t.translate('permissions.actionType') || 'Action Type',
columnId: 'child-action',
sortable: true,
filterable: true,
filterOptions: actionOpts,
minWidth: '140px',
},
// ... باقي الأعمدة
];
}
// Filter option builders — تترجم مع تغيير اللغة
export function buildStatusOptions(t: TranslocoService): FilterOption[] {
return (['Active', 'Inactive', 'Pending'] as const).map(s => ({
label: t.translate(`permissionStatus.${s}`) || s,
value: s,
}));
}@Component({ ... })
export class PermissionsComponent implements OnInit {
// ── Signals ─────────────────────────────────────────────────────────────
rawModules = signal<Module[]>([]);
availableRoles = signal<Role[]>([]);
roleAssignments = signal<Map<string, string[]>>(new Map());
entityColumns = signal<PermTableColumn[]>([]);
permColumns = signal<PermTableColumn[]>([]);
selectedEntities = signal<PermissionNode[]>([]);
selectedPermissions = signal<PermissionNode[]>([]);
layoutMode = signal<'list' | 'grid'>('list');
// activeSort و activeColFilters لازم يتحملوا في الـ parent
// عشان الـ filteredModules computed يعرف يشتغل عليهم
activeSort = signal<ActiveSort>({ field: '', order: 1, level: 'entity' });
activeColFilters = signal<ActiveColFilters>({ ...EMPTY_COL_FILTERS });
// ── sortedEntitiesMap — computed من rawModules + activeSort ─────────────
readonly sortedEntitiesMap = computed<Map<string, TreeNode[]>>(() => {
const { field, order, level } = this.activeSort();
const map = new Map<string, TreeNode[]>();
this.rawModules().forEach(mod => {
let entities = [...mod.entities];
if (level === 'entity' && field) {
entities.sort((a, b) => {
const av = String(a.data[field] ?? '');
const bv = String(b.data[field] ?? '');
return av.localeCompare(bv) * order;
});
}
if (level === 'child' && field) {
entities = entities.map(e => ({
...e,
children: [...(e.children ?? [])].sort((a, b) => {
const av = String(a.data[field] ?? '');
const bv = String(b.data[field] ?? '');
return av.localeCompare(bv) * order;
}),
}));
}
map.set(mod.name, entities);
});
return map;
});
// ── filteredModules — computed من rawModules + activeColFilters + search ─
readonly filteredModules = computed<Module[]>(() => {
const globalQuery = this.globalSearchText().trim().toLowerCase();
const colF = this.activeColFilters();
// ... تطبيق الفلاتر كما هو في permissions.ts
return this.rawModules(); // placeholder
});
// ── ngOnInit ─────────────────────────────────────────────────────────────
ngOnInit(): void {
// بناء الأعمدة أول ما الداتا تيجي
this.dataService.getData$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ modules, roles }) => {
this.rawModules.set(modules);
this.availableRoles.set(roles);
this.buildColumnDefs(); // ← بعد الداتا مباشرة
});
// إعادة بناء الأعمدة عند تغيير اللغة
this.t.langChanges$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.buildColumnDefs());
}
private buildColumnDefs(): void {
const statusOpts = buildStatusOptions(this.t);
const actionOpts = buildActionOptions(this.t);
// استخرج الـ categories ديناميكياً من الداتا
const cats = new Set<string>();
this.rawModules().forEach(m =>
m.entities.forEach(e => {
if (e.data['category']) cats.add(e.data['category'] as string);
})
);
const catOpts = Array.from(cats).sort().map(c => ({
label: this.t.translate(`permissionCategories.${c}`) || c,
value: c,
}));
this.entityColumns.set(buildEntityColumns(this.t, catOpts, statusOpts));
this.permColumns.set(buildPermissionColumns(this.t, statusOpts, actionOpts));
}
// ── Sort handler — يرفع الـ sort للـ parent signal ───────────────────────
onSortChanged(event: TreeTableSortEvent): void {
this.activeSort.set({
field: event.field,
order: event.order,
level: event.level,
});
}
// ── Column filter handler — يرفع الـ filter للـ parent signal ────────────
onColFilterChanged(event: TreeTableColFilterEvent): void {
const current = { ...this.activeColFilters() };
switch (event.columnId) {
case 'entity-name': current.entityName = event.text ?? null; break;
case 'entity-status': current.entityStatus = event.opts ?? []; break;
case 'child-name': current.childName = event.text ?? null; break;
case 'child-status': current.childStatus = event.opts ?? []; break;
case 'child-action': current.childAction = event.opts ?? []; break;
}
this.activeColFilters.set(current);
}
}<!-- التولبار ملك الـ host — مش جوه الـ tree-table -->
<app-shared-toolbar
[title]="'permissions.title' | transloco"
[searchValue]="globalSearchText()"
[hasFilters]="true"
[showBuiltInSearch]="true"
[showLayoutToggle]="true"
[activeLayout]="layoutMode()"
(searchChanged)="onSearchChanged($event)"
(clearSearch)="clearFilters()"
(onExport)="exportToCSV()"
(layoutChange)="layoutMode.set($event)"
>
<ng-container toolbar-filters>
<app-filter
[label]="'permissions.filters.module' | transloco"
[options]="moduleFilterOptions()"
[selected]="selectedModuleFilters()"
(selectedChange)="updateModuleFilters($event)"
/>
<app-filter
[label]="'permissions.filters.entityStatus' | transloco"
[options]="statusFilterOptions()"
[selected]="selectedStatusFilters()"
(selectedChange)="updateStatusFilters($event)"
/>
</ng-container>
</app-shared-toolbar>
<!-- Tree Table — [showToolbar]="false" لأن التولبار فوق في الـ host -->
<app-tree-table
[modules]="filteredModules()"
[sortedEntitiesMap]="sortedEntitiesMap()"
[roleAssignments]="roleAssignments()"
[expandedRows]="expandedRows"
[selectedEntities]="selectedEntities()"
[selectedItems]="selectedPermissions()"
[availableRoles]="availableRoles()"
[groupByModule]="true"
[entityColumns]="entityColumns()"
[childColumns]="permColumns()"
[showToolbar]="false"
[showLayoutToggle]="true"
[layoutInput]="layoutMode()"
(entityEdit)="openEntityEdit($event)"
(itemEdit)="openPermissionEdit($event)"
(entitySelectionChange)="onEntitySelectionChange($event)"
(itemSelectionChange)="onPermissionSelectionChange($event)"
(colFilterChanged)="onColFilterChanged($event)"
(sortChanged)="onSortChanged($event)"
/>
<!-- Bottom bar للـ bulk actions — يظهر لما يتحدد permissions -->
<app-shared-bottom-bar
[visible]="selectedPermissions().length > 0"
[count]="selectedPermissions().length"
itemLabel="permission"
[showDelete]="false"
[actions]="[{ key: 'bulk-edit', label: 'Edit Roles', icon: 'pi pi-pencil', severity: 'secondary' }]"
(bulkAction)="onBottomBarAction($event)"
(clearSelection)="clearSelection()"
/>لما الـ API مش بيرجع الـ envelope المعتادة { success, result } وبدله يرجع شكل مختلف، override الـ mapItem و getItems أو اعمل load() مباشر:
@Injectable({ providedIn: 'root' })
export class PermissionsDataService extends BaseApiService<PermissionsJson> {
protected override url = '/api/permissions.mock.json';
private readonly t = inject(TranslocoService);
// ① الـ JSON مش عليه envelope — بنرجعه as-is
protected override mapItem(raw: PermissionsJson): PermissionsJson { return raw; }
protected override getItems(result: PermissionsJson): PermissionsJson[] { return [result]; }
// ② Load مباشر بدون BaseApiService envelope logic
private load(): Observable<PermissionsJson> {
return this.http.get<PermissionsJson>(this.url);
}
// ③ getData$ — بيعيد بناء الداتا المترجمة عند كل تغيير لغة
getData$(): Observable<{ modules: Module[]; roles: Role[] }> {
return this.t.langChanges$.pipe(
switchMap(() => this.load()),
map(json => ({
modules: this.buildModules(json.modules),
roles: this.buildRoles(json.roles),
})),
);
}
// ④ getRoleAssignments$ — one-shot لبذر الـ roleAssignments signal
getRoleAssignments$(): Observable<[string, string[]][]> {
return this.load().pipe(
map(json => json.roleAssignments.map(
a => [a.permissionKey, a.roleIds] as [string, string[]]
)),
);
}
}// في الـ component — استخدام الـ service
ngOnInit(): void {
// ① بذر الـ roleAssignments مرة واحدة
this.dataService.getRoleAssignments$()
.pipe(take(1))
.subscribe(seed => {
this.roleAssignmentsMap = new Map(seed);
this.roleAssignments.set(new Map(seed));
});
// ② subscribe للداتا المترجمة — بيتحدث مع اللغة تلقائياً
this.dataService.getData$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ modules, roles }) => {
this.rawModules.set(modules);
this.availableRoles.set(roles);
this.buildColumnDefs();
});
}① حدد المستويات: هل 2 مستويات (entity + children) أو 3 (+ grandchildren)?
→ 2: entityColumns + childColumns
→ 3: + grandchildColumns + grandchildLabel
② هل التولبار داخل الـ tree-table أو في الـ host؟
→ داخلي: [showToolbar]="true" + [toolbarFilters] + [title]
→ خارجي: [showToolbar]="false" + ابني <app-shared-toolbar> في الـ host
③ هل عندك layout toggle؟
→ نعم: [showLayoutToggle]="true" + [layoutInput]="layoutMode()"
→ ادير الـ signal في الـ host عشان يتشارك مع التولبار الخارجي
④ هل الـ sort والـ filter يتحملوا في الـ parent؟
→ دايماً نعم — الـ tree-table بيبعت events، الـ parent يحفظهم في signals
→ sortedEntitiesMap + filteredModules = computed signals في الـ parent
⑤ هل عندك role assignments؟
→ نعم: [roleAssignments]="roleAssignments()" + [availableRoles]="availableRoles()"
→ احفظ المجموعة في Map<string, string[]> وحدّثها بـ set(new Map(...))
⑥ هل عندك bulk actions؟
→ أضف <app-shared-bottom-bar> منفصل في الـ host
→ [visible]="selectedItems().length > 0"