1
0
mirror of https://github.com/devcode-it/openstamanager.git synced 2025-06-05 22:09:38 +02:00
Files
openstamanager/resources/ts/Components/Pages/RecordsPage.tsx
2023-08-17 15:36:30 +02:00

415 lines
14 KiB
TypeScript

/* eslint-disable sonarjs/no-duplicate-string */
import '@maicol07/material-web-additions/layout-grid/layout-grid.js';
import '@material/web/dialog/dialog.js';
import '@material/web/fab/branded-fab.js';
import '@material/web/fab/fab.js';
import '@material/web/iconbutton/icon-button.js';
import {router} from '@maicol07/inertia-mithril';
import {PaginateDetail} from '@maicol07/material-web-additions/data-table/internal/data-table';
import {
FilterTextFieldInputEventDetail,
SortButtonClickedEventDetail
} from '@maicol07/material-web-additions/data-table/internal/data-table-column.js';
import {
mdiPlus,
mdiRefresh
} from '@mdi/js';
import RecordsTable, {RecordsTableColumnAttributes} from '@osm/Components/DataTable/RecordsTable';
import AddEditRecordDialog from '@osm/Components/Dialogs/AddEditRecordDialog';
import DeleteRecordDialog, {DeleteRecordDialogAttributes} from '@osm/Components/Dialogs/DeleteRecordDialog';
import RecordDialog, {RecordDialogAttributes} from '@osm/Components/Dialogs/RecordDialog';
import MdIcon from '@osm/Components/MdIcon';
import Page, {PageAttributes} from '@osm/Components/Page';
import Model from '@osm/Models/Model';
import collect, {type Collection} from 'collect.js';
import {SortDirection} from 'coloquent';
import dayjs from 'dayjs';
import type {
Children,
Vnode,
VnodeDOM
} from 'mithril';
import Stream from 'mithril/stream';
import {match} from 'ts-pattern';
import {Match} from 'ts-pattern/dist/types/Match';
import type {Class} from 'type-fest';
export interface Meta {
current_page: number;
from: number;
last_page: number;
path: string;
per_page: number;
to: number;
total: number;
}
export interface JSONAPIResponse {
meta: Meta;
}
type RecordDialogVnode<M extends Model<any, any>, D extends RecordDialog<M>> = Vnode<RecordDialogAttributes<M>, D>;
type DeleteRecordDialogVnode<M extends Model<any, any>, D extends DeleteRecordDialog<M>> = Vnode<DeleteRecordDialogAttributes<M>, D>;
// noinspection JSUnusedLocalSymbols
/**
* @abstract
*/
export default abstract class RecordsPage<
M extends Model<any, any>,
D extends AddEditRecordDialog<M> = AddEditRecordDialog<M>,
DRD extends DeleteRecordDialog<M> = DeleteRecordDialog<M>
> extends Page {
abstract modelType: Class<M> & typeof Model<any, any>;
recordDialogType?: Class<D>;
deleteRecordDialogType?: Class<DRD>;
protected addRecordDialogSlug: string = '__add_record_dialog__' as const;
protected records = new Map<string, M>();
protected isTableLoading = true;
protected recordDialogsStates: Map<string | M, Stream<boolean>> = new Map();
protected deleteRecordsDialogStates: Map<string, Stream<boolean>> = new Map();
protected recordPageRouteName?: string;
protected readonlyRecords = false;
protected refreshRecords = true;
protected currentPageSize = 50;
protected pageSizes = [10, 25, 50, 100];
protected currentPage = 1;
protected lastPage = 1;
protected firstRowOfPage = 1;
protected totalRecords = 0;
protected lastRowOfPage = this.totalRecords;
protected filters: Map<string, string> = new Map();
protected sort: Map<string, SortDirection> = new Map([['id', SortDirection.ASC]]);
protected relatedFilters: Map<string, string> = new Map();
private listenedFilterColumns: string[] = [];
private listenedSortedColumns: string[] = [];
oninit(vnode: Vnode<PageAttributes, this>) {
super.oninit(vnode);
// @ts-ignore
this.modelType.pageSize = this.currentPageSize;
// Redraw on a first load to call onbeforeupdate
m.redraw();
}
async onbeforeupdate(vnode: VnodeDOM<PageAttributes, this>) {
super.onbeforeupdate(vnode);
if (this.refreshRecords) {
this.refreshRecords = false;
await this.loadRecords();
}
}
async loadRecords() {
this.isTableLoading = true;
let query = this.modelQuery();
// Fix Restify when filtering relations
query = query.option('related', query.getQuery().getInclude().join(','));
const response = await query.get(this.currentPage);
const rawResponse = response.getHttpClientResponse().getData() as JSONAPIResponse;
this.lastPage = rawResponse.meta.last_page;
this.firstRowOfPage = rawResponse.meta.from;
this.lastRowOfPage = rawResponse.meta.to;
this.currentPageSize = rawResponse.meta.per_page;
this.totalRecords = rawResponse.meta.total;
const data = response.getData();
this.records.clear();
if (data.length > 0) {
for (const record of data) {
this.records.set(record.getId()!, record);
}
}
this.isTableLoading = false;
m.redraw();
}
/**
* Temporary
*
* @source https://stackoverflow.com/a/65003355/7520280
*/
private static convertToSnakeCase(string_: string, trim = false, removeSpecials = false, underscoredNumbers = false) {
return string_.replace(removeSpecials ? /[^\w ]/g : '', '')
.replace(/([ _]+)/g, '_')
.replace(trim ? /(^_|_$)/gm : '', '')
.replace(underscoredNumbers ? /([^\dA-Z_])([^_a-z])/g : /([^\dA-Z_])([^\d_a-z])/g, (m, preUpper, upper) => `${preUpper}_${upper}`)
.replace(underscoredNumbers ? /([^\d_]\d|\d[^\d_])/g : '', (m, index) => (index ? index.split('').join('_') : ''))
.replace(/([A-Z])([A-Z])([^\dA-Z_])/g, (m, previousUpper, upper, lower) => `${previousUpper}_${upper}${lower}`)
.replaceAll('_.', '.') // remove redundant underscores
.toLowerCase();
}
modelQuery() {
// @ts-ignore
let query = this.modelType.query<M>();
for (const [attribute, value] of this.filters) {
// Query = query.where(attribute, value); TODO: Revert when Restify uses JSONAPI syntax
query = query.option(RecordsPage.convertToSnakeCase(attribute), value);
}
for (const [relation, value] of this.relatedFilters) {
query = query.option('related', relation)
.option('search', value);
}
for (const [attribute, value] of this.sort) {
query = query.orderBy(RecordsPage.convertToSnakeCase(attribute), value);
}
return query;
}
contents(vnode: Vnode<PageAttributes, this>) {
return (
<>
<h2>{this.title}</h2>
{this.table()}
<div className="sticky-bottom" style={{display: 'flex', flexDirection: 'column', gap: '16px'}}>
{this.fab().values().all()}
</div>
<>
{...this.recordDialogs().values<Children>().all()}
{...this.deleteRecordDialogs().values<Children>().all()}
</>
</>
);
}
fab(): Collection<Vnode> {
const fabs = collect<Vnode>({
refresh: (
<md-fab id="refresh-records" ariaLabel={__('Aggiorna')} onclick={this.onRefreshRecordsButtonClicked.bind(this)}>
<MdIcon icon={mdiRefresh} slot="icon"/>
</md-fab>
)
});
if ((this.recordDialogType || this.recordPageRouteName) && !this.readonlyRecords) {
fabs.put(
'add',
<md-fab id="add-record" ariaLabel={__('Aggiungi')} onclick={this.onAddNewRecordButtonClicked.bind(this)}>
<MdIcon icon={mdiPlus} slot="icon"/>
</md-fab>
);
}
return fabs;
}
table(): Children {
return (
<RecordsTable<M>
records={this.records}
paginated
selectable
cols={this.tableColumns()}
in-progress={this.isTableLoading}
readonly={this.readonlyRecords}
current-first-row={this.firstRowOfPage}
current-last-row={this.lastRowOfPage}
current-page-size={this.currentPageSize}
page-sizes={JSON.stringify(this.pageSizes)}
total-rows={this.totalRecords}
onTableRowClick={this.onTableRowClick.bind(this)}
onDeleteRecordButtonClick={this.onDeleteRecordButtonClicked.bind(this)}
onDeleteSelectedRecordsButtonClick={this.onDeleteSelectedRecordsButtonClicked.bind(this)}
onPageChange={this.onTablePageChange.bind(this)}
valueModifier={(value: unknown, attribute: string, record: M) => this.cellValueModifier(value, attribute, record)
.otherwise(() => value)}/>
);
}
abstract tableColumns(): Collection<Children> | Collection<RecordsTableColumnAttributes> | Collection<Children | RecordsTableColumnAttributes>;
protected onTableRowClick(recordId: string) {
if (!this.readonlyRecords) {
const model = this.records.get(recordId)!;
this.updateRecord(model);
}
}
updateRecord(model: M) {
if (this.recordPageRouteName) {
router.visit(route(this.recordPageRouteName, {id: model.getId()!}));
return;
}
const state = this.getRecordDialogState(model);
state(true);
}
recordDialogs() {
const collection = collect<RecordDialogVnode<M, D>>({});
for (const [key, state] of this.recordDialogsStates) {
// noinspection LocalVariableNamingConventionJS
const RD = this.recordDialogType!;
const record = key instanceof Model ? key : this.records.get(key);
const vnodeKey = record?.getId() ?? (key as string);
collection.put(vnodeKey, <RD key={vnodeKey} record={record} open={state}/>);
}
return collection;
}
deleteRecordDialogs() {
const collection = collect<DeleteRecordDialogVnode<M, DRD>>({});
for (const [key, state] of this.deleteRecordsDialogStates) {
// noinspection LocalVariableNamingConventionJS
const RD = this.deleteRecordDialogType ?? DeleteRecordDialog;
const keyArray = key.split(',');
const records = keyArray.map((recordId) => this.records.get(recordId)!);
collection.put(key, <RD key={key} records={records} open={state}/>);
}
return collection;
}
onAddNewRecordButtonClicked() {
if (this.recordDialogType) {
this.openNewRecordDialog();
} else if (this.recordPageRouteName) {
router.visit(route(this.recordPageRouteName, {id: 'new'}));
}
}
onRefreshRecordsButtonClicked() {
this.refreshRecords = true;
}
onDeleteRecordButtonClicked(recordId: string, event: MouseEvent) {
this.openDeleteRecordsDialog(this.records.get(recordId)!);
}
onDeleteSelectedRecordsButtonClicked(recordsIds: string[], event: MouseEvent) {
this.openDeleteRecordsDialog(recordsIds.map((recordId) => this.records.get(recordId)!));
}
onTablePageChange(event: CustomEvent<PaginateDetail>) {
const {pageSize, action} = event.detail;
this.currentPageSize = pageSize;
const {currentPage} = this;
match(action)
.with('first', () => (this.currentPage = 1))
.with('previous', () => (this.currentPage--))
.with('next', () => (this.currentPage++))
.with('last', () => (this.currentPage = this.lastPage))
.with('current', () => {})
.run();
if (currentPage !== this.currentPage) {
this.refreshRecords = true;
m.redraw();
}
}
onupdate(vnode: VnodeDOM<PageAttributes, this>) {
super.onupdate(vnode);
const columns = this.element.querySelectorAll('md-data-table-column');
for (const column of columns) {
const attribute = column.dataset.modelAttribute!;
if (!this.listenedFilterColumns.includes(attribute)) {
column.addEventListener('filter', this.onFilter.bind(this));
this.listenedFilterColumns.push(attribute);
}
if (!this.listenedSortedColumns.includes(attribute)) {
column.addEventListener('sort', this.onColumnSort.bind(this));
this.listenedSortedColumns.push(attribute);
}
}
}
protected onFilter(event: Event) {
// TODO: Check if it's possible to use caseSensitiveness when filtering (currently enforces case insensitive)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {text, column, caseSensitive} = (event as CustomEvent<FilterTextFieldInputEventDetail>).detail;
const modelAttribute = (column.dataset.filterRelated === '' ? undefined : column.dataset.filterRelated) ?? column.dataset.filterAttribute ?? column.dataset.modelAttribute!;
if (text === '') {
this.filters.delete(modelAttribute);
this.relatedFilters.delete(modelAttribute);
} else if (column.dataset.filterRelated === undefined) {
this.filters.set(modelAttribute, text);
} else {
this.relatedFilters.set(modelAttribute, text);
}
this.refreshRecords = true;
m.redraw();
}
protected onColumnSort(event: Event) {
const {column, isDescending} = (event as CustomEvent<SortButtonClickedEventDetail>).detail;
const modelAttribute = column.dataset.sortAttribute ?? column.dataset.modelAttribute!;
this.sort.clear();
this.sort.set(modelAttribute, isDescending ? SortDirection.DESC : SortDirection.ASC);
this.refreshRecords = true;
m.redraw();
}
openDeleteRecordsDialog(records: M | M[]) {
const key = records instanceof Model ? records.getId()! : records.map((r) => r.getId()).join(',');
let state = this.deleteRecordsDialogStates.get(key);
if (!state) {
state = Stream<boolean>();
state.map((open) => {
if (!open) {
this.refreshRecords = true;
m.redraw();
}
return open;
});
this.deleteRecordsDialogStates.set(key, state);
}
state(true);
}
openNewRecordDialog() {
const state = this.getRecordDialogState(undefined, this.addRecordDialogSlug);
state(true);
}
protected getRecordDialogState(record?: M, slug?: string) {
const key: string = slug ?? record?.getId() ?? '';
if (!this.recordDialogsStates.has(key)) {
const state = Stream<boolean>(false);
this.recordDialogsStates.set(key, state);
state.map((open) => {
if (!open) {
this.isTableLoading = false;
this.refreshRecords = true;
m.redraw();
}
return open;
});
}
return this.recordDialogsStates.get(key)!;
}
protected cellValueModifier(value: unknown, attribute: string, record: M): Match<string, unknown, string[], string> {
return match(attribute)
.returnType()
.with('createdAt', 'updatedAt', () => dayjs(value as Date).format('DD/MM/YYYY HH:mm'));
}
}