mirror of
https://github.com/devcode-it/openstamanager.git
synced 2025-06-05 22:09:38 +02:00
415 lines
14 KiB
TypeScript
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'));
|
|
}
|
|
}
|