Skip to content

Commit 9c7e051

Browse files
Merge pull request #171 from CenterForOpenScience/feat/251-admin-table
feat(institution-users): added reusable table and users page
2 parents e1890e6 + 481f068 commit 9c7e051

36 files changed

+1832
-76
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<div class="flex justify-content-between align-items-center p-3">
2+
<div>
3+
<ng-content select="[slot=amount]"></ng-content>
4+
</div>
5+
6+
<p-menu #downloadMenu [model]="downloadMenuItems()" appendTo="body" popup>
7+
<ng-template #item let-item>
8+
<a class="p-menu-item-link ml-1" target="_blank" [href]="item.link">
9+
<i [class]="item.icon"></i>
10+
11+
{{ item.label }}
12+
</a>
13+
</ng-template>
14+
</p-menu>
15+
16+
<div class="filters-section flex align-items-center gap-2">
17+
<ng-content select="[slot=otherFilters]"></ng-content>
18+
19+
<p-multiselect
20+
[options]="tableColumns()"
21+
[(ngModel)]="selectedColumns"
22+
(onChange)="onColumnSelectionChange($event.value)"
23+
optionLabel="header"
24+
[showToggleAll]="false"
25+
[showClear]="false"
26+
[dropdownIcon]="'hidden'"
27+
>
28+
<ng-template let-values pTemplate="selectedItems">
29+
<div class="flex align-items-center gap-2">
30+
<i class="fa fa-table-columns text-primary font-bold"></i>
31+
<span>{{ 'adminInstitutions.institutionUsers.customize' | translate }}</span>
32+
</div>
33+
</ng-template>
34+
35+
<ng-template #item let-item>
36+
{{ item.header | translate }}
37+
</ng-template>
38+
</p-multiselect>
39+
40+
@if (downloadLink()) {
41+
<p-button
42+
icon="fa fa-download text-primary text-xl"
43+
class="p-button p-button-outlined p-button-sm p-06 font-bold grey-border-color child-button-0-padding"
44+
severity="info"
45+
(click)="downloadMenu.toggle($event)"
46+
/>
47+
}
48+
49+
@if (reportsLink()) {
50+
<a
51+
pButton
52+
[href]="reportsLink()"
53+
class="p-button p-button-outlined p-button-sm p-06 font-bold grey-border-color child-button-0-padding"
54+
target="_blank"
55+
>
56+
<i class="fa fa-chart-pie text-primary border-1 border-transparent text-xl"></i>
57+
</a>
58+
}
59+
</div>
60+
</div>
61+
62+
<p-table
63+
[columns]="selectedColumnsComputed()"
64+
[value]="tableData()"
65+
[autoLayout]="true"
66+
[scrollable]="true"
67+
[sortMode]="'single'"
68+
(onSort)="onSort($event)"
69+
[sortField]="sortColumn()"
70+
[sortOrder]="currentSortOrder()"
71+
[customSort]="true"
72+
[resetPageOnSort]="false"
73+
[tableStyle]="{ 'min-width': '50rem' }"
74+
>
75+
<ng-template #header let-columns>
76+
<tr>
77+
@for (col of columns; track col.field) {
78+
<th [pSortableColumn]="col.sortable ? col.field : null">
79+
<div class="flex align-items-center gap-2">
80+
<span>{{ col.header | translate }}</span>
81+
@if (col.sortable) {
82+
<p-sortIcon [field]="col.field"></p-sortIcon>
83+
}
84+
</div>
85+
</th>
86+
}
87+
</tr>
88+
</ng-template>
89+
90+
<ng-template #body let-rowData let-columns="columns">
91+
<tr>
92+
@for (col of columns; track col.field) {
93+
<td class="relative">
94+
<div class="flex align-items-center hover-group">
95+
@if (col.isLink && isLink(rowData[col.field])) {
96+
<a
97+
[href]="getLinkUrl(rowData[col.field])"
98+
[target]="getLinkTarget(rowData[col.field], col)"
99+
class="text-primary no-underline hover:underline"
100+
>
101+
{{ getCellValueWithFormatting(rowData[col.field], col) }}
102+
</a>
103+
} @else {
104+
{{ getCellValueWithFormatting(rowData[col.field], col) }}
105+
}
106+
107+
@if (col.showIcon) {
108+
<p-button
109+
[pTooltip]="col.iconTooltip | translate"
110+
class="icon-button pl-3"
111+
[icon]="col.iconClass"
112+
variant="text"
113+
severity="info"
114+
(click)="onIconClick(rowData, col)"
115+
/>
116+
}
117+
</div>
118+
</td>
119+
}
120+
</tr>
121+
</ng-template>
122+
</p-table>
123+
124+
@if (enablePagination() && totalCount() > pageSize()) {
125+
<div class="p-3">
126+
<osf-custom-paginator
127+
[first]="first()"
128+
[totalCount]="totalCount()"
129+
[rows]="pageSize()"
130+
(pageChanged)="onPageChange($event)"
131+
></osf-custom-paginator>
132+
</div>
133+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
.p-06 {
2+
padding: 0.6rem;
3+
}
4+
5+
.hover-group {
6+
.icon-button {
7+
opacity: 0;
8+
transition: opacity;
9+
}
10+
11+
&:hover {
12+
.icon-button {
13+
opacity: 100;
14+
}
15+
}
16+
}
17+
18+
.child-button-0-padding {
19+
--p-button-padding-y: 0;
20+
--p-button-icon-only-width: max-content;
21+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
3+
import { AdminTableComponent } from './admin-table.component';
4+
5+
describe('AdminTableComponent', () => {
6+
let component: AdminTableComponent;
7+
let fixture: ComponentFixture<AdminTableComponent>;
8+
9+
beforeEach(async () => {
10+
await TestBed.configureTestingModule({
11+
imports: [AdminTableComponent],
12+
}).compileComponents();
13+
14+
fixture = TestBed.createComponent(AdminTableComponent);
15+
component = fixture.componentInstance;
16+
fixture.detectChanges();
17+
});
18+
19+
it('should create', () => {
20+
expect(component).toBeTruthy();
21+
});
22+
});
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
2+
3+
import { SortEvent } from 'primeng/api';
4+
import { Button, ButtonDirective } from 'primeng/button';
5+
import { Menu } from 'primeng/menu';
6+
import { MultiSelect } from 'primeng/multiselect';
7+
import { PaginatorState } from 'primeng/paginator';
8+
import { TableModule } from 'primeng/table';
9+
import { Tooltip } from 'primeng/tooltip';
10+
11+
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal } from '@angular/core';
12+
import { FormsModule } from '@angular/forms';
13+
14+
import {
15+
TableCellData,
16+
TableCellLink,
17+
TableColumn,
18+
TableIconClickEvent,
19+
} from '@osf/features/admin-institutions/models';
20+
import { CustomPaginatorComponent } from '@osf/shared/components';
21+
import { SortOrder } from '@shared/enums';
22+
import { QueryParams } from '@shared/models';
23+
24+
@Component({
25+
selector: 'osf-admin-table',
26+
imports: [
27+
MultiSelect,
28+
TableModule,
29+
FormsModule,
30+
ButtonDirective,
31+
CustomPaginatorComponent,
32+
Tooltip,
33+
TranslatePipe,
34+
Button,
35+
Menu,
36+
],
37+
templateUrl: './admin-table.component.html',
38+
styleUrl: './admin-table.component.scss',
39+
changeDetection: ChangeDetectionStrategy.OnPush,
40+
})
41+
export class AdminTableComponent {
42+
private readonly translateService = inject(TranslateService);
43+
44+
tableColumns = input.required<TableColumn[]>();
45+
tableData = input.required<TableCellData[]>();
46+
47+
enablePagination = input<boolean>(false);
48+
totalCount = input<number>(0);
49+
currentPage = input<number>(1);
50+
pageSize = input<number>(10);
51+
first = input<number>(0);
52+
53+
sortField = input<string>('');
54+
sortOrder = input<number>(1);
55+
56+
pageChanged = output<PaginatorState>();
57+
sortChanged = output<QueryParams>();
58+
iconClicked = output<TableIconClickEvent>();
59+
60+
downloadLink = input<string>('');
61+
reportsLink = input<string>('');
62+
63+
selectedColumns = signal<TableColumn[]>([]);
64+
65+
downloadMenuItems = computed(() => {
66+
const baseUrl = this.downloadLink();
67+
if (!baseUrl) return [];
68+
69+
return [
70+
{
71+
label: 'CSV',
72+
icon: 'fa fa-file-csv',
73+
link: this.createUrl(baseUrl, 'csv'),
74+
},
75+
{
76+
label: 'TSV',
77+
icon: 'fa fa-file-alt',
78+
link: this.createUrl(baseUrl, 'tsv'),
79+
},
80+
{
81+
label: 'JSON',
82+
icon: 'fa fa-file-code',
83+
link: this.createUrl(baseUrl, 'json'),
84+
},
85+
];
86+
});
87+
88+
selectedColumnsComputed = computed(() => {
89+
const selected = this.selectedColumns();
90+
const allColumns = this.tableColumns();
91+
92+
if (selected.length === 0) {
93+
return allColumns;
94+
}
95+
96+
return selected;
97+
});
98+
99+
sortColumn = computed(() => this.sortField());
100+
currentSortOrder = computed(() => this.sortOrder());
101+
102+
constructor() {
103+
effect(() => {
104+
const columns = this.tableColumns();
105+
if (columns.length > 0 && this.selectedColumns().length === 0) {
106+
this.selectedColumns.set(columns);
107+
}
108+
});
109+
}
110+
111+
onColumnSelectionChange(selectedCols: TableColumn[]): void {
112+
this.selectedColumns.set(selectedCols);
113+
}
114+
115+
onPageChange(event: PaginatorState): void {
116+
this.pageChanged.emit(event);
117+
}
118+
119+
onSort(event: SortEvent): void {
120+
if (event.field) {
121+
this.sortChanged.emit({
122+
sortColumn: event.field,
123+
sortOrder: event.order === -1 ? SortOrder.Desc : SortOrder.Asc,
124+
} as QueryParams);
125+
}
126+
}
127+
128+
onIconClick(rowData: TableCellData, column: TableColumn): void {
129+
if (column.iconAction) {
130+
this.iconClicked.emit({
131+
rowData,
132+
column,
133+
action: column.iconAction,
134+
});
135+
}
136+
}
137+
138+
isLink(value: string | number | TableCellLink | undefined): value is TableCellLink {
139+
return value !== null && value !== undefined && typeof value === 'object' && 'text' in value && 'url' in value;
140+
}
141+
142+
getCellValue(value: string | number | TableCellLink | undefined): string {
143+
if (this.isLink(value)) {
144+
return this.translateService.instant(value.text);
145+
}
146+
return this.translateService.instant(String(value)) || '';
147+
}
148+
149+
getCellValueWithFormatting(value: string | number | TableCellLink | undefined, column: TableColumn): string {
150+
if (this.isLink(value)) {
151+
return this.translateService.instant(value.text);
152+
}
153+
154+
const stringValue = String(value);
155+
156+
if (column.dateFormat && stringValue) {
157+
return this.formatDate(stringValue, column.dateFormat);
158+
}
159+
160+
return this.translateService.instant(stringValue) || '';
161+
}
162+
163+
private formatDate(value: string, format: string): string {
164+
if (format === 'yyyy-mm-to-mm/yyyy') {
165+
const yearMonthRegex = /^(\d{4})-(\d{2})$/;
166+
const match = value.match(yearMonthRegex);
167+
168+
if (match) {
169+
const [, year, month] = match;
170+
return `${month}/${year}`;
171+
}
172+
}
173+
174+
return value;
175+
}
176+
177+
private createUrl(baseUrl: string, format: string): string {
178+
return `${baseUrl}?format=${format}`;
179+
}
180+
181+
getLinkUrl(value: string | number | TableCellLink | undefined): string {
182+
if (this.isLink(value)) {
183+
return value.url;
184+
}
185+
return '';
186+
}
187+
188+
getLinkTarget(value: string | number | TableCellLink | undefined, column: TableColumn): string {
189+
if (this.isLink(value)) {
190+
return value.target || column.linkTarget || '_self';
191+
}
192+
return column.linkTarget || '_self';
193+
}
194+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { AdminTableComponent } from './admin-table/admin-table.component';

0 commit comments

Comments
 (0)