├── assets ├── ajax │ ├── index.ts │ └── naja.ts ├── css │ ├── tom-select.css │ └── datagrid-full.css ├── integrations │ ├── index.ts │ ├── types │ │ └── tom-select.d.ts │ ├── vanilla-datepicker.ts │ ├── tom-select.ts │ └── sortable-js.ts ├── index.ts ├── types │ ├── integrations.d.ts │ ├── datagrid.d.ts │ ├── index.d.ts │ └── ajax.d.ts ├── plugins │ ├── index.ts │ ├── integrations │ │ ├── nette-forms.ts │ │ ├── datepicker.ts │ │ ├── selectpicker.ts │ │ └── sortable.ts │ └── features │ │ ├── item-detail.ts │ │ ├── treeView.ts │ │ ├── inline.ts │ │ ├── autosubmit.ts │ │ ├── checkboxes.ts │ │ ├── confirm.ts │ │ └── editable.ts ├── datagrid-full.ts └── utils.ts ├── src ├── Column │ ├── ColumnText.php │ ├── Action │ │ └── Confirmation │ │ │ ├── IConfirmation.php │ │ │ ├── CallbackConfirmation.php │ │ │ └── StringConfirmation.php │ ├── ActionCallback.php │ ├── Renderer.php │ ├── ColumnDateTime.php │ ├── FilterableColumn.php │ ├── ColumnNumber.php │ ├── ColumnStatus.php │ ├── ColumnLink.php │ ├── MultiAction.php │ └── ItemDetail.php ├── Exception │ ├── DatagridColumnNotFoundException.php │ ├── DatagridException.php │ ├── DatagridFilterNotFoundException.php │ ├── DatagridColumnException.php │ ├── DatagridFilterRangeException.php │ ├── DatagridGroupActionException.php │ ├── DatagridItemDetailException.php │ ├── DatagridColumnStatusException.php │ ├── DatagridActionCallbackException.php │ ├── DatagridArrayDataSourceException.php │ ├── DatagridColumnRendererException.php │ ├── DatagridDateTimeHelperException.php │ ├── DatagridWrongDataSourceException.php │ ├── DatagridLinkCreationException.php │ └── DatagridHasToBeAttachedToPresenterComponentException.php ├── AggregationFunction │ ├── IAggregatable.php │ ├── ISingleColumnAggregationFunction.php │ ├── IMultipleAggregationFunction.php │ ├── IAggregationFunction.php │ ├── FunctionSum.php │ └── TDatagridAggregationFunction.php ├── GroupAction │ ├── GroupTextAction.php │ ├── GroupTextareaAction.php │ ├── GroupMultiSelectAction.php │ ├── GroupSelectAction.php │ ├── GroupButtonAction.php │ └── GroupAction.php ├── Storage │ ├── IStateStorage.php │ ├── NoopStateStorage.php │ └── SessionStateStorage.php ├── templates │ ├── datagrid_filter_select.latte │ ├── datagrid_filter_text.latte │ ├── column_multi_action.latte │ ├── datagrid_filter_range.latte │ ├── datagrid_filter_date.latte │ ├── column_status.latte │ ├── datagrid_filter_daterange.latte │ └── datagrid_tree.latte ├── Traits │ ├── TButtonText.php │ ├── TButtonIcon.php │ ├── TButtonCaret.php │ ├── TButtonTitle.php │ ├── TButtonClass.php │ ├── TButtonTryAddIcon.php │ ├── TRenderCondition.php │ ├── TButtonRenderer.php │ └── TLink.php ├── Filter │ ├── IFilterDate.php │ ├── OneColumnFilter.php │ ├── SubmitButton.php │ ├── FilterDate.php │ ├── FilterRange.php │ ├── FilterMultiSelect.php │ ├── FilterText.php │ ├── FilterDateRange.php │ ├── FilterSelect.php │ └── Filter.php ├── InlineEdit │ ├── InlineAdd.php │ └── InlineEdit.php ├── Utils │ ├── Sorting.php │ ├── PropertyAccessHelper.php │ ├── NetteDatabaseSelectionHelper.php │ ├── ArraysHelper.php │ ├── ItemDetailForm.php │ └── DateTimeHelper.php ├── DataSource │ ├── IDataSource.php │ ├── DibiFluentPostgreDataSource.php │ ├── NetteDatabaseTableMssqlDataSource.php │ ├── FilterableDataSource.php │ ├── ApiDataSource.php │ ├── DibiFluentMssqlDataSource.php │ ├── SearchParamsBuilder.php │ └── ElasticsearchDataSource.php ├── CsvDataModel.php ├── Components │ └── DatagridPaginator │ │ ├── templates │ │ └── data_grid_paginator.latte │ │ └── DatagridPaginator.php ├── Export │ ├── ExportCsv.php │ └── Export.php ├── Localization │ └── SimpleTranslator.php ├── Toolbar │ └── ToolbarButton.php ├── Response │ └── CsvResponse.php ├── ColumnsSummary.php ├── Status │ └── Option.php └── DataModel.php ├── .claude ├── settings.local.json └── agents │ └── php-changelog-generator.md ├── LICENSE └── composer.json /assets/ajax/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./naja"; 2 | -------------------------------------------------------------------------------- /assets/css/tom-select.css: -------------------------------------------------------------------------------- 1 | .datagrid .form-select-sm .ts-control { 2 | padding: 0.25rem 0.5rem !important; 3 | } 4 | -------------------------------------------------------------------------------- /assets/integrations/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./sortable-js"; 2 | export * from "./tom-select"; 3 | export * from "./vanilla-datepicker"; 4 | -------------------------------------------------------------------------------- /assets/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./datagrid"; 2 | export * from "./plugins"; 3 | export * from "./integrations"; 4 | export * from "./datagrid"; 5 | -------------------------------------------------------------------------------- /src/Column/ColumnText.php: -------------------------------------------------------------------------------- 1 | = { 3 | [P in keyof T]?: RecursivePartial; 4 | }; 5 | export type TomInput = HTMLInputElement | HTMLSelectElement; 6 | export interface TomSettings { /* minimal needed interface */ } 7 | } 8 | -------------------------------------------------------------------------------- /src/GroupAction/GroupMultiSelectAction.php: -------------------------------------------------------------------------------- 1 | 8 | {label $input class => 'col-sm-3 control-label' /} 9 |
10 | {input $input} 11 |
12 | 13 | {else} 14 | {input $input} 15 | {/if} 16 | -------------------------------------------------------------------------------- /src/templates/datagrid_filter_text.latte: -------------------------------------------------------------------------------- 1 | {** 2 | * @param Filter $filter 3 | * @param Nette\Forms\Controls\TextInput $input 4 | *} 5 | 6 | {if $outer} 7 |
8 | {label $input class => 'col-sm-3 control-label' /} 9 |
10 | {input $input} 11 |
12 |
13 | {else} 14 | {input $input} 15 | {/if} 16 | -------------------------------------------------------------------------------- /assets/types/integrations.d.ts: -------------------------------------------------------------------------------- 1 | import { Datagrid } from ".."; 2 | 3 | export interface Sortable { 4 | initSortable(datagrid: Datagrid): void; 5 | 6 | initSortableTree(datagrid: Datagrid): void; 7 | } 8 | 9 | export interface Selectpicker { 10 | initSelectpickers(elements: HTMLElement[], datagrid: Datagrid): void; 11 | } 12 | 13 | export interface Datepicker { 14 | initDatepickers(elements: HTMLInputElement[], datagrid: Datagrid): void; 15 | } 16 | -------------------------------------------------------------------------------- /src/Traits/TButtonText.php: -------------------------------------------------------------------------------- 1 | text = $text; 16 | 17 | return $this; 18 | } 19 | 20 | public function getText(): string 21 | { 22 | return $this->text; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/Traits/TButtonIcon.php: -------------------------------------------------------------------------------- 1 | icon = $icon; 16 | 17 | return $this; 18 | } 19 | 20 | public function getIcon(): ?string 21 | { 22 | return $this->icon; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/Traits/TButtonCaret.php: -------------------------------------------------------------------------------- 1 | caret = $useCaret; 16 | 17 | return $this; 18 | } 19 | 20 | public function hasCaret(): bool 21 | { 22 | return $this->caret; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/Traits/TButtonTitle.php: -------------------------------------------------------------------------------- 1 | title = $title; 16 | 17 | return $this; 18 | } 19 | 20 | public function getTitle(): ?string 21 | { 22 | return $this->title; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/Traits/TButtonClass.php: -------------------------------------------------------------------------------- 1 | class = $class; 16 | 17 | return $this; 18 | } 19 | 20 | public function getClass(): string 21 | { 22 | return $this->class; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /assets/plugins/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./integrations/datepicker"; 2 | export * from "./integrations/nette-forms" 3 | export * from "./integrations/selectpicker"; 4 | export * from "./integrations/sortable"; 5 | 6 | export * from "./features/autosubmit"; 7 | export * from "./features/checkboxes"; 8 | export * from "./features/confirm"; 9 | export * from "./features/editable"; 10 | export * from "./features/inline"; 11 | export * from "./features/item-detail"; 12 | export * from "./features/treeView"; 13 | -------------------------------------------------------------------------------- /src/Column/Action/Confirmation/CallbackConfirmation.php: -------------------------------------------------------------------------------- 1 | callback = $callback; 14 | } 15 | 16 | public function getCallback(): callable 17 | { 18 | return $this->callback; 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/Filter/IFilterDate.php: -------------------------------------------------------------------------------- 1 | question; 15 | } 16 | 17 | public function getPlaceholderName(): ?string 18 | { 19 | return $this->placeholderName; 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/InlineEdit/InlineAdd.php: -------------------------------------------------------------------------------- 1 | shouldBeRendered; 18 | } 19 | 20 | public function setShouldBeRendered(bool $shouldBeRendered): void 21 | { 22 | $this->shouldBeRendered = $shouldBeRendered; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/GroupAction/GroupSelectAction.php: -------------------------------------------------------------------------------- 1 | options; 16 | } 17 | 18 | /** 19 | * Has the action some options? 20 | */ 21 | public function hasOptions(): bool 22 | { 23 | return $this->options !== []; 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/GroupAction/GroupButtonAction.php: -------------------------------------------------------------------------------- 1 | class = $class; 22 | } 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/templates/column_multi_action.latte: -------------------------------------------------------------------------------- 1 | {** 2 | * @param $multiAction Contributte\Datagrid\Column\MultiAction 3 | *} 4 | 5 | 19 | -------------------------------------------------------------------------------- /src/Storage/NoopStateStorage.php: -------------------------------------------------------------------------------- 1 | sortCallback = $sortCallback; 17 | } 18 | 19 | /** 20 | * @return array|string[] 21 | */ 22 | public function getSort(): array 23 | { 24 | return $this->sort; 25 | } 26 | 27 | public function getSortCallback(): ?callable 28 | { 29 | return $this->sortCallback; 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/Filter/OneColumnFilter.php: -------------------------------------------------------------------------------- 1 | column; 26 | } 27 | 28 | public function getCondition(): array 29 | { 30 | return [$this->column => $this->getValue()]; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/Traits/TButtonTryAddIcon.php: -------------------------------------------------------------------------------- 1 | addHtml(Html::el('i')->setAttribute('class', trim($iconClass))); 21 | 22 | if (mb_strlen($name) > 1) { 23 | $el->addHtml(' '); 24 | } 25 | } 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/Traits/TRenderCondition.php: -------------------------------------------------------------------------------- 1 | renderConditionCallback = $condition; 19 | 20 | return $this; 21 | } 22 | 23 | public function shouldBeRendered(Row $row): bool 24 | { 25 | $condition = $this->renderConditionCallback; 26 | 27 | return is_callable($condition) 28 | ? ($condition)($row->getItem()) 29 | : true; 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/AggregationFunction/IAggregationFunction.php: -------------------------------------------------------------------------------- 1 | getValue($class, $property); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /assets/plugins/integrations/nette-forms.ts: -------------------------------------------------------------------------------- 1 | import { DatagridPlugin, Nette } from "../../types"; 2 | import { Datagrid } from "../.."; 3 | import { window } from "../../utils"; 4 | 5 | export class NetteFormsPlugin implements DatagridPlugin { 6 | constructor(private nette?: Nette) { 7 | } 8 | 9 | onDatagridInit(datagrid: Datagrid): boolean { 10 | datagrid.ajax.addEventListener('complete', (event) => { 11 | this.initNetteForms(datagrid); 12 | }); 13 | 14 | this.initNetteForms(datagrid); 15 | 16 | return true; 17 | } 18 | 19 | private initNetteForms(datagrid: Datagrid): void { 20 | const nette = this.nette ?? window().Nette ?? null; 21 | 22 | if (nette) { 23 | datagrid.el.querySelectorAll("form").forEach(form => nette.initForm(form)); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /assets/plugins/integrations/datepicker.ts: -------------------------------------------------------------------------------- 1 | import { Datagrid } from "../.."; 2 | import { DatagridPlugin, Datepicker } from "../../types"; 3 | 4 | export class DatepickerPlugin implements DatagridPlugin { 5 | constructor(private datepicker: Datepicker) { 6 | } 7 | 8 | onDatagridInit(datagrid: Datagrid): boolean { 9 | datagrid.ajax.addEventListener('complete', (event) => { 10 | this.initDatepicker(datagrid); 11 | }); 12 | 13 | this.initDatepicker(datagrid); 14 | 15 | return true; 16 | } 17 | 18 | private initDatepicker(datagrid: Datagrid): void { 19 | const elements = datagrid.el.querySelectorAll("input[data-provide='datepicker']"); 20 | 21 | if (elements.length >= 1) { 22 | this.datepicker.initDatepickers(Array.from(elements), datagrid); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /assets/plugins/integrations/selectpicker.ts: -------------------------------------------------------------------------------- 1 | import { DatagridPlugin, Selectpicker } from "../../types"; 2 | import { Datagrid } from "../.."; 3 | 4 | export class SelectpickerPlugin implements DatagridPlugin { 5 | constructor(private selectpicker: Selectpicker) { 6 | } 7 | 8 | onDatagridInit(datagrid: Datagrid): boolean { 9 | datagrid.ajax.addEventListener('complete', (event) => { 10 | this.initSelectpicker(datagrid); 11 | }); 12 | 13 | this.initSelectpicker(datagrid); 14 | 15 | return true; 16 | } 17 | 18 | private initSelectpicker(datagrid: Datagrid): void { 19 | const elements = datagrid.el.querySelectorAll("select.selectpicker"); 20 | 21 | if (elements.length >= 1) { 22 | this.selectpicker.initSelectpickers(Array.from(elements), datagrid); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Column/ActionCallback.php: -------------------------------------------------------------------------------- 1 | href is a identifier of user callback 26 | */ 27 | $params += ['__key' => $this->href]; 28 | 29 | return $this->grid->link('actionCallback!', $params); 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/Column/Renderer.php: -------------------------------------------------------------------------------- 1 | callback = $callback; 20 | $this->conditionCallback = $conditionCallback; 21 | } 22 | 23 | /** 24 | * Get custom renderer callback 25 | */ 26 | public function getCallback(): callable 27 | { 28 | return $this->callback; 29 | } 30 | 31 | /** 32 | * Get custom renderer condition callback 33 | */ 34 | public function getConditionCallback(): ?callable 35 | { 36 | return $this->conditionCallback; 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/Utils/NetteDatabaseSelectionHelper.php: -------------------------------------------------------------------------------- 1 | getConnection(); 16 | 17 | return $connection->getDriver(); 18 | } 19 | 20 | public static function getContext(Selection $selection): Explorer 21 | { 22 | $reflection = new ReflectionClass($selection); 23 | 24 | $explorerProperty = $reflection->getProperty('explorer'); 25 | $explorerProperty->setAccessible(true); 26 | 27 | return $explorerProperty->getValue($selection); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/templates/datagrid_filter_range.latte: -------------------------------------------------------------------------------- 1 | {** 2 | * @param Filter $filter 3 | * @param Nette\Forms\Container $input 4 | *} 5 | 6 | {var $container = $input} 7 | 8 | {if $outer} 9 |
10 | {label $container['from'], class => 'col-sm-3 control-label' /} 11 |
12 | {input $container['from']} 13 |
14 | {label $container['to'], class => 'filter-range-delimiter col-sm-1 control-label' /} 15 |
16 | {input $container['to']} 17 |
18 |
19 | {else} 20 |
21 |
22 | {input $container['from']} 23 | 24 |
-
25 | 26 | {input $container['to']} 27 |
28 |
29 | {/if} 30 | -------------------------------------------------------------------------------- /src/Storage/SessionStateStorage.php: -------------------------------------------------------------------------------- 1 | sessionSection->get($key); 23 | } 24 | 25 | public function saveState(string $key, mixed $value): void 26 | { 27 | $this->sessionSection->set($key, $value); 28 | } 29 | 30 | public function deleteState(string $key): void 31 | { 32 | $this->sessionSection->remove($key); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /assets/integrations/vanilla-datepicker.ts: -------------------------------------------------------------------------------- 1 | import { Datepicker as DatepickerInterface } from "../types"; 2 | import { Datepicker } from "vanillajs-datepicker"; 3 | import { DatepickerOptions } from "vanillajs-datepicker/Datepicker"; 4 | 5 | export class VanillaDatepicker implements DatepickerInterface { 6 | constructor(private opts: DatepickerOptions | ((input: HTMLInputElement) => DatepickerOptions) = {}) { 7 | } 8 | 9 | initDatepickers(elements: HTMLInputElement[]): void { 10 | elements.forEach((element) => { 11 | const options = typeof this.opts === "function" ? this.opts(element) : this.opts; 12 | const picker = new Datepicker(element, { 13 | ...options, 14 | updateOnBlur: false 15 | }); 16 | 17 | element.addEventListener('changeDate', () => { 18 | const form = element.closest('form'); 19 | if (form) { 20 | form.submit(); 21 | } 22 | }); 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/templates/datagrid_filter_date.latte: -------------------------------------------------------------------------------- 1 | {** 2 | * @param Filter $filter 3 | * @param Nette\Forms\Controls\TextInput $input 4 | * @param string $iconPrefix Icon prefix (fa fa-) 5 | *} 6 | 7 | {if $outer} 8 |
9 | {label $input class => 'col-sm-3 control-label' /} 10 |
11 |
12 | {input $input} 13 | 16 |
17 |
18 |
19 | {else} 20 |
21 |
22 | {input $input} 23 | 26 |
27 |
28 | {/if} 29 | -------------------------------------------------------------------------------- /assets/types/datagrid.d.ts: -------------------------------------------------------------------------------- 1 | import { Datagrid, Datagrids } from ".."; 2 | import { EventMap } from "."; 3 | 4 | export interface DatagridEventDetail { 5 | datagrid: Datagrid; 6 | } 7 | 8 | export interface DatagridEventMap extends EventMap { 9 | beforeInit: CustomEvent; 10 | afterInit: CustomEvent; 11 | } 12 | 13 | export interface DatagridPlugin { 14 | onInit?(datagrids: Datagrids): void; 15 | 16 | onDatagridInit?(datagrid: Datagrid): boolean; 17 | } 18 | 19 | export interface DatagridOptions { 20 | confirm(this: Datagrid, message: string): boolean; 21 | 22 | // Returning null will skip this datagrid 23 | resolveDatagridName: (this: Datagrid, datagrid: HTMLElement) => string | null; 24 | plugins: DatagridPlugin[]; 25 | } 26 | 27 | export interface DatagridsOptions { 28 | datagrid: Partial; 29 | selector: string; 30 | root: HTMLElement | string; 31 | } 32 | -------------------------------------------------------------------------------- /assets/integrations/tom-select.ts: -------------------------------------------------------------------------------- 1 | import { Constructor, Selectpicker } from "../types"; 2 | import { RecursivePartial, TomInput, TomSettings } from "tom-select/dist/types/types"; 3 | import type TomSelectType from "tom-select"; 4 | import { window } from "../utils"; 5 | 6 | export class TomSelect implements Selectpicker { 7 | constructor( 8 | private select?: Constructor, 9 | private opts: RecursivePartial | ((input: HTMLElement | TomInput) => RecursivePartial) = {} 10 | ) { 11 | } 12 | 13 | initSelectpickers(elements: HTMLElement[]): void { 14 | const Select = this.select ?? window()?.TomSelect ?? null; 15 | 16 | if (Select) { 17 | elements.forEach(element => { 18 | if(element.hasAttribute('data-Tom-Initialised')){ 19 | return; 20 | } 21 | element.setAttribute('data-Tom-Initialised','true'); 22 | 23 | new Select( 24 | element as TomInput, 25 | typeof this.opts === "function" ? this.opts(element) : this.opts) 26 | }) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/DataSource/IDataSource.php: -------------------------------------------------------------------------------- 1 | $filters 25 | */ 26 | public function filter(array $filters): void; 27 | 28 | /** 29 | * Filter data - get one row 30 | * 31 | * @return static 32 | */ 33 | public function filterOne(array $condition): self; 34 | 35 | /** 36 | * Apply limit and offset on data 37 | * 38 | * @phpstan-param positive-int|0 $offset 39 | * @phpstan-param positive-int|0 $limit 40 | * @return static 41 | */ 42 | public function limit(int $offset, int $limit): self; 43 | 44 | /** 45 | * Sort data 46 | * 47 | * @return static 48 | */ 49 | public function sort(Sorting $sorting): self; 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/Utils/ArraysHelper.php: -------------------------------------------------------------------------------- 1 | getCondition(); 13 | $driver = $this->dataSource->getConnection()->getDriver(); 14 | $or = []; 15 | 16 | foreach ($condition as $column => $value) { 17 | 18 | $column = '[' . $column . ']'; 19 | 20 | if ($filter->isExactSearch()) { 21 | $this->dataSource->where(sprintf('%s = %%s', $column), $value); 22 | 23 | continue; 24 | } 25 | 26 | $words = $filter->hasSplitWordsSearch() === false ? [$value] : explode(' ', $value); 27 | 28 | foreach ($words as $word) { 29 | $or[] = $column . ' ILIKE ' . $driver->escapeText('%' . $word . '%'); 30 | } 31 | } 32 | 33 | if (count($or) > 1) { 34 | $this->dataSource->where($filter->hasConjunctionSearch() ? '(%and)' : '(%or)', $or); 35 | } else { 36 | $this->dataSource->where($or); 37 | } 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/Column/ColumnDateTime.php: -------------------------------------------------------------------------------- 1 | format($this->format); 29 | } catch (DatagridDateTimeHelperException) { 30 | /** 31 | * Otherwise just return raw string 32 | */ 33 | return (string) $value; 34 | } 35 | } 36 | 37 | return $value->format($this->format); 38 | } 39 | 40 | /** 41 | * @return static 42 | */ 43 | public function setFormat(string $format): self 44 | { 45 | $this->format = $format; 46 | 47 | return $this; 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/GroupAction/GroupAction.php: -------------------------------------------------------------------------------- 1 | title; 29 | } 30 | 31 | /** 32 | * @return static 33 | */ 34 | public function setClass(string $class): self 35 | { 36 | $this->class = $class; 37 | 38 | return $this; 39 | } 40 | 41 | public function getClass(): string 42 | { 43 | return $this->class; 44 | } 45 | 46 | /** 47 | * @return static 48 | */ 49 | public function setAttribute(string $key, mixed $value): self 50 | { 51 | $this->attributes[$key] = $value; 52 | 53 | return $this; 54 | } 55 | 56 | public function getAttributes(): array 57 | { 58 | return $this->attributes; 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /assets/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import TomSelect from "tom-select"; 2 | 3 | export interface Nette { 4 | initForm: (form: HTMLFormElement) => void; 5 | } 6 | 7 | export type Constructor = new (...args: any[]) => T; 8 | 9 | export type KeysOf = { [P in keyof T]: TVal; } 10 | 11 | export interface ExtendedWindow extends Window { 12 | jQuery?: any; 13 | Nette?: Nette; 14 | TomSelect?: Constructor; 15 | } 16 | 17 | // https://github.com/naja-js/naja/blob/384d298a9199bf778985d1bcf5747fe8de305b22/src/utils.ts 18 | type EventListenerFunction = ( 19 | this: ET, 20 | event: E 21 | ) => boolean | void | Promise; 22 | 23 | interface EventListenerObject { 24 | handleEvent(event: E): void | Promise; 25 | } 26 | 27 | export type EventListener = 28 | | EventListenerFunction 29 | | EventListenerObject 30 | | null; 31 | 32 | export type EventDetail = E extends CustomEvent ? D : never; 33 | 34 | export interface EventMap extends Record { 35 | } 36 | 37 | export * from "./datagrid"; 38 | export * from "./integrations"; 39 | export * from "./ajax"; 40 | -------------------------------------------------------------------------------- /src/CsvDataModel.php: -------------------------------------------------------------------------------- 1 | getHeader(); 27 | } 28 | 29 | foreach ($this->data as $item) { 30 | $return[] = $this->getRow($item); 31 | } 32 | 33 | return $return; 34 | } 35 | 36 | public function getHeader(): array 37 | { 38 | $header = []; 39 | 40 | foreach ($this->columns as $column) { 41 | $header[] = $this->translator->translate($column->getName()); 42 | } 43 | 44 | return $header; 45 | } 46 | 47 | /** 48 | * Get item values saved into row 49 | */ 50 | public function getRow(mixed $item): array 51 | { 52 | $row = []; 53 | 54 | foreach ($this->columns as $column) { 55 | $row[] = strip_tags((string) $column->render($item)); 56 | } 57 | 58 | return $row; 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /assets/datagrid-full.ts: -------------------------------------------------------------------------------- 1 | import naja from "naja"; 2 | import { default as netteForms } from "nette-forms"; 3 | import { 4 | AutosubmitPlugin, 5 | CheckboxPlugin, 6 | ConfirmPlugin, 7 | createDatagrids, 8 | DatepickerPlugin, 9 | EditablePlugin, 10 | InlinePlugin, 11 | ItemDetailPlugin, 12 | NetteFormsPlugin, 13 | SelectpickerPlugin, 14 | SortableJS, 15 | SortablePlugin, 16 | TomSelect, 17 | TreeViewPlugin, 18 | VanillaDatepicker, 19 | } from "." 20 | import { NajaAjax } from "./ajax"; 21 | import Select from "tom-select"; 22 | import { Dropdown } from "bootstrap"; 23 | 24 | // Datagrid + UI 25 | document.addEventListener("DOMContentLoaded", () => { 26 | // Initialize dropdowns 27 | Array.from(document.querySelectorAll('.dropdown')) 28 | .forEach(el => new Dropdown(el)) 29 | 30 | // Initialize Naja (nette ajax) 31 | naja.formsHandler.netteForms = netteForms; 32 | naja.initialize(); 33 | 34 | // Initialize datagrids 35 | createDatagrids(new NajaAjax(naja), { 36 | datagrid: { 37 | plugins: [ 38 | new AutosubmitPlugin(), 39 | new CheckboxPlugin(), 40 | new ConfirmPlugin(), 41 | new EditablePlugin(), 42 | new InlinePlugin(), 43 | new ItemDetailPlugin(), 44 | new NetteFormsPlugin(netteForms), 45 | new SortablePlugin(new SortableJS()), 46 | new DatepickerPlugin(new VanillaDatepicker({ buttonClass: 'btn' })), 47 | new SelectpickerPlugin(new TomSelect(Select)), 48 | new TreeViewPlugin(), 49 | ], 50 | }, 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/templates/column_status.latte: -------------------------------------------------------------------------------- 1 | {** 2 | * @param $row Contributte\Datagrid\Row 3 | * @param $status Contributte\Datagrid\Column\ColumnStatus 4 | *} 5 | 6 | {var $activeOption = $status->getCurrentOption($row)} 7 | 8 | 38 | -------------------------------------------------------------------------------- /src/Components/DatagridPaginator/templates/data_grid_paginator.latte: -------------------------------------------------------------------------------- 1 | {* 2 | * @param Paginator $paginator 3 | * @param array $steps 4 | * @param string $iconPrefix 5 | *} 6 | 7 | {var $link = [$control->getParent(), link]} 8 | 9 |
10 | {capture $firstButton} 11 |  {='contributte_datagrid.previous'|translate} 12 | {/capture} 13 | 14 | {if $paginator->isFirst()} 15 | {$firstButton} 16 | {else} 17 | 18 | {/if} 19 | 20 | {foreach $steps as $step} 21 | {if $step == $paginator->page} 22 | {$step} 23 | {else} 24 | {$step} 25 | {/if} 26 | 27 | {if $iterator->nextValue > $step + 1}{/if} 28 | {/foreach} 29 | 30 | {capture $lastButton} 31 | {='contributte_datagrid.next'|translate}  32 | {/capture} 33 | 34 | {if $paginator->isLast()} 35 | {$lastButton} 36 | {else} 37 | 38 | {/if} 39 |
40 | -------------------------------------------------------------------------------- /src/Export/ExportCsv.php: -------------------------------------------------------------------------------- 1 | getExportCallback($name, $outputEncoding, $delimiter, $includeBom), 30 | $filtered 31 | ); 32 | } 33 | 34 | private function getExportCallback( 35 | string $name, 36 | string $outputEncoding, 37 | string $delimiter, 38 | bool $includeBom 39 | ): callable 40 | { 41 | return function ( 42 | array $data, 43 | Datagrid $grid 44 | ) use ( 45 | $name, 46 | $outputEncoding, 47 | $delimiter, 48 | $includeBom 49 | ): void { 50 | $columns = $this->getColumns(); 51 | 52 | if ($columns === []) { 53 | $columns = $this->grid->getColumns(); 54 | } 55 | 56 | $csvDataModel = new CsvDataModel($data, $columns, $this->grid->getTranslator()); 57 | 58 | $this->grid->getPresenter()->sendResponse(new CsvResponse( 59 | $csvDataModel->getSimpleData(), 60 | $name, 61 | $outputEncoding, 62 | $delimiter, 63 | $includeBom 64 | )); 65 | }; 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /src/Filter/SubmitButton.php: -------------------------------------------------------------------------------- 1 | text); 27 | 28 | $this->text = 'contributte_datagrid.filter_submit_button'; 29 | $this->class = 'btn btn-sm btn-primary'; 30 | $this->icon = 'search'; 31 | 32 | $this->control = Html::el('button', ['type' => 'submit', 'name' => 'submit']); 33 | } 34 | 35 | public function getControl(Stringable|string|null $caption = null): Html 36 | { 37 | $el = parent::getControl($caption); 38 | 39 | $el->setAttribute('type', 'submit'); 40 | $el->setAttribute('class', $this->getClass()); 41 | 42 | if ($this->getIcon() !== null) { 43 | $el->addHtml( 44 | Html::el('span')->appendAttribute( 45 | 'class', 46 | Datagrid::$iconPrefix . $this->getIcon() 47 | ) 48 | ); 49 | 50 | if ($this->getText() !== '') { 51 | $el->addHtml(' '); 52 | } 53 | } 54 | 55 | $el->addText($this->grid->getTranslator()->translate($this->getText())); 56 | 57 | return $el; 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/Filter/FilterDate.php: -------------------------------------------------------------------------------- 1 | addText($this->key, $this->name); 19 | 20 | $control->setHtmlAttribute('data-provide', 'datepicker') 21 | ->setHtmlAttribute('data-date-orientation', 'bottom') 22 | ->setHtmlAttribute('data-date-format', $this->getJsFormat()) 23 | ->setHtmlAttribute('data-date-today-highlight', 'true') 24 | ->setHtmlAttribute('data-date-autoclose', 'true'); 25 | 26 | $this->addAttributes($control); 27 | 28 | if ($this->grid->hasAutoSubmit()) { 29 | $control->setHtmlAttribute('data-autosubmit-change', true); 30 | } 31 | 32 | if ($this->getPlaceholder() !== null) { 33 | $control->setHtmlAttribute('placeholder', $this->getPlaceholder()); 34 | } 35 | } 36 | 37 | /** 38 | * Set format for datepicker etc 39 | */ 40 | public function setFormat(string $phpFormat, string $jsFormat): IFilterDate 41 | { 42 | $this->format = [$phpFormat, $jsFormat]; 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * Get php format for datapicker 49 | */ 50 | public function getPhpFormat(): string 51 | { 52 | return $this->format[0]; 53 | } 54 | 55 | /** 56 | * Get js format for datepicker 57 | */ 58 | public function getJsFormat(): string 59 | { 60 | return $this->format[1]; 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/templates/datagrid_filter_daterange.latte: -------------------------------------------------------------------------------- 1 | {** 2 | * @param Filter $filter 3 | * @param Nette\Forms\Container $input 4 | * @param string $iconPrefix Icon prefix (fa fa-) 5 | *} 6 | 7 | {var $container = $input} 8 | 9 | {if $outer} 10 |
11 | {label $container['from'], class => 'col-sm-3 control-label' /} 12 |
13 |
14 | {input $container['from']} 15 | 18 |
19 |
20 | {label $container['to'], class => 'filter-range-delimiter col-sm-1 control-label' /} 21 |
22 |
23 | {input $container['to']} 24 | 27 |
28 |
29 |
30 | {else} 31 |
32 |
33 | {input $container['from']} 34 | 37 |
38 | 39 |
-
40 | 41 |
42 | {input $container['to']} 43 | 46 |
47 |
48 | {/if} 49 | -------------------------------------------------------------------------------- /assets/plugins/features/item-detail.ts: -------------------------------------------------------------------------------- 1 | import { Datagrid } from "../.."; 2 | import { DatagridPlugin } from "../../types"; 3 | 4 | export class ItemDetailPlugin implements DatagridPlugin { 5 | onDatagridInit(datagrid: Datagrid): boolean { 6 | datagrid.el.querySelectorAll("[data-toggle-detail-grid]") 7 | .forEach((el) => { 8 | if (el.getAttribute("data-toggle-detail-grid") !== datagrid.name) return; 9 | const toggleId = el.getAttribute("data-toggle-detail")!; 10 | 11 | 12 | el.addEventListener("click", (e) => { 13 | const contentRow = datagrid.el.querySelector( 14 | `.item-detail-${datagrid.name}-id-${toggleId}` 15 | ); 16 | 17 | const gridRow = el.closest('tr'); 18 | 19 | if (contentRow) { 20 | // const div = contentRow.querySelector("td > div"); 21 | // if (div && !el.classList.contains("datagrid--slide-toggle")) { 22 | // attachSlideToggle(div, el); 23 | // } TODO: fix 24 | contentRow.classList.add("datagrid--content-row") 25 | contentRow.classList.toggle("is-active"); 26 | 27 | } 28 | 29 | datagrid.ajax.addEventListener("before", (e) => { 30 | if (e.detail.params.url.includes(`do=${datagrid.name}-getItemDetail`) && e.detail.params.url.includes(`grid-id=${toggleId}`)) { 31 | e.stopPropagation(); 32 | e.preventDefault(); 33 | } 34 | }) 35 | }) 36 | }); 37 | 38 | datagrid.ajax.addEventListener("success", ({detail: {payload}}) => { 39 | if (payload._datagrid_redraw_item_id && payload._datagrid_redraw_item_class) { 40 | datagrid.el.querySelector( 41 | `tr[data-id='${payload._datagrid_redraw_item_id}']` 42 | )?.setAttribute("class", payload._datagrid_redraw_item_class) 43 | } 44 | }) 45 | 46 | return true; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /assets/integrations/sortable-js.ts: -------------------------------------------------------------------------------- 1 | import { Datagrid } from ".."; 2 | import { Sortable as SortableInterface } from "../types"; 3 | import Sortable from "sortablejs" 4 | 5 | export class SortableJS implements SortableInterface { 6 | initSortable(datagrid: Datagrid): void { 7 | const sortable = datagrid.el.querySelector("[data-sortable]"); 8 | if (sortable) { 9 | new Sortable(sortable, { 10 | handle: '.handle-sort', 11 | draggable: 'tr', 12 | sort: true, 13 | direction: 'vertical', 14 | async onEnd({item}) { 15 | const itemId = item.getAttribute("data-id"); 16 | if (itemId) { 17 | const prevId = item.previousElementSibling?.getAttribute("data-id") ?? null; 18 | const nextId = item.nextElementSibling?.getAttribute("data-id") ?? null; 19 | 20 | const tbody = datagrid.el.querySelector("tbody"); 21 | 22 | if (tbody) { 23 | let componentPrefix = tbody.getAttribute("data-sortable-parent-path") ?? ''; 24 | if (componentPrefix.length) componentPrefix = `${componentPrefix}-`; 25 | 26 | const url = tbody.getAttribute("data-sortable-url") ?? "?do=sort"; 27 | 28 | const data = { 29 | [`${componentPrefix}item_id`]: itemId, 30 | ...(prevId ? {[`${componentPrefix}prev_id`]: prevId} : {}), 31 | ...(nextId ? {[`${componentPrefix}next_id`]: nextId} : {}), 32 | }; 33 | 34 | return await datagrid.ajax.request({ 35 | method: "GET", 36 | url, 37 | data, 38 | }) 39 | } 40 | } 41 | }, 42 | }) 43 | } 44 | } 45 | 46 | initSortableTree(datagrid: Datagrid): void { 47 | datagrid.el.querySelectorAll(".datagrid-tree-item-children").forEach((el) => { 48 | new Sortable(el, { 49 | handle: '.handle-sort', 50 | draggable: '.datagrid-tree-item:not(.datagrid-tree-header)', 51 | async onEnd({item}) { 52 | // TODO 53 | }, 54 | }) 55 | }) 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/Filter/FilterRange.php: -------------------------------------------------------------------------------- 1 | addContainer($this->key); 31 | 32 | $from = $container->addText('from', $this->name); 33 | $to = $container->addText('to', $this->nameSecond); 34 | 35 | $this->addAttributes($from); 36 | $this->addAttributes($to); 37 | 38 | $placeholders = $this->getPlaceholders(); 39 | 40 | if ($placeholders !== []) { 41 | $text_from = reset($placeholders); 42 | 43 | if ($text_from) { 44 | $from->setHtmlAttribute('placeholder', $text_from); 45 | } 46 | 47 | $text_to = end($placeholders); 48 | 49 | if ($text_to && ($text_to !== $text_from)) { 50 | $to->setHtmlAttribute('placeholder', $text_to); 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * Set html attr placeholder of both inputs 57 | * 58 | * @return static 59 | */ 60 | public function setPlaceholders(array $placeholders): self 61 | { 62 | $this->placeholders = $placeholders; 63 | 64 | return $this; 65 | } 66 | 67 | /** 68 | * Get html attr placeholders 69 | */ 70 | public function getPlaceholders(): array 71 | { 72 | return $this->placeholders; 73 | } 74 | 75 | /** 76 | * Get filter condition 77 | */ 78 | public function getCondition(): array 79 | { 80 | $value = $this->getValue(); 81 | 82 | return [ 83 | $this->column => [ 84 | 'from' => $value['from'] ?? '', 85 | 'to' => $value['to'] ?? '', 86 | ], 87 | ]; 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /assets/plugins/features/treeView.ts: -------------------------------------------------------------------------------- 1 | import { DatagridPlugin } from "../../types"; 2 | import { Datagrid } from "../.."; 3 | 4 | export class TreeViewPlugin implements DatagridPlugin { 5 | onDatagridInit(datagrid: Datagrid): boolean { 6 | datagrid.ajax.addEventListener("before", (e) => { 7 | }) 8 | 9 | datagrid.ajax.addEventListener("success", ({detail: {payload}}) => { 10 | if (payload._datagrid_tree) { 11 | const id = payload._datagrid_tree; 12 | const rowBlock = document.querySelector(`.datagrid-tree-item[data-id="${id}"]`); 13 | const childrenBlock = document.querySelector(`.datagrid-tree-item[data-id="${id}"] .datagrid-tree-item-children`); 14 | if (childrenBlock) { 15 | if (childrenBlock.classList.contains('showed')) { 16 | childrenBlock.innerHTML = ''; 17 | childrenBlock.classList.remove('showed'); 18 | if (rowBlock) { 19 | const chevron = rowBlock.querySelector(`a.chevron`); 20 | if (chevron) { 21 | chevron.style.transform = "rotate(0deg)"; 22 | } 23 | } 24 | return; 25 | } 26 | 27 | childrenBlock.classList.add('showed'); 28 | if (rowBlock) { 29 | const chevron = rowBlock.querySelector(`a.chevron`); 30 | if (chevron) { 31 | chevron.style.transform = "rotate(90deg)"; 32 | } 33 | } 34 | const snippets = payload.snippets; 35 | for (const snippetName in snippets) { 36 | const snippet = snippets[snippetName]; 37 | const snippetDocEl = new DOMParser().parseFromString(snippet, "text/html") 38 | .querySelector("[data-id]"); 39 | 40 | const id = snippetDocEl?.getAttribute("data-id") ?? ''; 41 | const hasChildren = snippetDocEl?.hasAttribute("data-has-children") ?? false; 42 | 43 | const template = `\n
${snippet}
`; 44 | 45 | childrenBlock.innerHTML = template; 46 | } 47 | //children_block.addClass('loaded'); 48 | //children_block.slideToggle('fast'); 49 | } 50 | } 51 | }) 52 | return true; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /assets/plugins/integrations/sortable.ts: -------------------------------------------------------------------------------- 1 | import { Datagrid } from "../../datagrid"; 2 | import { DatagridPlugin, Sortable } from "../../types"; 3 | 4 | export class SortablePlugin implements DatagridPlugin { 5 | constructor(private sortable: Sortable) { 6 | } 7 | 8 | onDatagridInit(datagrid: Datagrid): boolean { 9 | datagrid.ajax.addEventListener('before', (event) => { 10 | // TODO old ln 694... wtf? 11 | }) 12 | 13 | this.sortable.initSortable(datagrid); 14 | 15 | datagrid.ajax.addEventListener('success', ({detail: {payload}}) => { 16 | if (payload._datagrid_sort) { 17 | for (const key in payload._datagrid_sort) { 18 | const href = payload._datagrid_sort[key]; 19 | const element = datagrid.el.querySelector(`#datagrid-sort-${key}`); 20 | 21 | if (element) { 22 | // TODO: Only for BC support, to be removed 23 | element.setAttribute("href", href); 24 | 25 | element.setAttribute("data-href", href); 26 | } 27 | } 28 | this.sortable.initSortable(datagrid); 29 | } 30 | 31 | if (payload._datagrid_tree) { 32 | const childrenContainer = datagrid.el.querySelector( 33 | `.datagrid-tree-item[data-id='${payload._datagrid_tree}'] .datagrid-tree-item-children` 34 | ); 35 | if (childrenContainer && payload.snippets) { 36 | childrenContainer.classList.add("loaded"); 37 | for (const key in payload.snippets) { 38 | const snippet = payload.snippets[key]; 39 | 40 | const doc = new DOMParser().parseFromString(snippet, 'text/html'); 41 | const element = doc.firstElementChild; 42 | if (element) { 43 | const treeItem = document.createElement("div"); 44 | treeItem.id = key; 45 | treeItem.classList.add("datagrid-tree-item") 46 | treeItem.setAttribute("data-id", key); 47 | if (element.hasAttribute("has-children")) { 48 | treeItem.classList.add("has-children"); 49 | } 50 | 51 | childrenContainer.append(treeItem); 52 | // attachSlideToggle(childrenContainer); 53 | } 54 | } 55 | } 56 | this.sortable.initSortableTree(datagrid); 57 | } 58 | }) 59 | return true; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/DataSource/NetteDatabaseTableMssqlDataSource.php: -------------------------------------------------------------------------------- 1 | getCondition(); 16 | 17 | try { 18 | $date = DateTimeHelper::tryConvertToDateTime($conditions[$filter->getColumn()], [$filter->getPhpFormat()]); 19 | 20 | $this->dataSource->where( 21 | sprintf('CONVERT(varchar(10), %s, 112) = ?', $filter->getColumn()), 22 | $date->format('Ymd') 23 | ); 24 | } catch (DatagridDateTimeHelperException) { 25 | // ignore the invalid filter value 26 | } 27 | } 28 | 29 | protected function applyFilterDateRange(FilterDateRange $filter): void 30 | { 31 | $conditions = $filter->getCondition(); 32 | 33 | $valueFrom = $conditions[$filter->getColumn()]['from']; 34 | $valueTo = $conditions[$filter->getColumn()]['to']; 35 | 36 | if ($valueFrom) { 37 | try { 38 | $dateFrom = DateTimeHelper::tryConvertToDateTime($valueFrom, [$filter->getPhpFormat()]); 39 | $dateFrom->setTime(0, 0, 0); 40 | 41 | $this->dataSource->where( 42 | sprintf('CONVERT(varchar(10), %s, 112) >= ?', $filter->getColumn()), 43 | $dateFrom->format('Ymd') 44 | ); 45 | } catch (DatagridDateTimeHelperException) { 46 | // ignore the invalid filter value 47 | } 48 | } 49 | 50 | if ($valueTo) { 51 | try { 52 | $dateTo = DateTimeHelper::tryConvertToDateTime($valueTo, [$filter->getPhpFormat()]); 53 | $dateTo->setTime(23, 59, 59); 54 | 55 | $this->dataSource->where( 56 | sprintf('CONVERT(varchar(10), %s, 112) <= ?', $filter->getColumn()), 57 | $dateTo->format('Ymd') 58 | ); 59 | } catch (DatagridDateTimeHelperException) { 60 | // ignore the invalid filter value 61 | } 62 | } 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/Filter/FilterMultiSelect.php: -------------------------------------------------------------------------------- 1 | ['form-select', 'form-select-sm', 'selectpicker'], 18 | 'data-selected-text-format' => ['count'], 19 | ]; 20 | 21 | public function __construct( 22 | Datagrid $grid, 23 | string $key, 24 | string $name, 25 | array $options, 26 | string $column 27 | ) 28 | { 29 | parent::__construct($grid, $key, $name, $options, $column); 30 | 31 | $this->addAttribute('data-selected-icon-check', Datagrid::$iconPrefix . 'check'); 32 | } 33 | 34 | /** 35 | * Get filter condition 36 | */ 37 | public function getCondition(): array 38 | { 39 | $return = [$this->column => []]; 40 | 41 | foreach ($this->getValue() as $value) { 42 | $return[$this->column][] = $value; 43 | } 44 | 45 | return $return; 46 | } 47 | 48 | protected function addControl( 49 | Container $container, 50 | string $key, 51 | string $name, 52 | array $options 53 | ): BaseControl 54 | { 55 | /** 56 | * Set some translated texts 57 | */ 58 | $form = $container->lookup(Form::class); 59 | 60 | if (!$form instanceof Form) { 61 | throw new UnexpectedValueException(); 62 | } 63 | 64 | $translator = $form->getTranslator(); 65 | 66 | if ($translator === null) { 67 | throw new UnexpectedValueException(); 68 | } 69 | 70 | $this->addAttribute( 71 | 'title', 72 | $translator->translate('contributte_datagrid.multiselect_choose') 73 | ); 74 | $this->addAttribute( 75 | 'data-i18n-selected', 76 | $translator->translate('contributte_datagrid.multiselect_selected') 77 | ); 78 | 79 | /** 80 | * Add input to container 81 | */ 82 | $input = $container->addMultiSelect($key, $name, $options); 83 | 84 | $this->addAttributes($input); 85 | 86 | return $input; 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /src/Localization/SimpleTranslator.php: -------------------------------------------------------------------------------- 1 | 'No items found. You can reset the filter', 12 | 'contributte_datagrid.no_item_found' => 'No items found.', 13 | 'contributte_datagrid.here' => 'here', 14 | 'contributte_datagrid.items' => 'Items', 15 | 'contributte_datagrid.all' => 'all', 16 | 'contributte_datagrid.from' => 'from', 17 | 'contributte_datagrid.reset_filter' => 'Reset filter', 18 | 'contributte_datagrid.group_actions' => 'Group actions', 19 | 'contributte_datagrid.show' => 'Show', 20 | 'contributte_datagrid.add' => 'Add', 21 | 'contributte_datagrid.edit' => 'Edit', 22 | 'contributte_datagrid.show_all_columns' => 'Show all columns', 23 | 'contributte_datagrid.show_default_columns' => 'Show default columns', 24 | 'contributte_datagrid.hide_column' => 'Hide column', 25 | 'contributte_datagrid.action' => 'Action', 26 | 'contributte_datagrid.previous' => 'Previous', 27 | 'contributte_datagrid.next' => 'Next', 28 | 'contributte_datagrid.choose' => 'Choose', 29 | 'contributte_datagrid.choose_input_required' => 'Group action text not allow empty value', 30 | 'contributte_datagrid.execute' => 'Execute', 31 | 'contributte_datagrid.save' => 'Save', 32 | 'contributte_datagrid.cancel' => 'Cancel', 33 | 'contributte_datagrid.multiselect_choose' => 'Choose', 34 | 'contributte_datagrid.multiselect_selected' => '{0} selected', 35 | 'contributte_datagrid.filter_submit_button' => 'Filter', 36 | 'contributte_datagrid.show_filter' => 'Show filter', 37 | 'contributte_datagrid.per_page_submit' => 'Change', 38 | ]; 39 | 40 | public function __construct(array $dictionary = []) 41 | { 42 | $this->dictionary = array_merge($this->dictionary, $dictionary); 43 | } 44 | 45 | public function translate(mixed $message, mixed ...$parameters): string 46 | { 47 | return $this->dictionary[$message] ?? $message; 48 | } 49 | 50 | public function setDictionary(array $dictionary): void 51 | { 52 | $this->dictionary = $dictionary; 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ublaboo/datagrid", 3 | "type": "library", 4 | "description": "DataGrid for Nette Framework: filtering, sorting, pagination, tree view, table view, translator, etc", 5 | "keywords": [ 6 | "datagrid", 7 | "grid", 8 | "nette", 9 | "contributte", 10 | "table", 11 | "data" 12 | ], 13 | "license": [ 14 | "MIT" 15 | ], 16 | "authors": [ 17 | { 18 | "name": "Pavel Janda", 19 | "homepage": "https://paveljanda.com" 20 | }, 21 | { 22 | "name": "Milan Felix Šulc", 23 | "homepage": "https://f3l1x.io" 24 | } 25 | ], 26 | "require": { 27 | "php": ">=8.2", 28 | "nette/application": "^3.2.0", 29 | "nette/di": "^3.0.0", 30 | "nette/forms": "^3.2.0", 31 | "nette/utils": "^4.0.0", 32 | "symfony/property-access": "^6.4.0 || ^7.2.0" 33 | }, 34 | "require-dev": { 35 | "contributte/qa": "^0.3.0", 36 | "dibi/dibi": "^5.0.2", 37 | "doctrine/annotations": "^1.14.4", 38 | "doctrine/cache": "^1.13.0", 39 | "doctrine/orm": "^2.20.2", 40 | "elasticsearch/elasticsearch": "^8.6", 41 | "mockery/mockery": "^1.6.12", 42 | "nette/database": "^3.0.2", 43 | "nette/tester": "^2.3.4", 44 | "nextras/dbal": "^4.0 || ^5.0", 45 | "nextras/orm": "^4.0 || ^5.0", 46 | "phpstan/phpstan-deprecation-rules": "^1.1", 47 | "phpstan/phpstan-mockery": "^1.1", 48 | "phpstan/phpstan-nette": "^1.0.0", 49 | "phpstan/phpstan-strict-rules": "^1.4", 50 | "tharos/leanmapper": "^3.4.2 || ^4.0.0", 51 | "tracy/tracy": "^2.6.3" 52 | }, 53 | "conflict": { 54 | "doctrine/collections": "<2.2.0" 55 | }, 56 | "autoload": { 57 | "psr-4": { 58 | "Contributte\\Datagrid\\": "src/" 59 | } 60 | }, 61 | "autoload-dev": { 62 | "psr-4": { 63 | "Contributte\\Datagrid\\Tests\\": "tests" 64 | } 65 | }, 66 | "prefer-stable": true, 67 | "minimum-stability": "dev", 68 | "config": { 69 | "sort-packages": true, 70 | "allow-plugins": { 71 | "composer/package-versions-deprecated": true, 72 | "dealerdirect/phpcodesniffer-composer-installer": true, 73 | "php-http/discovery": true 74 | } 75 | }, 76 | "extra": { 77 | "branch-alias": { 78 | "dev-master": "7.2.x-dev" 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Utils/ItemDetailForm.php: -------------------------------------------------------------------------------- 1 | */ 22 | private array $containerSetByName = []; 23 | 24 | public function __construct(callable $callableSetContainer) 25 | { 26 | $this->monitor( 27 | Presenter::class, 28 | function (Presenter $presenter): void { 29 | $this->loadHttpData(); 30 | } 31 | ); 32 | 33 | $this->callableSetContainer = $callableSetContainer; 34 | } 35 | 36 | public function offsetGet(mixed $name): IComponent 37 | { 38 | return $this->getComponentAndSetContainer($name); 39 | } 40 | 41 | public function getComponentAndSetContainer(mixed $name): IComponent 42 | { 43 | $container = $this->addContainer($name); 44 | 45 | if (!isset($this->containerSetByName[$name])) { 46 | call_user_func($this->callableSetContainer, $container); 47 | 48 | $this->containerSetByName[$name] = true; 49 | } 50 | 51 | return $container; 52 | } 53 | 54 | /** 55 | * @throws UnexpectedValueException 56 | */ 57 | private function getHttpData(): mixed 58 | { 59 | if ($this->httpPost === null) { 60 | $lookupPath = $this->lookupPath(Form::class); 61 | $form = $this->getForm(); 62 | 63 | $path = explode(self::NameSeparator, $lookupPath); 64 | 65 | /** @var array $httpData */ 66 | $httpData = $form->getHttpData(); 67 | $this->httpPost = Arrays::get($httpData, $path, null); 68 | } 69 | 70 | return $this->httpPost; 71 | } 72 | 73 | /** 74 | * @throws UnexpectedValueException 75 | */ 76 | private function loadHttpData(): void 77 | { 78 | $form = $this->getForm(); 79 | 80 | if ($form->isSubmitted() === false) { 81 | return; 82 | } 83 | 84 | foreach ((array) $this->getHttpData() as $name => $value) { 85 | if ((is_iterable($value))) { 86 | $this->getComponentAndSetContainer($name); 87 | } 88 | } 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/Utils/DateTimeHelper.php: -------------------------------------------------------------------------------- 1 | getTimezone(); 60 | $date = new DateTime('now', $tz !== false ? $tz : null); 61 | $date->setTimestamp($value->getTimestamp()); 62 | 63 | return $date; 64 | } 65 | 66 | foreach ($formats as $format) { 67 | $date = DateTime::createFromFormat($format, (string) $value); 68 | 69 | if ($date === false) { 70 | continue; 71 | } 72 | 73 | return $date; 74 | } 75 | 76 | $timestamp = strtotime((string) $value); 77 | 78 | if ($timestamp !== false) { 79 | $date = new DateTime(); 80 | $date->setTimestamp($timestamp); 81 | 82 | return $date; 83 | } 84 | 85 | throw new DatagridDateTimeHelperException(); 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /src/Column/FilterableColumn.php: -------------------------------------------------------------------------------- 1 | column]; 24 | } else { 25 | $columns = is_string($columns) 26 | ? [$columns] 27 | : $columns; 28 | } 29 | 30 | return $this->grid->addFilterText($this->key, $this->name, $columns); 31 | } 32 | 33 | public function setFilterSelect( 34 | array $options, 35 | ?string $column = null 36 | ): FilterSelect 37 | { 38 | $column ??= $this->column; 39 | 40 | return $this->grid->addFilterSelect($this->key, $this->name, $options, $column); 41 | } 42 | 43 | public function setFilterMultiSelect( 44 | array $options, 45 | ?string $column = null 46 | ): FilterMultiSelect 47 | { 48 | $column ??= $this->column; 49 | 50 | return $this->grid->addFilterMultiSelect($this->key, $this->name, $options, $column); 51 | } 52 | 53 | public function setFilterDate(?string $column = null): FilterDate 54 | { 55 | $column ??= $this->column; 56 | 57 | return $this->grid->addFilterDate($this->key, $this->name, $column); 58 | } 59 | 60 | public function setFilterRange( 61 | ?string $column = null, 62 | string $nameSecond = '-' 63 | ): FilterRange 64 | { 65 | $column ??= $this->column; 66 | 67 | return $this->grid->addFilterRange($this->key, $this->name, $column, $nameSecond); 68 | } 69 | 70 | public function setFilterDateRange( 71 | ?string $column = null, 72 | string $nameSecond = '-' 73 | ): FilterDateRange 74 | { 75 | $column ??= $this->column; 76 | 77 | return $this->grid->addFilterDateRange($this->key, $this->name, $column, $nameSecond); 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /src/DataSource/FilterableDataSource.php: -------------------------------------------------------------------------------- 1 | $filters 23 | */ 24 | public function filter(array $filters): void 25 | { 26 | foreach ($filters as $filter) { 27 | if ($filter->isValueSet()) { 28 | if ($filter->getConditionCallback() !== null) { 29 | $value = $filter->getValue(); 30 | 31 | if (is_array($value)) { 32 | $value = ArrayHash::from($filter->getValue()); 33 | } 34 | 35 | ($filter->getConditionCallback())($this->getDataSource(), $value); 36 | } else { 37 | if ($filter instanceof FilterText) { 38 | $this->applyFilterText($filter); 39 | } elseif ($filter instanceof FilterMultiSelect) { 40 | $this->applyFilterMultiSelect($filter); 41 | } elseif ($filter instanceof FilterSelect) { 42 | $this->applyFilterSelect($filter); 43 | } elseif ($filter instanceof FilterDate) { 44 | $this->applyFilterDate($filter); 45 | } elseif ($filter instanceof FilterDateRange) { 46 | $this->applyFilterDateRange($filter); 47 | } elseif ($filter instanceof FilterRange) { 48 | $this->applyFilterRange($filter); 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | abstract protected function applyFilterDate(FilterDate $filter): void; 56 | 57 | abstract protected function applyFilterDateRange(FilterDateRange $filter): void; 58 | 59 | abstract protected function applyFilterRange(FilterRange $filter): void; 60 | 61 | abstract protected function applyFilterText(FilterText $filter): void; 62 | 63 | abstract protected function applyFilterMultiSelect(FilterMultiSelect $filter): void; 64 | 65 | abstract protected function applyFilterSelect(FilterSelect $filter): void; 66 | 67 | } 68 | -------------------------------------------------------------------------------- /src/Column/ColumnNumber.php: -------------------------------------------------------------------------------- 1 | self::PHP_FLOAT_SAFE_MIN && $value < self::PHP_FLOAT_SAFE_MAX) { 37 | return number_format( 38 | (float) $value, 39 | (int) $this->numberFormat[0], 40 | (string) $this->numberFormat[1], 41 | (string) $this->numberFormat[2] 42 | ); 43 | } 44 | 45 | return $this->parseBigIntNumber((string) $value); 46 | } 47 | 48 | /** 49 | * @return static 50 | */ 51 | public function setFormat( 52 | int $decimals = 0, 53 | string $decPoint = '.', 54 | string $thousandsSep = ' ' 55 | ): self 56 | { 57 | $this->numberFormat = [$decimals, $decPoint, $thousandsSep]; 58 | 59 | return $this; 60 | } 61 | 62 | public function getFormat(): array 63 | { 64 | return [ 65 | $this->numberFormat[0], 66 | $this->numberFormat[1], 67 | $this->numberFormat[2], 68 | ]; 69 | } 70 | 71 | protected function parseBigIntNumber(string $number): string 72 | { 73 | $decimal = null; 74 | 75 | if (str_contains($number, '.')) { 76 | [$integer, $decimal] = explode('.', $number, 2); 77 | } else { 78 | $integer = $number; 79 | } 80 | 81 | if ($this->numberFormat[0] > 0) { 82 | $decimal = substr( 83 | $decimal . str_repeat('0', $this->numberFormat[0]), 84 | 0, 85 | $this->numberFormat[0] 86 | ); 87 | } 88 | 89 | $integer = preg_replace('/\B(?=(\d{3})+(?!\d))/', $this->numberFormat[2], $integer); 90 | 91 | if (strlen((string) $decimal) > 0) { 92 | return $integer . $this->numberFormat[1] . $decimal; 93 | } 94 | 95 | return (string) $integer; 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/AggregationFunction/FunctionSum.php: -------------------------------------------------------------------------------- 1 | dataType; 28 | } 29 | 30 | public function processDataSource(Fluent|QueryBuilder|Collection|Selection|ICollection $dataSource): void 31 | { 32 | if ($dataSource instanceof Fluent) { 33 | $connection = $dataSource->getConnection(); 34 | $this->result = (int) $connection->select('SUM(%n)', $this->column) 35 | ->from($dataSource, 's') 36 | ->fetchSingle(); 37 | } 38 | 39 | if ($dataSource instanceof QueryBuilder) { 40 | $column = str_contains($this->column, '.') 41 | ? $this->column 42 | : current($dataSource->getRootAliases()) . '.' . $this->column; 43 | 44 | $this->result = (int) $dataSource 45 | ->select(sprintf('SUM(%s)', $column)) 46 | ->setMaxResults(1) 47 | ->setFirstResult(0) 48 | ->getQuery() 49 | ->getSingleScalarResult(); 50 | } 51 | 52 | if ($dataSource instanceof Collection) { 53 | $dataSource->forAll(function ($key, $value): bool { 54 | $this->result += PropertyAccessHelper::getValue($value, $this->column); 55 | 56 | return true; 57 | }); 58 | } 59 | 60 | if ($dataSource instanceof DbalCollection) { 61 | foreach ($dataSource->fetchAll() as $item) 62 | $this->result += $item->getValue($this->column); 63 | } 64 | } 65 | 66 | public function renderResult(): mixed 67 | { 68 | $result = $this->result; 69 | 70 | if (isset($this->renderer)) { 71 | $result = call_user_func($this->renderer, $result); 72 | } 73 | 74 | return $result; 75 | } 76 | 77 | /** 78 | * @return static 79 | */ 80 | public function setRenderer(?callable $callback = null): self 81 | { 82 | $this->renderer = $callback; 83 | 84 | return $this; 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /src/Filter/FilterText.php: -------------------------------------------------------------------------------- 1 | addText($this->key, $this->name); 40 | 41 | $this->addAttributes($control); 42 | 43 | if ($this->getPlaceholder() !== null) { 44 | $control->setHtmlAttribute('placeholder', $this->getPlaceholder()); 45 | } 46 | } 47 | 48 | /** 49 | * Return array of conditions to put in result [column1 => value, column2 => value] 50 | * If more than one column exists in fitler text, 51 | * than there is OR clause put betweeen their conditions 52 | * Or callback in case of custom condition callback 53 | */ 54 | public function getCondition(): array 55 | { 56 | return array_fill_keys($this->columns, $this->getValue()); 57 | } 58 | 59 | public function isExactSearch(): bool 60 | { 61 | return $this->exact; 62 | } 63 | 64 | /** 65 | * @return static 66 | */ 67 | public function setExactSearch(bool $exact = true): self 68 | { 69 | $this->exact = $exact; 70 | 71 | return $this; 72 | } 73 | 74 | /** 75 | * @return static 76 | */ 77 | public function setSplitWordsSearch(bool $splitWordsSearch): self 78 | { 79 | $this->splitWordsSearch = $splitWordsSearch; 80 | 81 | return $this; 82 | } 83 | 84 | public function hasSplitWordsSearch(): bool 85 | { 86 | return $this->splitWordsSearch; 87 | } 88 | 89 | public function setConjunctionSearch(bool $conjunctionSearch = true): void 90 | { 91 | $this->conjunctionSearch = $conjunctionSearch; 92 | } 93 | 94 | public function hasConjunctionSearch(): bool 95 | { 96 | return $this->conjunctionSearch; 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /src/Filter/FilterDateRange.php: -------------------------------------------------------------------------------- 1 | addContainer($this->key); 22 | 23 | $from = $container->addText('from', $this->name); 24 | 25 | $from->setHtmlAttribute('data-provide', 'datepicker') 26 | ->setHtmlAttribute('data-date-orientation', 'bottom') 27 | ->setHtmlAttribute('data-date-format', $this->getJsFormat()) 28 | ->setHtmlAttribute('data-date-today-highlight', 'true') 29 | ->setHtmlAttribute('data-date-autoclose', 'true'); 30 | 31 | $to = $container->addText('to', $this->nameSecond); 32 | 33 | $to->setHtmlAttribute('data-provide', 'datepicker') 34 | ->setHtmlAttribute('data-date-orientation', 'bottom') 35 | ->setHtmlAttribute('data-date-format', $this->getJsFormat()) 36 | ->setHtmlAttribute('data-date-today-highlight', 'true') 37 | ->setHtmlAttribute('data-date-autoclose', 'true'); 38 | 39 | $this->addAttributes($from); 40 | $this->addAttributes($to); 41 | 42 | if ($this->grid->hasAutoSubmit()) { 43 | $from->setHtmlAttribute('data-autosubmit-change', true); 44 | $to->setHtmlAttribute('data-autosubmit-change', true); 45 | } 46 | 47 | $placeholders = $this->getPlaceholders(); 48 | 49 | if ($placeholders !== []) { 50 | $textFrom = reset($placeholders); 51 | 52 | if ($textFrom) { 53 | $from->setHtmlAttribute('placeholder', $textFrom); 54 | } 55 | 56 | $textTo = end($placeholders); 57 | 58 | if ($textTo && ($textTo !== $textFrom)) { 59 | $to->setHtmlAttribute('placeholder', $textTo); 60 | } 61 | } 62 | } 63 | 64 | /** 65 | * Set format for datepicker etc 66 | */ 67 | public function setFormat(string $phpFormat, string $jsFormat): IFilterDate 68 | { 69 | $this->format = [$phpFormat, $jsFormat]; 70 | 71 | return $this; 72 | } 73 | 74 | /** 75 | * Get php format for datapicker 76 | */ 77 | public function getPhpFormat(): string 78 | { 79 | return $this->format[0]; 80 | } 81 | 82 | /** 83 | * Get js format for datepicker 84 | */ 85 | public function getJsFormat(): string 86 | { 87 | return $this->format[1]; 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/Traits/TButtonRenderer.php: -------------------------------------------------------------------------------- 1 | getRenderer(); 23 | 24 | $args = $row instanceof Row ? [$row->getItem()] : []; 25 | 26 | if ($renderer === null) { 27 | throw new DatagridColumnRendererException(); 28 | } 29 | 30 | if ($renderer->getConditionCallback() !== null) { 31 | if (call_user_func_array($renderer->getConditionCallback(), $args) === false) { 32 | throw new DatagridColumnRendererException(); 33 | } 34 | 35 | return call_user_func_array($renderer->getCallback(), $args); 36 | } 37 | 38 | return call_user_func_array($renderer->getCallback(), $args); 39 | } 40 | 41 | /** 42 | * Set renderer callback and (it may be optional - the condition callback will decide) 43 | * 44 | * @return static 45 | * @throws DatagridException 46 | */ 47 | public function setRenderer( 48 | callable $renderer, 49 | ?callable $conditionCallback = null 50 | ): self 51 | { 52 | if ($this->hasReplacements()) { 53 | throw new DatagridException('Use either Column::setReplacement() or Column::setRenderer, not both.'); 54 | } 55 | 56 | $this->renderer = new Renderer($renderer, $conditionCallback); 57 | 58 | return $this; 59 | } 60 | 61 | /** 62 | * @return static 63 | */ 64 | public function setRendererOnCondition( 65 | callable $renderer, 66 | callable $conditionCallback 67 | ): self 68 | { 69 | return $this->setRenderer($renderer, $conditionCallback); 70 | } 71 | 72 | public function getRenderer(): ?Renderer 73 | { 74 | return $this->renderer; 75 | } 76 | 77 | public function hasReplacements(): bool 78 | { 79 | return $this->replacements !== []; 80 | } 81 | 82 | public function applyReplacements(Row $row, string $column): array 83 | { 84 | $value = $row->getValue($column); 85 | 86 | if ((is_scalar($value) || $value === null) && 87 | isset($this->replacements[gettype($value) === 'double' ? (int) $value : $value])) { 88 | return [true, $this->replacements[gettype($value) === 'double' ? (int) $value : $value]]; 89 | } 90 | 91 | return [false, null]; 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /src/Toolbar/ToolbarButton.php: -------------------------------------------------------------------------------- 1 | text = $text; 37 | } 38 | 39 | /** 40 | * Render toolbar button 41 | */ 42 | public function renderButton(): Html 43 | { 44 | try { 45 | // Renderer function may be used 46 | return $this->useRenderer(); 47 | } catch (DatagridColumnRendererException) { 48 | // Do not use renderer 49 | } 50 | 51 | $link = $this->createLink($this->grid, $this->href, $this->params); 52 | 53 | $a = Html::el('a')->href($link); 54 | 55 | $this->tryAddIcon($a, $this->getIcon(), $this->getText()); 56 | 57 | if ($this->attributes !== []) { 58 | $a->addAttributes($this->attributes); 59 | } 60 | 61 | $a->addText($this->grid->getTranslator()->translate($this->text)); 62 | 63 | if ($this->getTitle() !== null) { 64 | $a->setAttribute( 65 | 'title', 66 | $this->grid->getTranslator()->translate($this->getTitle()) 67 | ); 68 | } 69 | 70 | $a->setAttribute('class', $this->getClass()); 71 | 72 | if ($this->confirmDialog !== null) { 73 | $a->setAttribute('data-datagrid-confirm', $this->confirmDialog); 74 | } 75 | 76 | return $a; 77 | } 78 | 79 | /** 80 | * @return static 81 | */ 82 | public function addAttributes(array $attrs): static 83 | { 84 | $this->attributes += $attrs; 85 | 86 | return $this; 87 | } 88 | 89 | /** 90 | * Add Confirm dialog 91 | */ 92 | public function setConfirmDialog(string $confirmDialog): self 93 | { 94 | $this->confirmDialog = $confirmDialog; 95 | 96 | return $this; 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /assets/plugins/features/inline.ts: -------------------------------------------------------------------------------- 1 | import { DatagridPlugin } from "../../types"; 2 | import { isEnter } from "../../utils"; 3 | import { Datagrid, Datagrids } from "../.."; 4 | 5 | export class InlinePlugin implements DatagridPlugin { 6 | onInit(datagrids: Datagrids) { 7 | } 8 | 9 | onDatagridInit(datagrid: Datagrid): boolean { 10 | datagrid.ajax.addEventListener('success', ({ detail: { payload } }) => { 11 | if (!payload._datagrid_name || payload._datagrid_name !== datagrid.name) return; 12 | 13 | if (payload._datagrid_inline_edited || payload._datagrid_inline_edit_cancel) { 14 | if (payload._datagrid_inline_edited) { 15 | let rows = datagrid.el.querySelectorAll( 16 | `tr[data-id='${payload._datagrid_inline_edited}'] > td` 17 | ); 18 | 19 | rows.forEach(row => { 20 | row.classList.add("edited"); 21 | }); 22 | } 23 | 24 | return; 25 | } 26 | 27 | if (payload._datagrid_inline_adding) { 28 | const row = datagrid.el.querySelector(".datagrid-row-inline-add"); 29 | if (row) { 30 | row.classList.remove("datagrid-row-inline-add-hidden"); 31 | row.querySelector( 32 | "input:not([readonly]), textarea:not([readonly])" 33 | )?.focus(); 34 | } 35 | } 36 | 37 | datagrid.el.querySelectorAll(".datagrid-inline-edit input").forEach(inputEl => { 38 | inputEl.addEventListener("keydown", e => { 39 | if (!isEnter(e)) return; 40 | 41 | e.stopPropagation(); 42 | e.preventDefault(); 43 | 44 | return inputEl 45 | .closest("tr") 46 | ?.querySelector(".col-action-inline-edit [name='inline_edit[submit]']") 47 | ?.click(); 48 | }); 49 | }); 50 | 51 | datagrid.el.querySelectorAll(".datagrid-inline-add input").forEach(inputEl => { 52 | inputEl.addEventListener("keydown", e => { 53 | if (!isEnter(e)) return; 54 | 55 | e.stopPropagation(); 56 | e.preventDefault(); 57 | 58 | return inputEl 59 | .closest("tr") 60 | ?.querySelector(".col-action-inline-edit [name='inline_add[submit]']") 61 | ?.click(); 62 | }); 63 | }); 64 | 65 | datagrid.el.querySelectorAll("[data-datagrid-cancel-inline-add]").forEach(cancel => { 66 | cancel.addEventListener("mouseup", e => { 67 | if (e.button === 0) { 68 | e.stopPropagation(); 69 | e.preventDefault(); 70 | const inlineAdd = cancel.closest(".datagrid-row-inline-add"); 71 | if (inlineAdd) { 72 | inlineAdd.classList.add("datagrid-row-inline-add-hidden"); 73 | } 74 | } 75 | }); 76 | }); 77 | }) 78 | 79 | return true; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Filter/FilterSelect.php: -------------------------------------------------------------------------------- 1 | ['form-select', 'form-select-sm', 'selectpicker'], 18 | ]; 19 | 20 | protected ?string $template = 'datagrid_filter_select.latte'; 21 | 22 | protected ?string $type = 'select'; 23 | 24 | protected ?string $prompt = null; 25 | 26 | public function __construct( 27 | Datagrid $grid, 28 | string $key, 29 | string $name, 30 | protected array $options, 31 | string $column 32 | ) 33 | { 34 | parent::__construct($grid, $key, $name, $column); 35 | } 36 | 37 | public function addToFormContainer(Container $container): void 38 | { 39 | $form = $container->lookup(Form::class); 40 | 41 | if (!$form instanceof Form) { 42 | throw new UnexpectedValueException(); 43 | } 44 | 45 | $translator = $form->getTranslator(); 46 | 47 | if ($translator === null) { 48 | throw new UnexpectedValueException(); 49 | } 50 | 51 | $select = $this->addControl($container, $this->key, $this->name, $this->options); 52 | 53 | if (!$this->translateOptions) { 54 | $select->setTranslator(null); 55 | } 56 | } 57 | 58 | /** 59 | * @return static 60 | */ 61 | public function setTranslateOptions(bool $translateOptions = true): self 62 | { 63 | $this->translateOptions = $translateOptions; 64 | 65 | return $this; 66 | } 67 | 68 | public function getOptions(): array 69 | { 70 | return $this->options; 71 | } 72 | 73 | public function getTranslateOptions(): bool 74 | { 75 | return $this->translateOptions; 76 | } 77 | 78 | public function getCondition(): array 79 | { 80 | return [$this->column => $this->getValue()]; 81 | } 82 | 83 | public function getPrompt(): ?string 84 | { 85 | return $this->prompt; 86 | } 87 | 88 | /** 89 | * @return static 90 | */ 91 | public function setPrompt(?string $prompt): self 92 | { 93 | $this->prompt = $prompt; 94 | 95 | return $this; 96 | } 97 | 98 | protected function addControl( 99 | Container $container, 100 | string $key, 101 | string $name, 102 | array $options 103 | ): BaseControl 104 | { 105 | $input = $container->addSelect($key, $name, $options); 106 | 107 | if ($this->getPrompt() !== null) { 108 | $input->setPrompt($this->getPrompt()); 109 | } 110 | 111 | $this->addAttributes($input); 112 | 113 | return $input; 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /assets/plugins/features/autosubmit.ts: -------------------------------------------------------------------------------- 1 | import { DatagridPlugin } from "../../types"; 2 | import { debounce, isEnter, isFunctionKey, isInKeyRange } from "../../utils"; 3 | import { Datagrid } from "../.."; 4 | 5 | export const AutosubmitAttribute = "data-autosubmit"; 6 | 7 | export const AutosubmitPerPageAttribute = "data-autosubmit-per-page"; 8 | 9 | export const AutosubmitChangeAttribute = "data-autosubmit-change"; 10 | 11 | export class AutosubmitPlugin implements DatagridPlugin { 12 | onDatagridInit(datagrid: Datagrid): boolean { 13 | datagrid.ajax.addEventListener('complete', (event) => { 14 | this.initPerPage(datagrid); 15 | this.initChange(datagrid); 16 | }); 17 | 18 | this.initPerPage(datagrid); 19 | this.initChange(datagrid); 20 | 21 | return true; 22 | }; 23 | 24 | // Auto-submit perPage 25 | initPerPage(datagrid: Datagrid) { 26 | datagrid.el.querySelectorAll(`select[${AutosubmitPerPageAttribute}]`) 27 | .forEach(pageSelectEl => { 28 | pageSelectEl.addEventListener("change", () => { 29 | let inputEl = pageSelectEl.parentElement?.querySelector("input[type=submit]"); 30 | if (!inputEl) { 31 | inputEl = pageSelectEl.parentElement?.querySelector("button[type=submit]"); 32 | } 33 | if (!(inputEl instanceof HTMLElement)) return; 34 | const form = inputEl.closest('form'); 35 | form && datagrid.ajax.submitForm(form); 36 | }); 37 | }); 38 | } 39 | 40 | // Auto-submit change 41 | initChange(datagrid: Datagrid) { 42 | datagrid.el.querySelectorAll(`[${AutosubmitAttribute}]`) 43 | .forEach(submitEl => { 44 | const form = submitEl.closest("form"); 45 | if (!form) return; 46 | 47 | if (submitEl.dataset.listenersAttached === "true") { 48 | return; // Skip if listeners are already attached 49 | } 50 | submitEl.dataset.listenersAttached = "true"; 51 | 52 | // Select auto-submit 53 | if (submitEl instanceof HTMLSelectElement) { 54 | submitEl.addEventListener("change", () => datagrid.ajax.submitForm(form)); 55 | 56 | return; 57 | } 58 | 59 | // Input auto-submit 60 | if (submitEl instanceof HTMLInputElement) { 61 | // Handle change events 62 | if (submitEl.hasAttribute(AutosubmitChangeAttribute)) { 63 | submitEl.addEventListener( 64 | "change", 65 | debounce(() => datagrid.ajax.submitForm(form)) 66 | ); 67 | } 68 | 69 | submitEl.addEventListener( 70 | "keyup", 71 | debounce(e => { 72 | // Ignore keys such as alt, ctrl, etc, F-keys... (when enter is not pressed) 73 | if (!isEnter(e) && (isInKeyRange(e, 9, 40) || isFunctionKey(e))) { 74 | return; 75 | } 76 | 77 | return datagrid.ajax.submitForm(form); 78 | }) 79 | ); 80 | } 81 | }); 82 | } 83 | } 84 | 85 | 86 | -------------------------------------------------------------------------------- /src/Traits/TLink.php: -------------------------------------------------------------------------------- 1 | getPresenter(); 32 | 33 | if (str_contains($href, ':')) { 34 | return $presenter->link($href, $params); 35 | } 36 | 37 | for ($iteration = 0; $iteration < 10; $iteration++) { 38 | $targetComponent = $targetComponent->getParent(); 39 | 40 | if (!$targetComponent instanceof Component) { 41 | throw $this->createHierarchyLookupException($grid, $href, $params); 42 | } 43 | 44 | try { 45 | $link = $targetComponent->link($href, $params); 46 | } catch (InvalidLinkException) { 47 | $link = false; 48 | } 49 | 50 | if (is_string($link)) { 51 | if ( 52 | str_starts_with($link, '#error') || 53 | (strrpos($href, '!') !== false && str_starts_with($link, '#')) || 54 | (in_array($presenter->invalidLinkMode, [Presenter::InvalidLinkWarning, Presenter::InvalidLinkSilent], true) && str_starts_with($link, '#')) 55 | ) { 56 | continue; // Did not find signal handler 57 | } 58 | 59 | return $link; // Found signal handler! 60 | } else { 61 | continue; // Did not find signal handler 62 | } 63 | } 64 | 65 | // Went 10 steps up to the Presenter and did not find any signal handler 66 | throw $this->createHierarchyLookupException($grid, $href, $params); 67 | } 68 | 69 | private function createHierarchyLookupException( 70 | Datagrid $grid, 71 | string $href, 72 | array $params 73 | ): DatagridLinkCreationException 74 | { 75 | $parent = $grid->getParent(); 76 | $presenter = $grid->getPresenter(); 77 | 78 | if ($parent === null) { 79 | throw new UnexpectedValueException( 80 | sprintf('%s can not live withnout a parent component', self::class) 81 | ); 82 | } 83 | 84 | $desiredHandler = $parent::class . '::handle' . ucfirst($href) . '()'; 85 | 86 | return new DatagridLinkCreationException( 87 | 'Datagrid could not create link "' 88 | . $href . '" - did not find any signal handler in componenet hierarchy from ' 89 | . $parent::class . ' up to the ' 90 | . $presenter::class . '. ' 91 | . 'Try adding handler ' . $desiredHandler 92 | ); 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /src/DataSource/ApiDataSource.php: -------------------------------------------------------------------------------- 1 | getResponse(['count' => '']); 32 | } 33 | 34 | /** 35 | * {@inheritDoc} 36 | */ 37 | public function getData(): array 38 | { 39 | return $this->data !== [] ? $this->data : $this->getResponse([ 40 | 'sort' => $this->sortColumn, 41 | 'order' => $this->orderColumn, 42 | 'limit' => $this->limit, 43 | 'offset' => $this->offset, 44 | 'filter' => $this->filter, 45 | 'one' => $this->filterOne, 46 | ]); 47 | } 48 | 49 | /** 50 | * {@inheritDoc} 51 | */ 52 | public function filter(array $filters): void 53 | { 54 | /** 55 | * First, save all filter values to array 56 | */ 57 | foreach ($filters as $filter) { 58 | if ($filter->isValueSet() && $filter->getConditionCallback() === null) { 59 | $this->filter[$filter->getKey()] = $filter->getCondition(); 60 | } 61 | } 62 | 63 | /** 64 | * Download filtered data 65 | */ 66 | $this->data = $this->getData(); 67 | 68 | /** 69 | * Apply possible user filter callbacks 70 | */ 71 | foreach ($filters as $filter) { 72 | if ($filter->isValueSet() && $filter->getConditionCallback() !== null) { 73 | $this->data = (array) call_user_func_array( 74 | $filter->getConditionCallback(), 75 | [$this->data, $filter->getValue()] 76 | ); 77 | } 78 | } 79 | } 80 | 81 | /** 82 | * {@inheritDoc} 83 | */ 84 | public function filterOne(array $condition): IDataSource 85 | { 86 | $this->filter = $condition; 87 | $this->filterOne = 1; 88 | 89 | return $this; 90 | } 91 | 92 | public function limit(int $offset, int $limit): IDataSource 93 | { 94 | $this->offset = $offset; 95 | $this->limit = $limit; 96 | 97 | return $this; 98 | } 99 | 100 | public function sort(Sorting $sorting): IDataSource 101 | { 102 | /** 103 | * there is only one iteration 104 | */ 105 | foreach ($sorting->getSort() as $column => $order) { 106 | $this->sortColumn = $column; 107 | $this->orderColumn = $order; 108 | } 109 | 110 | return $this; 111 | } 112 | 113 | /** 114 | * Get data of remote source 115 | */ 116 | protected function getResponse(array $params = []): mixed 117 | { 118 | $queryString = http_build_query($params + $this->queryParams); 119 | $url = sprintf('%s?%s', $this->url, $queryString); 120 | 121 | $content = file_get_contents($url); 122 | 123 | if ($content === false) { 124 | throw new UnexpectedValueException(sprintf('Could not open URL %s', $url)); 125 | } 126 | 127 | return json_decode($content, null, 512, JSON_THROW_ON_ERROR); 128 | } 129 | 130 | } 131 | -------------------------------------------------------------------------------- /src/Components/DatagridPaginator/DatagridPaginator.php: -------------------------------------------------------------------------------- 1 | templateFile = $templateFile; 36 | } 37 | 38 | public function getTemplateFile(): string 39 | { 40 | return $this->templateFile ?? __DIR__ . '/templates/data_grid_paginator.latte'; 41 | } 42 | 43 | public function getOriginalTemplateFile(): string 44 | { 45 | return __DIR__ . '/templates/data_grid_paginator.latte'; 46 | } 47 | 48 | public function getPaginator(): Paginator 49 | { 50 | if ($this->paginator === null) { 51 | $this->paginator = new Paginator(); 52 | } 53 | 54 | return $this->paginator; 55 | } 56 | 57 | public function render(): void 58 | { 59 | $paginator = $this->getPaginator(); 60 | $page = $paginator->page; 61 | 62 | if ($paginator->pageCount < 2) { 63 | $steps = [$page]; 64 | 65 | } else { 66 | $arr = range(max($paginator->firstPage, $page - 2), (int) min($paginator->lastPage, $page + 2)); 67 | 68 | /** 69 | * Something to do with steps in template... 70 | * [Default $count = 3;] 71 | */ 72 | $count = 1; 73 | 74 | $perPage = $paginator->pageCount; 75 | 76 | $quotient = ($perPage - 1) / $count; 77 | 78 | for ($i = 0; $i <= $count; $i++) { 79 | $arr[] = round($quotient * $i) + $paginator->firstPage; 80 | } 81 | 82 | sort($arr); 83 | $steps = array_values(array_unique($arr)); 84 | } 85 | 86 | if (!$this->getTemplate() instanceof Template) { 87 | throw new UnexpectedValueException(); 88 | } 89 | 90 | $this->getTemplate()->setTranslator($this->translator); 91 | 92 | if (!isset($this->getTemplate()->steps)) { 93 | $this->getTemplate()->steps = $steps; 94 | } 95 | 96 | if (!isset($this->getTemplate()->paginator)) { 97 | $this->getTemplate()->paginator = $paginator; 98 | } 99 | 100 | $this->getTemplate()->iconPrefix = $this->iconPrefix; 101 | $this->getTemplate()->btnSecondaryClass = $this->btnSecondaryClass; 102 | $this->getTemplate()->originalTemplate = $this->getOriginalTemplateFile(); 103 | $this->getTemplate()->setFile($this->getTemplateFile()); 104 | $this->getTemplate()->render(); 105 | } 106 | 107 | /** 108 | * Loads state informations. 109 | */ 110 | public function loadState(array $params): void 111 | { 112 | parent::loadState($params); 113 | 114 | if ($this->getParent() instanceof Datagrid) { 115 | $this->getPaginator()->page = $this->getParent()->page; 116 | } 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /src/DataSource/DibiFluentMssqlDataSource.php: -------------------------------------------------------------------------------- 1 | dataSource; 28 | $clone->removeClause('ORDER BY'); 29 | 30 | return $clone->count(); 31 | } 32 | 33 | /** 34 | * {@inheritDoc} 35 | */ 36 | public function filterOne(array $condition): IDataSource 37 | { 38 | $this->dataSource->where($condition); 39 | 40 | return $this; 41 | } 42 | 43 | public function limit(int $offset, int $limit): IDataSource 44 | { 45 | $sql = (string) $this->dataSource; 46 | 47 | $result = $this->dataSource->getConnection() 48 | ->query('%sql OFFSET ? ROWS FETCH NEXT ? ROWS ONLY', $sql, $offset, $limit); 49 | 50 | if (!$result instanceof Result) { 51 | throw new UnexpectedValueException(); 52 | } 53 | 54 | $this->data = $result->fetchAll(); 55 | 56 | return $this; 57 | } 58 | 59 | protected function applyFilterDate(FilterDate $filter): void 60 | { 61 | $conditions = $filter->getCondition(); 62 | 63 | try { 64 | $date = DateTimeHelper::tryConvertToDateTime( 65 | $conditions[$filter->getColumn()], 66 | [$filter->getPhpFormat()] 67 | ); 68 | 69 | $this->dataSource->where( 70 | 'CONVERT(varchar(10), %n, 112) = ?', 71 | $filter->getColumn(), 72 | $date->format('Ymd') 73 | ); 74 | } catch (DatagridDateTimeHelperException) { 75 | // ignore the invalid filter value 76 | } 77 | } 78 | 79 | protected function applyFilterDateRange(FilterDateRange $filter): void 80 | { 81 | $conditions = $filter->getCondition(); 82 | 83 | $valueFrom = $conditions[$filter->getColumn()]['from']; 84 | $valueTo = $conditions[$filter->getColumn()]['to']; 85 | 86 | if ($valueFrom) { 87 | $this->dataSource->where( 88 | 'CONVERT(varchar(10), %n, 112) >= ?', 89 | $filter->getColumn(), 90 | $valueFrom 91 | ); 92 | } 93 | 94 | if ($valueTo) { 95 | $this->dataSource->where( 96 | 'CONVERT(varchar(10), %n, 112) <= ?', 97 | $filter->getColumn(), 98 | $valueTo 99 | ); 100 | } 101 | } 102 | 103 | protected function applyFilterText(FilterText $filter): void 104 | { 105 | $condition = $filter->getCondition(); 106 | $driver = $this->dataSource->getConnection()->getDriver(); 107 | $or = []; 108 | 109 | foreach ($condition as $column => $value) { 110 | $column = Helpers::escape($driver, $column, Fluent::Identifier); 111 | 112 | if ($filter->isExactSearch()) { 113 | $this->dataSource->where(sprintf('%s = %%s', $column), $value); 114 | 115 | continue; 116 | } 117 | 118 | $or[] = sprintf('%s LIKE "%%%s%%"', $column, $value); 119 | } 120 | 121 | if (count($or) > 1) { 122 | $this->dataSource->where($filter->hasConjunctionSearch() ? '(%and)' : '(%or)', $or); 123 | } else { 124 | $this->dataSource->where($or); 125 | } 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /.claude/agents/php-changelog-generator.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: php-changelog-generator 3 | description: Use this agent when you need to generate or update a changelog/upgrade guide for a PHP project following PHP community conventions. Examples: Context: User has just completed a new feature release for their PHP library and needs to document the changes. user: 'I've just finished implementing a new authentication system and deprecated the old login methods. Can you help me create a changelog entry for version 2.1.0?' assistant: 'I'll use the php-changelog-generator agent to create a properly formatted changelog entry following PHP community standards.' The user needs a changelog for their PHP project changes, so use the php-changelog-generator agent to create documentation following PHP patterns like Doctrine ORM. Context: User is preparing for a major version release with breaking changes. user: 'We're releasing version 3.0.0 next week with several breaking changes to our API. I need to update our UPGRADE.md file.' assistant: 'Let me use the php-changelog-generator agent to help you create a comprehensive upgrade guide with breaking changes documentation.' This is exactly what the php-changelog-generator agent is designed for - creating upgrade documentation for PHP projects with breaking changes. 4 | model: sonnet 5 | color: blue 6 | --- 7 | 8 | You are a PHP project documentation specialist with deep expertise in creating changelogs and upgrade guides that follow PHP community standards and conventions. You understand the patterns used by major PHP projects like Doctrine ORM, Symfony, and other established libraries. 9 | 10 | Your primary responsibility is to generate well-structured changelog entries and upgrade documentation that follows these principles: 11 | 12 | **Format and Structure:** 13 | - Use clear version headers (e.g., '# Upgrade to 2.1', '## 2.1.0') 14 | - Organize changes by impact level: BREAKING CHANGES, New Features, Improvements, Bug Fixes, Deprecations 15 | - Use consistent markdown formatting with proper heading hierarchy 16 | - Include dates in ISO format (YYYY-MM-DD) when available 17 | - Follow semantic versioning principles while accommodating near-semver practices 18 | 19 | **Content Guidelines:** 20 | - Write clear, actionable descriptions that help developers understand the impact 21 | - For breaking changes, provide before/after code examples 22 | - Include migration paths and upgrade instructions 23 | - Mention deprecated features with timeline for removal 24 | - Reference relevant issue numbers, pull requests, or documentation when available 25 | - Use PHP-specific terminology and conventions 26 | 27 | **Quality Standards:** 28 | - Prioritize clarity and developer experience over brevity 29 | - Ensure technical accuracy in all code examples 30 | - Maintain consistent tone and style throughout 31 | - Group related changes logically 32 | - Provide context for why changes were made when it aids understanding 33 | 34 | **Process:** 35 | 1. Ask for the version number, release date, and list of changes if not provided 36 | 2. Categorize changes by type and impact level 37 | 3. Structure the changelog following PHP community patterns 38 | 4. Include code examples for breaking changes and new features 39 | 5. Provide clear upgrade instructions for breaking changes 40 | 6. Review for completeness and clarity before presenting 41 | 42 | When information is missing or unclear, proactively ask specific questions to ensure the changelog meets professional PHP project standards. Focus on creating documentation that genuinely helps developers upgrade their code safely and efficiently. 43 | -------------------------------------------------------------------------------- /assets/plugins/features/checkboxes.ts: -------------------------------------------------------------------------------- 1 | import { DatagridPlugin } from "../../types"; 2 | import { Datagrid } from "../.."; 3 | 4 | export const CheckboxAttribute = "data-check"; 5 | 6 | export class CheckboxPlugin implements DatagridPlugin { 7 | onDatagridInit(datagrid: Datagrid): boolean { 8 | datagrid.ajax.addEventListener('complete', (event) => { 9 | this.init(datagrid); 10 | }); 11 | 12 | return this.init(datagrid); 13 | } 14 | 15 | private init(datagrid: Datagrid): boolean { 16 | let lastCheckbox = null; 17 | 18 | datagrid.el.addEventListener("click", e => { 19 | if (!(e.target instanceof HTMLElement)) return; 20 | 21 | if (e.target.classList.contains("col-checkbox")) { 22 | lastCheckbox = e.target; 23 | if (e.shiftKey && lastCheckbox) { 24 | const currentCheckboxRow = lastCheckbox.closest("tr"); 25 | if (!currentCheckboxRow) return; 26 | 27 | const lastCheckboxRow = lastCheckbox.closest("tr"); 28 | if (!lastCheckboxRow) return; 29 | 30 | const lastCheckboxTbody = lastCheckboxRow.closest("tbody"); 31 | if (!lastCheckboxTbody) return; 32 | 33 | const checkboxesRows = Array.from(lastCheckboxTbody.querySelectorAll("tr")); 34 | const [start, end] = [lastCheckboxRow.rowIndex, currentCheckboxRow.rowIndex].sort(); 35 | const rows = checkboxesRows.slice(start, end + 1); 36 | 37 | rows.forEach(row => { 38 | const input = row.querySelector('.col-checkbox input[type="checkbox"]'); 39 | if (input) { 40 | input.checked = true; 41 | } 42 | }); 43 | } 44 | } 45 | }); 46 | 47 | const checkboxes = Array.from(datagrid.el.querySelectorAll(`input[data-check='${datagrid.name}']:not([data-check-all])`)); 48 | const selectAll = datagrid.el.querySelector(`input[data-check='${datagrid.name}'][data-check-all]`); 49 | 50 | const select = datagrid.el.querySelector("select[name='group_action[group_action]']"); 51 | const actionButtons = document.querySelectorAll( 52 | ".row-group-actions *[type='submit']" 53 | ); 54 | const counter = document.querySelector(".datagrid-selected-rows-count"); 55 | 56 | [...checkboxes, selectAll].forEach(checkEl => { 57 | if (!checkEl) return; 58 | 59 | checkEl.addEventListener("change", () => { 60 | // Select all 61 | const isSelectAll = checkEl.hasAttribute("data-check-all"); 62 | if (isSelectAll) { 63 | if (datagrid.name !== checkEl.getAttribute("data-check-all")) return; 64 | 65 | checkboxes.forEach(checkbox => (checkbox.checked = checkEl.checked)); 66 | 67 | actionButtons.forEach(button => (button.disabled = !checkEl.checked)); 68 | 69 | if (select) { 70 | select.disabled = !checkEl.checked; 71 | } 72 | 73 | if (counter) { 74 | const total = checkboxes.length; 75 | counter.innerText = `${checkEl.checked ? total : 0}/${total}`; 76 | } 77 | return; 78 | } else { 79 | if (selectAll) { 80 | selectAll.checked = checkboxes.every(c => c.checked); 81 | } 82 | } 83 | 84 | const checkedBoxes = checkboxes.filter(c => c.checked); 85 | const hasChecked = checkedBoxes.length >= 1; 86 | 87 | actionButtons.forEach(button => (button.disabled = !hasChecked)); 88 | 89 | if (select) { 90 | select.disabled = !hasChecked; 91 | } 92 | 93 | if (counter) { 94 | counter.innerText = `${checkedBoxes.length}/${checkboxes.length}`; 95 | } 96 | }); 97 | }); 98 | 99 | return true; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Export/Export.php: -------------------------------------------------------------------------------- 1 | text = $text; 44 | $this->callback = $callback; 45 | $this->title = $text; 46 | } 47 | 48 | public function render(): Html 49 | { 50 | $a = Html::el('a', [ 51 | 'class' => [$this->class], 52 | 'title' => $this->grid->getTranslator()->translate($this->getTitle()), 53 | 'href' => $this->link, 54 | 'target' => $this->target, 55 | ]); 56 | 57 | $this->tryAddIcon( 58 | $a, 59 | $this->getIcon(), 60 | $this->grid->getTranslator()->translate($this->getTitle()) 61 | ); 62 | 63 | $a->addText($this->grid->getTranslator()->translate($this->text)); 64 | 65 | if ($this->isAjax()) { 66 | $a->appendAttribute('class', 'ajax'); 67 | } 68 | 69 | if ($this->confirmDialog !== null) { 70 | $a->setAttribute('data-datagrid-confirm', $this->confirmDialog); 71 | } 72 | 73 | return $a; 74 | } 75 | 76 | /** 77 | * @return static 78 | */ 79 | public function setConfirmDialog(string $confirmDialog): self 80 | { 81 | $this->confirmDialog = $confirmDialog; 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * Tell export which columns to use when exporting data 88 | * 89 | * @return static 90 | */ 91 | public function setColumns(array $columns): self 92 | { 93 | $this->columns = $columns; 94 | 95 | return $this; 96 | } 97 | 98 | /** 99 | * Get columns for export 100 | */ 101 | public function getColumns(): array 102 | { 103 | return $this->columns; 104 | } 105 | 106 | /** 107 | * Export signal url 108 | * 109 | * @return static 110 | */ 111 | public function setLink(Link $link): self 112 | { 113 | $this->link = $link; 114 | 115 | return $this; 116 | } 117 | 118 | /** 119 | * Tell export whether to be called via ajax or not 120 | * 121 | * @return static 122 | */ 123 | public function setAjax(bool $ajax = true): self 124 | { 125 | $this->ajax = $ajax; 126 | 127 | return $this; 128 | } 129 | 130 | public function isAjax(): bool 131 | { 132 | return $this->ajax; 133 | } 134 | 135 | /** 136 | * Is export filtered? 137 | */ 138 | public function isFiltered(): bool 139 | { 140 | return $this->filtered; 141 | } 142 | 143 | /** 144 | * Call export callback 145 | */ 146 | public function invoke(iterable $data): void 147 | { 148 | ($this->callback)($data, $this->grid); 149 | } 150 | 151 | /** 152 | * Adds target to html:a [_blank, _self, _parent, _top] 153 | */ 154 | public function setTarget(?string $target = null): void 155 | { 156 | if (in_array($target, ['_blank', '_self', '_parent', '_top'], true)) { 157 | $this->target = $target; 158 | } 159 | } 160 | 161 | } 162 | -------------------------------------------------------------------------------- /src/Response/CsvResponse.php: -------------------------------------------------------------------------------- 1 | > */ 22 | protected array $data; 23 | 24 | protected string $outputEncoding; 25 | 26 | protected bool $includeBom; 27 | 28 | protected string $name; 29 | 30 | /** @var string[] */ 31 | protected array $headers = [ 32 | 'Expires' => '0', 33 | 'Cache-Control' => 'no-cache', 34 | 'Pragma' => 'Public', 35 | ]; 36 | 37 | /** 38 | * @param array> $data Input data 39 | */ 40 | public function __construct( 41 | array $data, 42 | string $name = 'export.csv', 43 | string $outputEncoding = 'utf-8', 44 | string $delimiter = ';', 45 | bool $includeBom = false 46 | ) 47 | { 48 | if (strpos($name, '.csv') === false) { 49 | $name = sprintf('%s.csv', $name); 50 | } 51 | 52 | $this->name = $name; 53 | $this->delimiter = $delimiter; 54 | $this->data = $data; 55 | $this->outputEncoding = $outputEncoding; 56 | $this->includeBom = $includeBom; 57 | } 58 | 59 | public function send(HttpRequest $httpRequest, HttpResponse $httpResponse): void 60 | { 61 | // Disable tracy bar 62 | if (class_exists(Debugger::class)) { 63 | Debugger::$productionMode = true; 64 | } 65 | 66 | // Set Content-Type header 67 | $httpResponse->setContentType($this->contentType, $this->outputEncoding); 68 | 69 | // Set Content-Disposition header 70 | $httpResponse->setHeader('Content-Disposition', sprintf('attachment; filename="%s"', $this->name)); 71 | 72 | // Set other headers 73 | foreach ($this->headers as $key => $value) { 74 | $httpResponse->setHeader($key, $value); 75 | } 76 | 77 | if (function_exists('ob_start')) { 78 | ob_start(); 79 | } 80 | 81 | // Output data 82 | if ($this->includeBom) { 83 | echo $this->getBom(); 84 | } 85 | 86 | foreach ($this->data as $row) { 87 | $csvRow = $this->printCsv((array) $row); // @phpstan-ignore-line 88 | 89 | if (strtolower($this->outputEncoding) === 'utf-8') { 90 | echo $csvRow; 91 | } elseif (strtolower($this->outputEncoding) === 'windows-1250') { 92 | echo iconv('utf-8', $this->outputEncoding, $csvRow); 93 | } else { 94 | echo mb_convert_encoding($csvRow, $this->outputEncoding); 95 | } 96 | } 97 | 98 | if (function_exists('ob_end_flush')) { 99 | ob_end_flush(); 100 | } 101 | } 102 | 103 | private function getBom(): string 104 | { 105 | switch (strtolower($this->outputEncoding)) { 106 | case 'utf-8': 107 | return b"\xEF\xBB\xBF"; 108 | case 'utf-16': 109 | return b"\xFF\xFE"; 110 | default: 111 | return ''; 112 | } 113 | } 114 | 115 | /** 116 | * @param array $row 117 | */ 118 | private function printCsv(array $row): string 119 | { 120 | $out = fopen('php://memory', 'wb+'); 121 | 122 | if ($out === false) { 123 | throw new InvalidStateException('Unable to open memory stream'); 124 | } 125 | 126 | fputcsv($out, $row, $this->delimiter, escape: '\\'); 127 | rewind($out); 128 | $c = stream_get_contents($out); 129 | fclose($out); 130 | 131 | if ($c === false) { 132 | throw new InvalidStateException('Unable to read from memory stream'); 133 | } 134 | 135 | return $c; 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /src/Column/ColumnStatus.php: -------------------------------------------------------------------------------- 1 | key = $key; 40 | 41 | $this->setTemplate(__DIR__ . '/../templates/column_status.latte'); 42 | } 43 | 44 | public function getKey(): string 45 | { 46 | return $this->key; 47 | } 48 | 49 | public function getOptions(): array 50 | { 51 | return $this->options; 52 | } 53 | 54 | /** 55 | * @throws DatagridColumnStatusException 56 | */ 57 | public function getOption(mixed $value): Option 58 | { 59 | foreach ($this->options as $option) { 60 | if ($option->getValue() === $value) { 61 | return $option; 62 | } 63 | } 64 | 65 | throw new DatagridColumnStatusException(sprintf('Option [%s] does not exist', $value)); 66 | } 67 | 68 | public function getColumn(): string 69 | { 70 | return $this->column; 71 | } 72 | 73 | /** 74 | * Find selected option for current item/row 75 | */ 76 | public function getCurrentOption(Row $row): ?Option 77 | { 78 | foreach ($this->getOptions() as $option) { 79 | if ($option->getValue() === $row->getValue($this->getColumn())) { 80 | return $option; 81 | } 82 | } 83 | 84 | return null; 85 | } 86 | 87 | /** 88 | * @throws DatagridColumnStatusException 89 | */ 90 | public function addOption(mixed $value, string $text): Option 91 | { 92 | if (!is_scalar($value)) { 93 | throw new DatagridColumnStatusException('Option value has to be scalar'); 94 | } 95 | 96 | $option = new Option($this->grid, $this, $value, $text); 97 | 98 | $this->options[] = $option; 99 | 100 | return $option; 101 | } 102 | 103 | /** 104 | * Set all options at once 105 | * 106 | * @param string[] $options 107 | * @return static 108 | */ 109 | public function setOptions(array $options): self 110 | { 111 | foreach ($options as $value => $text) { 112 | $this->addOption($value, $text); 113 | } 114 | 115 | return $this; 116 | } 117 | 118 | public function removeOption(mixed $value): void 119 | { 120 | foreach ($this->options as $key => $option) { 121 | if ($option->getValue() === $value) { 122 | unset($this->options[$key]); 123 | } 124 | } 125 | } 126 | 127 | /** 128 | * Column can have variables that will be passed to custom template scope 129 | */ 130 | public function getTemplateVariables(): array 131 | { 132 | return array_merge($this->templateVariables, [ 133 | 'options' => $this->getOptions(), 134 | 'column' => $this->getColumn(), 135 | 'key' => $this->getKey(), 136 | 'status' => $this, 137 | ]); 138 | } 139 | 140 | public function setReplacement(array $replacements): Column 141 | { 142 | throw new DatagridColumnStatusException( 143 | 'Cannot set replacement for Column Status. For status texts replacement use ->setOptions($replacements)' 144 | ); 145 | } 146 | 147 | } 148 | -------------------------------------------------------------------------------- /src/Filter/Filter.php: -------------------------------------------------------------------------------- 1 | ['form-control', 'form-control-sm'], 28 | ]; 29 | 30 | private ?string $placeholder = null; 31 | 32 | public function __construct(protected Datagrid $grid, protected string $key, protected string $name) 33 | { 34 | } 35 | 36 | abstract public function getCondition(): array; 37 | 38 | /** 39 | * Get filter key 40 | */ 41 | public function getKey(): string 42 | { 43 | return $this->key; 44 | } 45 | 46 | /** 47 | * Get filter name 48 | */ 49 | public function getName(): string 50 | { 51 | return $this->name; 52 | } 53 | 54 | /** 55 | * Tell whether value has been set in this fitler 56 | */ 57 | public function isValueSet(): bool 58 | { 59 | return $this->valueSet; 60 | } 61 | 62 | /** 63 | * @return static 64 | */ 65 | public function setValue(mixed $value): self 66 | { 67 | $this->value = $value; 68 | $this->valueSet = true; 69 | 70 | return $this; 71 | } 72 | 73 | public function getValue(): mixed 74 | { 75 | return $this->value; 76 | } 77 | 78 | /** 79 | * Set HTML attribute "placeholder" 80 | * 81 | * @return static 82 | */ 83 | public function setPlaceholder(string $placeholder): self 84 | { 85 | $this->placeholder = $placeholder; 86 | 87 | return $this; 88 | } 89 | 90 | public function getPlaceholder(): ?string 91 | { 92 | return $this->placeholder; 93 | } 94 | 95 | /** 96 | * Set custom condition on filter 97 | * 98 | * @return static 99 | */ 100 | public function setCondition(callable $conditionCallback): self 101 | { 102 | $this->conditionCallback = $conditionCallback; 103 | 104 | return $this; 105 | } 106 | 107 | public function getConditionCallback(): ?callable 108 | { 109 | return $this->conditionCallback; 110 | } 111 | 112 | /** 113 | * @return static 114 | */ 115 | public function setTemplate(string $template): self 116 | { 117 | $this->template = $template; 118 | 119 | return $this; 120 | } 121 | 122 | public function getTemplate(): ?string 123 | { 124 | return $this->template; 125 | } 126 | 127 | public function getType(): ?string 128 | { 129 | return $this->type; 130 | } 131 | 132 | /** 133 | * @return static 134 | */ 135 | public function addAttribute(string $name, mixed $value): self 136 | { 137 | $this->attributes[$name][] = $value; 138 | 139 | return $this; 140 | } 141 | 142 | /** 143 | * @return static 144 | */ 145 | public function setAttribute(string $name, mixed $value): self 146 | { 147 | $this->attributes[$name] = (array) $value; 148 | 149 | return $this; 150 | } 151 | 152 | public function getAttributes(): array 153 | { 154 | return $this->attributes; 155 | } 156 | 157 | protected function addAttributes(BaseControl $input): BaseControl 158 | { 159 | if ($this->grid->hasAutoSubmit()) { 160 | $input->setHtmlAttribute('data-autosubmit', true); 161 | } else { 162 | $input->setHtmlAttribute('data-datagrid-manualsubmit', true); 163 | } 164 | 165 | foreach ($this->attributes as $key => $value) { 166 | if (is_array($value)) { 167 | $value = array_unique($value); 168 | $value = implode(' ', $value); 169 | } 170 | 171 | $input->setHtmlAttribute($key, $value); 172 | } 173 | 174 | return $input; 175 | } 176 | 177 | } 178 | -------------------------------------------------------------------------------- /src/Column/ColumnLink.php: -------------------------------------------------------------------------------- 1 | useRenderer($row); 44 | } catch (DatagridColumnRendererException) { 45 | /** 46 | * Do not use renderer 47 | */ 48 | } 49 | 50 | $value = parent::render($row); 51 | 52 | if (! (bool) $value && $this->icon !== null) { 53 | return null; 54 | } 55 | 56 | $a = Html::el('a') 57 | ->href($this->createLink( 58 | $this->grid, 59 | $this->href, 60 | $this->getItemParams($row, $this->params) + $this->parameters 61 | )); 62 | 63 | if ($this->dataAttributes !== []) { 64 | foreach ($this->dataAttributes as $key => $attrValue) { 65 | $a->data((string) $key, $attrValue); 66 | } 67 | } 68 | 69 | if ($this->openInNewTab) { 70 | $a->addAttributes(['target' => '_blank']); 71 | } 72 | 73 | if ($this->title !== null) { 74 | $a->setAttribute('title', $this->title); 75 | } 76 | 77 | if ($this->class !== null) { 78 | $a->setAttribute('class', $this->class); 79 | } 80 | 81 | $element = $a; 82 | 83 | if ($this->icon !== null) { 84 | $a->addHtml( 85 | Html::el('span')->setAttribute('class', Datagrid::$iconPrefix . $this->icon) 86 | ); 87 | 88 | if (strlen($value) > 0) { 89 | $a->addHtml(' '); 90 | } 91 | } 92 | 93 | if ($this->isTemplateEscaped()) { 94 | $a->addText((string) $value); 95 | } else { 96 | $a->addHtml($value); 97 | } 98 | 99 | return $element; 100 | } 101 | 102 | /** 103 | * @return static 104 | */ 105 | public function addParameters(array $parameters): self 106 | { 107 | $this->parameters = $parameters; 108 | 109 | return $this; 110 | } 111 | 112 | /** 113 | * @return static 114 | */ 115 | public function setIcon(?string $icon = null): self 116 | { 117 | $this->icon = $icon; 118 | 119 | return $this; 120 | } 121 | 122 | /** 123 | * @return static 124 | */ 125 | public function setDataAttribute(string $key, mixed $value): self 126 | { 127 | $this->dataAttributes[$key] = $value; 128 | 129 | return $this; 130 | } 131 | 132 | /** 133 | * @return static 134 | */ 135 | public function setTitle(string $title): self 136 | { 137 | $this->title = $title; 138 | 139 | return $this; 140 | } 141 | 142 | public function getTitle(): ?string 143 | { 144 | return $this->title; 145 | } 146 | 147 | /** 148 | * @return static 149 | */ 150 | public function setClass(?string $class): self 151 | { 152 | $this->class = $class; 153 | 154 | return $this; 155 | } 156 | 157 | public function getClass(): ?string 158 | { 159 | return $this->class; 160 | } 161 | 162 | public function isOpenInNewTab(): bool 163 | { 164 | return $this->openInNewTab; 165 | } 166 | 167 | /** 168 | * @return static 169 | */ 170 | public function setOpenInNewTab(bool $openInNewTab = true): self 171 | { 172 | $this->openInNewTab = $openInNewTab; 173 | 174 | return $this; 175 | } 176 | 177 | } 178 | -------------------------------------------------------------------------------- /src/ColumnsSummary.php: -------------------------------------------------------------------------------- 1 | summary = array_fill_keys(array_values($columns), 0); 35 | $this->rowCallback = $rowCallback; 36 | 37 | foreach (array_keys($this->summary) as $key) { 38 | $column = $this->datagrid->getColumn($key); 39 | 40 | if ($column instanceof ColumnNumber) { 41 | $arg = $column->getFormat(); 42 | array_unshift($arg, $key); 43 | 44 | $this->setFormat(...$arg); 45 | } 46 | } 47 | } 48 | 49 | public function add(Row $row): void 50 | { 51 | foreach (array_keys($this->summary) as $key) { 52 | $column = $this->datagrid->getColumn($key); 53 | 54 | $value = $this->getValue($row, $column); 55 | $this->summary[$key] += $value; 56 | } 57 | } 58 | 59 | public function render(string $key): ?string 60 | { 61 | try { 62 | return $this->useRenderer($key); 63 | } catch (DatagridColumnRendererException) { 64 | // No need to worry. 65 | } 66 | 67 | if (!isset($this->summary[$key])) { 68 | return ''; 69 | } 70 | 71 | return number_format( 72 | $this->summary[$key], 73 | $this->format[$key][0], 74 | $this->format[$key][1], 75 | $this->format[$key][2] 76 | ); 77 | } 78 | 79 | public function useRenderer(string $key): mixed 80 | { 81 | if (!isset($this->summary[$key])) { 82 | return null; 83 | } 84 | 85 | $renderer = $this->getRenderer(); 86 | 87 | if ($renderer === null) { 88 | throw new DatagridColumnRendererException(); 89 | } 90 | 91 | return call_user_func_array($renderer->getCallback(), [$this->summary[$key], $key]); 92 | } 93 | 94 | public function getRenderer(): ?Renderer 95 | { 96 | return $this->renderer; 97 | } 98 | 99 | /** 100 | * @return static 101 | */ 102 | public function setRenderer(callable $renderer): self 103 | { 104 | $this->renderer = new Renderer($renderer, null); 105 | 106 | return $this; 107 | } 108 | 109 | /** 110 | * @return static 111 | */ 112 | public function setFormat( 113 | string $key, 114 | int $decimals = 0, 115 | string $dec_point = '.', 116 | string $thousands_sep = ' ' 117 | ): self 118 | { 119 | $this->format[$key] = [$decimals, $dec_point, $thousands_sep]; 120 | 121 | return $this; 122 | } 123 | 124 | public function someColumnsExist(array $columns): bool 125 | { 126 | foreach (array_keys($columns) as $key) { 127 | if (isset($this->summary[$key])) { 128 | return true; 129 | } 130 | } 131 | 132 | return false; 133 | } 134 | 135 | /** 136 | * @return static 137 | */ 138 | public function setPositionTop(bool $top = true): self 139 | { 140 | $this->positionTop = $top !== false; 141 | 142 | return $this; 143 | } 144 | 145 | public function getPositionTop(): bool 146 | { 147 | return $this->positionTop; 148 | } 149 | 150 | /** 151 | * Get value from column using Row::getValue() or custom callback 152 | */ 153 | private function getValue(Row $row, Column $column): mixed 154 | { 155 | if ($this->rowCallback === null) { 156 | return $row->getValue($column->getColumn()); 157 | } 158 | 159 | return call_user_func_array($this->rowCallback, [$row->getItem(), $column->getColumn()]); 160 | } 161 | 162 | } 163 | -------------------------------------------------------------------------------- /src/Column/MultiAction.php: -------------------------------------------------------------------------------- 1 | setTemplate(__DIR__ . '/../templates/column_multi_action.latte'); 42 | } 43 | 44 | public function renderButton(): Html 45 | { 46 | $button = Html::el('button') 47 | ->setAttribute('type', 'button') 48 | ->data('bs-toggle', 'dropdown'); 49 | 50 | $this->tryAddIcon($button, $this->getIcon(), $this->getName()); 51 | 52 | $button->addText($this->grid->getTranslator()->translate($this->name)); 53 | 54 | if ($this->hasCaret()) { 55 | $button->addHtml(' '); 56 | $button->addHtml(''); 57 | } 58 | 59 | if ($this->getTitle() !== null) { 60 | $button->setAttribute( 61 | 'title', 62 | $this->grid->getTranslator()->translate($this->getTitle()) 63 | ); 64 | } 65 | 66 | if ($this->getClass() !== '') { 67 | $button->setAttribute('class', $this->getClass() . ' dropdown-toggle'); 68 | } 69 | 70 | return $button; 71 | } 72 | 73 | /** 74 | * @return static 75 | */ 76 | public function addAction( 77 | string $key, 78 | string $name, 79 | ?string $href = null, 80 | ?array $params = null 81 | ): self 82 | { 83 | if (isset($this->actions[$key])) { 84 | throw new DatagridException( 85 | sprintf('There is already action at key [%s] defined for MultiAction.', $key) 86 | ); 87 | } 88 | 89 | $href ??= $key; 90 | 91 | if ($params === null) { 92 | $params = [$this->grid->getPrimaryKey()]; 93 | } 94 | 95 | $action = new Action($this->grid, $key, $href, $name, $params); 96 | 97 | $action->setClass('dropdown-item datagrid-multiaction-dropdown-item'); 98 | 99 | $this->actions[$key] = $action; 100 | 101 | return $this; 102 | } 103 | 104 | /** 105 | * @return array 106 | */ 107 | public function getActions(): array 108 | { 109 | return $this->actions; 110 | } 111 | 112 | public function getAction(string $key): Action 113 | { 114 | if (!isset($this->actions[$key])) { 115 | throw new DatagridException( 116 | sprintf('There is no action at key [%s] defined for MultiAction.', $key) 117 | ); 118 | } 119 | 120 | return $this->actions[$key]; 121 | } 122 | 123 | /** 124 | * Column can have variables that will be passed to custom template scope 125 | */ 126 | public function getTemplateVariables(): array 127 | { 128 | return array_merge($this->templateVariables, [ 129 | 'multiAction' => $this, 130 | ]); 131 | } 132 | 133 | public function setRowCondition( 134 | string $actionKey, 135 | callable $rowCondition 136 | ): void 137 | { 138 | $this->rowConditions[$actionKey] = $rowCondition; 139 | } 140 | 141 | public function testRowCondition(string $actionKey, Row $row): bool 142 | { 143 | if (!isset($this->rowConditions[$actionKey])) { 144 | return true; 145 | } 146 | 147 | return (bool) call_user_func($this->rowConditions[$actionKey], $row->getItem()); 148 | } 149 | 150 | } 151 | -------------------------------------------------------------------------------- /src/Status/Option.php: -------------------------------------------------------------------------------- 1 | value; 37 | } 38 | 39 | public function endOption(): ColumnStatus 40 | { 41 | return $this->columnStatus; 42 | } 43 | 44 | /** 45 | * @return static 46 | */ 47 | public function setTitle(string $title): self 48 | { 49 | $this->title = $title; 50 | 51 | return $this; 52 | } 53 | 54 | public function getTitle(): ?string 55 | { 56 | return $this->title; 57 | } 58 | 59 | /** 60 | * @return static 61 | */ 62 | public function setClass(string $class, ?string $classSecondary = null): self 63 | { 64 | $this->class = $class; 65 | 66 | if ($classSecondary !== null) { 67 | $this->classSecondary = $classSecondary; 68 | } 69 | 70 | return $this; 71 | } 72 | 73 | public function getClass(): ?string 74 | { 75 | return $this->class; 76 | } 77 | 78 | /** 79 | * @return static 80 | */ 81 | public function setClassSecondary(string $classSecondary): self 82 | { 83 | $this->classSecondary = $classSecondary; 84 | 85 | return $this; 86 | } 87 | 88 | public function getClassSecondary(): string 89 | { 90 | return $this->classSecondary; 91 | } 92 | 93 | /** 94 | * @return static 95 | */ 96 | public function setClassInDropdown(string $classInDropdown): self 97 | { 98 | $this->classInDropdown = $classInDropdown; 99 | 100 | return $this; 101 | } 102 | 103 | public function getClassInDropdown(): string 104 | { 105 | return $this->classInDropdown; 106 | } 107 | 108 | /** 109 | * @return static 110 | */ 111 | public function setIcon(string $icon): self 112 | { 113 | $this->icon = $icon; 114 | 115 | return $this; 116 | } 117 | 118 | public function getIcon(): ?string 119 | { 120 | return $this->icon; 121 | } 122 | 123 | /** 124 | * @return static 125 | */ 126 | public function setIconSecondary(string $iconSecondary): self 127 | { 128 | $this->iconSecondary = $iconSecondary; 129 | 130 | return $this; 131 | } 132 | 133 | public function getIconSecondary(): ?string 134 | { 135 | return $this->iconSecondary; 136 | } 137 | 138 | public function getText(): string 139 | { 140 | return $this->text; 141 | } 142 | 143 | /** 144 | * @return static 145 | */ 146 | public function setConfirmation(IConfirmation $confirmation): self 147 | { 148 | $this->confirmation = $confirmation; 149 | 150 | return $this; 151 | } 152 | 153 | public function getConfirmationDialog(Row $row): ?string 154 | { 155 | if ($this->confirmation === null) { 156 | return null; 157 | } 158 | 159 | if ($this->confirmation instanceof CallbackConfirmation) { 160 | return ($this->confirmation->getCallback())($row->getItem()); 161 | } 162 | 163 | if ($this->confirmation instanceof StringConfirmation) { 164 | $question = $this->grid->getTranslator()->translate($this->confirmation->getQuestion()); 165 | 166 | if ($this->confirmation->getPlaceholderName() === null) { 167 | return $question; 168 | } 169 | 170 | return str_replace( 171 | '%s', 172 | (string) $row->getValue($this->confirmation->getPlaceholderName()), 173 | $question 174 | ); 175 | } 176 | 177 | throw new DatagridException('Unsupported confirmation'); 178 | } 179 | 180 | } 181 | -------------------------------------------------------------------------------- /assets/types/ajax.d.ts: -------------------------------------------------------------------------------- 1 | import { EventDetail, EventListener, EventMap } from "."; 2 | import { Datagrid } from ".."; 3 | 4 | export interface BaseRequestParams { 5 | method: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH" | string; 6 | url: string; 7 | } 8 | 9 | export interface RequestParams extends BaseRequestParams { 10 | data: D; 11 | } 12 | 13 | export interface DatagridPayload { 14 | _datagrid_name?: string; 15 | _datagrid_toggle_detail?: string 16 | _datagrid_inline_editing?: boolean; 17 | _datagrid_inline_adding?: boolean; 18 | _datagrid_inline_edited?: boolean; 19 | _datagrid_inline_edit_cancel?: boolean; 20 | _datagrid_url?: boolean; 21 | _datagrid_sort?: Record; 22 | _datagrid_tree?: string; 23 | _datagrid_editable_new_value?: string; 24 | _datagrid_redraw_item_id?: string; 25 | _datagrid_redraw_item_class?: string; 26 | _datagrid_init?: boolean; 27 | non_empty_filters?: string[]; 28 | } 29 | 30 | export interface DatagridState { 31 | "grid-page": number | null, 32 | "grid-perPage": number, 33 | // TODO 34 | "grid-sort": any | null, 35 | "grid-filter": any | null 36 | } 37 | 38 | export type Payload

= P & { 39 | snippets?: Record; 40 | redirect?: string; 41 | state: S; 42 | }; 43 | 44 | export interface Response { 45 | headers: Record | Headers; 46 | status: number; 47 | } 48 | 49 | export interface BeforeEventDetail { 50 | params: RequestParams; 51 | } 52 | 53 | export interface InteractEventDetail { 54 | element: E; 55 | } 56 | 57 | export interface SuccessEventDetail

{ 58 | params: BaseRequestParams; 59 | payload: Payload

; 60 | response: Response; 61 | } 62 | 63 | export interface CompleteEventDetail

{ 64 | params: BaseRequestParams; 65 | payload: Payload

; 66 | response: Response; 67 | } 68 | 69 | export interface ErrorEventDetail { 70 | params: BaseRequestParams; 71 | response?: Response; 72 | error?: E; 73 | } 74 | 75 | export interface AjaxEventMap extends EventMap { 76 | before: CustomEvent; 77 | interact: CustomEvent; 78 | snippetUpdate: CustomEvent; 79 | success: CustomEvent; 80 | complete: CustomEvent; 81 | error: CustomEvent; 82 | } 83 | 84 | export interface Ajax extends EventTarget { 85 | client: C; 86 | 87 | /** 88 | * Initialization of the Ajax instance, called in createDatagrids(). 89 | * @return this 90 | */ 91 | onInit(): this; 92 | 93 | /** 94 | * Initializes a Datagrid instance. 95 | * @param grid The Datagrid instance 96 | */ 97 | onDatagridInit?(grid: G): void; 98 | 99 | /** 100 | * Sends a request to the server. 101 | */ 102 | request(args: RequestParams): Promise

; 103 | 104 | /** 105 | * Submits a form 106 | */ 107 | submitForm(element: E): Promise

; 108 | 109 | /** 110 | * Shortcut for dispatchEvent 111 | * @internal 112 | */ 113 | dispatch( 114 | type: K, 115 | detail: K extends keyof M ? EventDetail : any, 116 | options?: boolean 117 | ): boolean; 118 | 119 | /** 120 | * Note: For events dispatched directly from the underlying client, {@see Ajax.client}} 121 | **/ 122 | addEventListener( 123 | type: K, 124 | listener: EventListener, 125 | options?: boolean | AddEventListenerOptions 126 | ): void; 127 | 128 | /** 129 | * Note: For events dispatched directly from the underlying client, {@see Ajax.client}} 130 | **/ 131 | removeEventListener( 132 | type: K, 133 | listener: EventListener, 134 | options?: boolean | AddEventListenerOptions 135 | ): void; 136 | 137 | /** 138 | * @internal 139 | */ 140 | dispatchEvent( 141 | event: K extends keyof M ? M[K] : CustomEvent 142 | ): boolean; 143 | } 144 | -------------------------------------------------------------------------------- /src/Column/ItemDetail.php: -------------------------------------------------------------------------------- 1 | title = 'contributte_datagrid.show'; 45 | $this->class = sprintf('btn btn-xs %s ajax', $grid::$btnSecondaryClass); 46 | $this->icon = 'eye'; 47 | } 48 | 49 | /** 50 | * Render row item detail button 51 | */ 52 | public function renderButton(Row $row): Html 53 | { 54 | $a = Html::el('a') 55 | ->href($this->grid->link('getItemDetail!', ['id' => $row->getId()])) 56 | ->data('toggle-detail', $row->getId()) 57 | ->data('toggle-detail-grid-fullname', $this->grid->getFullName()) 58 | ->data('toggle-detail-grid', $this->grid->getName()); 59 | 60 | $this->tryAddIcon($a, $this->getIcon(), $this->getText()); 61 | 62 | $a->addText($this->text); 63 | 64 | if ($this->title !== null) { 65 | $a->setAttribute( 66 | 'title', 67 | $this->grid->getTranslator()->translate($this->title) 68 | ); 69 | } 70 | 71 | if ($this->class !== '') { 72 | $a->setAttribute('class', $this->class); 73 | } 74 | 75 | return $a; 76 | } 77 | 78 | public function render(mixed $item): mixed 79 | { 80 | if ($this->getType() === 'block') { 81 | throw new DatagridItemDetailException('ItemDetail is set to render as block, but block #detail is not defined'); 82 | } 83 | 84 | if ($this->getRenderer() === null) { 85 | throw new LogicException('Renderer is not set'); 86 | } 87 | 88 | return call_user_func($this->getRenderer(), $item); 89 | } 90 | 91 | public function getPrimaryWhereColumn(): string 92 | { 93 | return $this->primaryWhereColumn; 94 | } 95 | 96 | /** 97 | * Set item detail type 98 | * 99 | * @return static 100 | */ 101 | public function setType(string $type): self 102 | { 103 | $this->type = $type; 104 | 105 | return $this; 106 | } 107 | 108 | /** 109 | * Get item detail type 110 | */ 111 | public function getType(): ?string 112 | { 113 | return $this->type; 114 | } 115 | 116 | /** 117 | * Set item detail template 118 | * 119 | * @return static 120 | */ 121 | public function setTemplate(string $template): self 122 | { 123 | $this->template = $template; 124 | 125 | return $this; 126 | } 127 | 128 | /** 129 | * Get item detail template 130 | */ 131 | public function getTemplate(): ?string 132 | { 133 | return $this->template; 134 | } 135 | 136 | /** 137 | * @return static 138 | */ 139 | public function setRenderer(callable $renderer): self 140 | { 141 | $this->renderer = $renderer; 142 | 143 | return $this; 144 | } 145 | 146 | public function getRenderer(): ?callable 147 | { 148 | return $this->renderer; 149 | } 150 | 151 | /** 152 | * @return static 153 | */ 154 | public function setForm(ItemDetailForm $form): self 155 | { 156 | $this->form = $form; 157 | 158 | return $this; 159 | } 160 | 161 | public function getForm(): ?ItemDetailForm 162 | { 163 | return $this->form; 164 | } 165 | 166 | /** 167 | * @return static 168 | */ 169 | public function setTemplateParameters(array $templateParameters): self 170 | { 171 | $this->templateParameters = $templateParameters; 172 | 173 | return $this; 174 | } 175 | 176 | public function getTemplateVariables(): array 177 | { 178 | return $this->templateParameters; 179 | } 180 | 181 | } 182 | -------------------------------------------------------------------------------- /src/DataSource/SearchParamsBuilder.php: -------------------------------------------------------------------------------- 1 | phrasePrefixQueries[] = [$field => $query]; 31 | } 32 | 33 | public function addMatchQuery(string $field, mixed $query): void 34 | { 35 | $this->matchQueries[] = [$field => $query]; 36 | } 37 | 38 | public function addBooleanMatchQuery(string $field, array $queries): void 39 | { 40 | $this->booleanMatchQueries[] = [$field => $queries]; 41 | } 42 | 43 | public function addRangeQuery(string $field, ?int $from, ?int $to): void 44 | { 45 | $this->rangeQueries[] = [$field => ['from' => $from, 'to' => $to]]; 46 | } 47 | 48 | public function addIdsQuery(array $ids): void 49 | { 50 | $this->idsQueries[] = $ids; 51 | } 52 | 53 | public function setSort(array $sort): void 54 | { 55 | $this->sort = $sort; 56 | } 57 | 58 | public function setFrom(int $from): void 59 | { 60 | $this->from = $from; 61 | } 62 | 63 | public function setSize(int $size): void 64 | { 65 | $this->size = $size; 66 | } 67 | 68 | public function buildParams(): array 69 | { 70 | $return = [ 71 | 'index' => $this->indexName, 72 | ]; 73 | 74 | if ($this->sort !== [] || ($this->from !== null) || ($this->size !== null)) { 75 | $return['body'] = []; 76 | } 77 | 78 | if ($this->sort !== []) { 79 | $return['body']['sort'] = $this->sort; 80 | } 81 | 82 | if ($this->from !== null) { 83 | $return['body']['from'] = $this->from; 84 | } 85 | 86 | if ($this->size !== null) { 87 | $return['body']['size'] = $this->size; 88 | } 89 | 90 | if ($this->phrasePrefixQueries === [] 91 | && $this->matchQueries === [] 92 | && $this->booleanMatchQueries === [] 93 | && $this->rangeQueries === [] 94 | && $this->idsQueries === []) { 95 | return $return; 96 | } 97 | 98 | $return['body']['query'] = [ 99 | 'bool' => [ 100 | 'must' => [], 101 | ], 102 | ]; 103 | 104 | foreach ($this->phrasePrefixQueries as $phrasePrefixQuery) { 105 | foreach ($phrasePrefixQuery as $field => $query) { 106 | $return['body']['query']['bool']['must'][] = [ 107 | 'multi_match' => [ 108 | 'query' => $query, 109 | 'type' => 'phrase_prefix', 110 | 'fields' => [$field], 111 | ], 112 | ]; 113 | } 114 | } 115 | 116 | foreach ($this->matchQueries as $matchQuery) { 117 | foreach ($matchQuery as $field => $query) { 118 | $return['body']['query']['bool']['must'][] = [ 119 | 'match' => [ 120 | $field => [ 121 | 'query' => $query, 122 | ], 123 | ], 124 | ]; 125 | } 126 | } 127 | 128 | foreach ($this->booleanMatchQueries as $booleanMatchQuery) { 129 | foreach ($booleanMatchQuery as $field => $queries) { 130 | if ($queries === []) { 131 | continue; 132 | } 133 | 134 | $boolFilter = []; 135 | 136 | foreach ($queries as $query) { 137 | $boolFilter[] = [ 138 | 'match' => [ 139 | $field => [ 140 | 'query' => $query, 141 | ], 142 | ], 143 | ]; 144 | } 145 | 146 | $return['body']['query']['bool']['must'][] = [ 147 | 'bool' => [ 148 | 'should' => [$boolFilter], 149 | ], 150 | ]; 151 | } 152 | } 153 | 154 | foreach ($this->rangeQueries as $rangeQuery) { 155 | foreach ($rangeQuery as $field => $range) { 156 | if ($range['from'] === null && $range['to'] === null) { 157 | continue; 158 | } 159 | 160 | $rangeFilter = ['range' => [$field => []]]; 161 | 162 | if ($range['from'] !== null) { 163 | $rangeFilter['range'][$field]['gte'] = $range['from']; 164 | } 165 | 166 | if ($range['to'] !== null) { 167 | $rangeFilter['range'][$field]['lte'] = $range['to']; 168 | } 169 | 170 | $return['body']['query']['bool']['must'][] = $rangeFilter; 171 | } 172 | } 173 | 174 | foreach ($this->idsQueries as $ids) { 175 | $return['body']['query']['bool']['must'][] = [ 176 | 'ids' => [ 177 | 'values' => $ids, 178 | ], 179 | ]; 180 | } 181 | 182 | return $return; 183 | } 184 | 185 | } 186 | -------------------------------------------------------------------------------- /assets/plugins/features/confirm.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Datagrid plugin that asks for confirmation before deleting. 3 | * If there is a modal in the DOM, use it, otherwise use a native confirm window. 4 | */ 5 | import { Datagrid } from "../../datagrid"; 6 | import { DatagridPlugin } from "../../types"; 7 | 8 | interface NajaInteractDetail { 9 | method: string; 10 | url: string; 11 | payload: any; 12 | options: Record; 13 | } 14 | 15 | export const ConfirmAttribute = "data-datagrid-confirm"; 16 | 17 | export class ConfirmPlugin implements DatagridPlugin { 18 | private datagrid!: Datagrid; 19 | 20 | private modalId = 'datagridConfirmModal'; 21 | private messageBoxId = 'datagridConfirmMessage'; 22 | private confirmButtonId = 'datagridConfirmOk'; 23 | 24 | /** 25 | * Initializes the plugin and registers event handlers. 26 | * @param datagrid The datagrid instance that the plugin is connected to. 27 | * @returns true if initialization was successful. 28 | */ 29 | 30 | onDatagridInit(datagrid: Datagrid): boolean { 31 | this.datagrid = datagrid; 32 | 33 | const confirmElements = datagrid.el.querySelectorAll(`[${ConfirmAttribute}]:not(.ajax)`); 34 | confirmElements.forEach(el => el.addEventListener("click", e => this.handleClick(el, e))); 35 | 36 | datagrid.ajax.addEventListener("interact", e => { 37 | const target = e.detail.element; 38 | if (datagrid.el.contains(target)) { 39 | this.handleClick(target, e); 40 | } 41 | }); 42 | 43 | return true; 44 | } 45 | 46 | private handleClick(el: HTMLElement, e: Event): void { 47 | const message = this.getConfirmationMessage(el); 48 | if (!message) return; 49 | 50 | e.preventDefault(); 51 | e.stopPropagation(); 52 | 53 | const modal = this.getElement(this.modalId); 54 | if (modal) { 55 | this.showModalConfirm(modal, message, el, e); 56 | } else { 57 | if (window.confirm(message)) { 58 | this.executeConfirmedAction(el, e); 59 | } 60 | } 61 | } 62 | 63 | private getConfirmationMessage(el: HTMLElement): string | null { 64 | return el.getAttribute(ConfirmAttribute) ?? el.closest('a')?.getAttribute(ConfirmAttribute) ?? null; 65 | } 66 | 67 | private showModalConfirm(modal: HTMLElement, message: string, el: HTMLElement, e: Event): void { 68 | if (typeof bootstrap === 'undefined') { 69 | if (window.confirm(message)) { 70 | this.executeConfirmedAction(el, e); 71 | } 72 | return; 73 | } 74 | 75 | const messageBox = this.getElement(this.messageBoxId); 76 | const confirmButton = this.getElement(this.confirmButtonId); 77 | 78 | if (!messageBox || !confirmButton) { 79 | if (window.confirm(message)) { 80 | this.executeConfirmedAction(el, e); 81 | } 82 | return; 83 | } 84 | 85 | messageBox.textContent = message; 86 | 87 | const newButton = confirmButton.cloneNode(true) as HTMLElement; 88 | confirmButton.parentNode!.replaceChild(newButton, confirmButton); 89 | 90 | newButton.addEventListener("click", () => { 91 | bootstrap.Modal.getInstance(modal)?.hide(); 92 | this.executeConfirmedAction(el, e); 93 | }, { once: true }); 94 | 95 | const modalInstance = bootstrap.Modal.getInstance(modal) || new bootstrap.Modal(modal); 96 | modalInstance.show(); 97 | } 98 | 99 | private executeConfirmedAction(el: HTMLElement, e?: Event): void { 100 | //const detail: NajaInteractDetail | null = (e instanceof CustomEvent ? (e.detail as NajaInteractDetail) : null); 101 | const detail: NajaInteractDetail | null = ( 102 | e instanceof CustomEvent && 103 | e.detail && 104 | typeof e.detail === 'object' 105 | ) ? e.detail as NajaInteractDetail : null; 106 | const isAjax = el.classList.contains('ajax'); 107 | 108 | if (el instanceof HTMLAnchorElement && el.href && isAjax) { 109 | if (typeof naja === 'undefined') { 110 | return; 111 | } 112 | 113 | if (detail && typeof detail.method === 'string' && typeof detail.url === 'string') { 114 | const options = { ...detail.options, history: false }; 115 | naja.makeRequest(detail.method, detail.url, detail.payload ?? null, options); 116 | } else { 117 | const method = el.getAttribute('data-naja-method') ?? 'GET'; 118 | naja.makeRequest(method, el.href, null, { history: false }); 119 | } 120 | return; 121 | } 122 | 123 | this.triggerNativeInteraction(el); 124 | } 125 | 126 | private getElement(id: string): HTMLElement | null { 127 | return document.getElementById(id); 128 | } 129 | 130 | private triggerNativeInteraction(el: HTMLElement): void { 131 | const confirmValue = el.getAttribute(ConfirmAttribute); 132 | 133 | if (confirmValue !== null) { 134 | el.removeAttribute(ConfirmAttribute); 135 | } 136 | 137 | try { 138 | el.click(); 139 | } finally { 140 | if (confirmValue !== null) { 141 | el.setAttribute(ConfirmAttribute, confirmValue); 142 | } 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /assets/plugins/features/editable.ts: -------------------------------------------------------------------------------- 1 | import { DatagridPlugin } from "../../types"; 2 | import { calculateCellLines, isEnter, isEsc } from "../../utils"; 3 | import { Datagrid } from "../.."; 4 | 5 | export const EditableUrlAttribute = "data-datagrid-editable-url"; 6 | 7 | export const EditableTypeAttribute = "data-datagrid-editable-type"; 8 | 9 | export const EditableElementAttribute = "data-datagrid-editable-element"; 10 | 11 | export const EditableValueAttribute = "data-datagrid-editable-value"; 12 | 13 | export const EditableAttrsAttribute = "data-datagrid-editable-attrs"; 14 | 15 | export class EditablePlugin implements DatagridPlugin { 16 | onDatagridInit(datagrid: Datagrid): boolean { 17 | datagrid.ajax.addEventListener('success', (event) => { 18 | this.initEditableCells(datagrid); 19 | }); 20 | 21 | this.initEditableCells(datagrid); 22 | 23 | return true; 24 | } 25 | 26 | initEditableCells(datagrid: Datagrid) { 27 | 28 | datagrid.el.querySelectorAll(`[${EditableUrlAttribute}]`).forEach(cell => { 29 | 30 | cell.addEventListener("click", e => { 31 | 32 | if (cell instanceof HTMLAnchorElement || cell.classList.contains("datagrid-inline-edit")) return; 33 | 34 | if (!cell.classList.contains("editing")) { 35 | cell.classList.add("editing"); 36 | const originalValue = (cell.innerHTML.replace(/<\/?br>/g, "\n")).trim(); 37 | const valueToEdit = (cell.getAttribute(EditableValueAttribute) ?? originalValue).trim(); 38 | 39 | cell.setAttribute("originalValue", originalValue); 40 | cell.setAttribute("valueToEdit", valueToEdit); 41 | 42 | const type = cell.getAttribute(EditableTypeAttribute) ?? "text"; 43 | 44 | let input: HTMLElement; 45 | 46 | switch (type) { 47 | case "textarea": 48 | cell.innerHTML = ``; 49 | input = cell.querySelector("textarea")!; 50 | break; 51 | case "select": 52 | cell.innerHTML = cell.getAttribute(EditableElementAttribute) ?? ""; 53 | input = cell.querySelector("select")!; 54 | input 55 | .querySelectorAll(`option[value='${valueToEdit}']`) 56 | .forEach(input => input.setAttribute("selected", "true")); 57 | break; 58 | default: 59 | cell.innerHTML = ``; 60 | input = cell.querySelector("input")!; 61 | input.setAttribute("value", valueToEdit); 62 | } 63 | 64 | input.focus(); 65 | 66 | const attributes = JSON.parse(cell.getAttribute(EditableAttrsAttribute) ?? "{}"); 67 | for (const key in attributes) { 68 | const value = attributes[key]; 69 | input.setAttribute(key, value); 70 | } 71 | 72 | cell.classList.remove("edited"); 73 | 74 | const submitCell = async (el: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) => { 75 | let value = el.value; 76 | if (value !== valueToEdit) { 77 | try { 78 | const response = await datagrid.ajax.request({ 79 | url: cell.getAttribute(EditableUrlAttribute) ?? "", 80 | method: "POST", 81 | data: { 82 | value, 83 | }, 84 | }) as any; 85 | 86 | if (type === "select") { 87 | cell.innerHTML = (el instanceof HTMLSelectElement) 88 | ? el.options[el.selectedIndex]?.text ?? value 89 | : value; 90 | } else { 91 | if (response._datagrid_editable_new_value) { 92 | value = response._datagrid_editable_new_value; 93 | } 94 | cell.innerHTML = value; 95 | } 96 | cell.classList.add("edited"); 97 | } catch { 98 | cell.innerHTML = originalValue; 99 | cell.classList.add("edited-error"); 100 | } 101 | } else { 102 | cell.innerHTML = originalValue; 103 | } 104 | 105 | cell.classList.remove("editing"); 106 | }; 107 | 108 | cell 109 | .querySelectorAll( 110 | "input, textarea, select" 111 | ) 112 | .forEach(el => { 113 | if (!(el instanceof HTMLSelectElement)) { 114 | el.addEventListener("blur", () => submitCell(el)); 115 | } 116 | 117 | el.addEventListener("keydown", e => { 118 | if (isEnter(e as KeyboardEvent)) { 119 | e.stopPropagation(); 120 | e.preventDefault(); 121 | return submitCell(el); 122 | } 123 | 124 | if (isEsc(e as KeyboardEvent)) { 125 | e.stopPropagation(); 126 | e.preventDefault(); 127 | cell.classList.remove("editing"); 128 | cell.innerHTML = originalValue; 129 | } 130 | }); 131 | 132 | if (el instanceof HTMLSelectElement) { 133 | el.addEventListener("change", () => submitCell(el)); 134 | } 135 | }); 136 | } 137 | }); 138 | }); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/DataModel.php: -------------------------------------------------------------------------------- 1 | getConnection()->getDriver(); 60 | 61 | if ($driver instanceof OdbcDriver) { 62 | $source = new DibiFluentMssqlDataSource($source, $primaryKey); 63 | 64 | } elseif ($driver instanceof MsSqlDriver) { 65 | $source = new DibiFluentMssqlDataSource($source, $primaryKey); 66 | 67 | } elseif ($driver instanceof PostgreDriver) { 68 | $source = new DibiFluentPostgreDataSource($source, $primaryKey); 69 | 70 | } elseif ($driver instanceof SqlsrvDriver) { 71 | $source = new DibiFluentMssqlDataSource($source, $primaryKey); 72 | 73 | } else { 74 | $source = new DibiFluentDataSource($source, $primaryKey); 75 | } 76 | } elseif ($source instanceof Selection) { 77 | $driver = NetteDatabaseSelectionHelper::getDriver($source); 78 | 79 | $source = $driver instanceof NDBMsSqlDriver || $driver instanceof NDBSqlsrvDriver ? new NetteDatabaseTableMssqlDataSource($source, $primaryKey) : new NetteDatabaseTableDataSource($source, $primaryKey); 80 | } elseif ($source instanceof QueryBuilder) { 81 | $source = new DoctrineDataSource($source, $primaryKey); 82 | 83 | } elseif ($source instanceof Collection) { 84 | $source = new DoctrineCollectionDataSource($source, $primaryKey); 85 | 86 | } elseif ($source instanceof ICollection) { 87 | $source = new NextrasDataSource($source, $primaryKey); 88 | 89 | } elseif (!($source instanceof IDataSource)) { 90 | throw new DatagridWrongDataSourceException(sprintf( 91 | 'Datagrid can not take [%s] as data source.', 92 | is_object($source) ? $source::class : 'null' 93 | )); 94 | } 95 | 96 | $this->dataSource = $source; 97 | } 98 | 99 | public function getDataSource(): IDataSource 100 | { 101 | return $this->dataSource; 102 | } 103 | 104 | public function filterData( 105 | ?DatagridPaginator $paginatorComponent, 106 | Sorting $sorting, 107 | array $filters 108 | ): iterable 109 | { 110 | $this->onBeforeFilter($this->dataSource); 111 | 112 | $this->dataSource->filter($filters); 113 | 114 | $this->onAfterFilter($this->dataSource); 115 | 116 | /** 117 | * Paginator is optional 118 | */ 119 | if ($paginatorComponent !== null) { 120 | $paginator = $paginatorComponent->getPaginator(); 121 | $paginator->setItemCount($this->dataSource->getCount()); 122 | 123 | $this->dataSource->sort($sorting)->limit( 124 | $paginator->getOffset(), 125 | $paginator->getItemsPerPage() 126 | ); 127 | 128 | $this->onAfterPaginated($this->dataSource); 129 | 130 | return $this->dataSource->getData(); 131 | } 132 | 133 | return $this->dataSource->sort($sorting)->getData(); 134 | } 135 | 136 | public function filterRow(array $condition): mixed 137 | { 138 | $this->onBeforeFilter($this->dataSource); 139 | $this->onAfterFilter($this->dataSource); 140 | 141 | return $this->dataSource->filterOne($condition)->getData(); 142 | } 143 | 144 | } 145 | -------------------------------------------------------------------------------- /assets/ajax/naja.ts: -------------------------------------------------------------------------------- 1 | import type { Naja } from "naja"; 2 | import type { 3 | Ajax, 4 | AjaxEventMap as BaseAjaxEventMap, 5 | BaseRequestParams as AjaxBaseRequestParams, 6 | BeforeEventDetail as BaseBeforeEventDetail, 7 | DatagridPayload, 8 | ErrorEventDetail as BaseErrorEventDetail, 9 | EventDetail, 10 | EventListener, 11 | InteractEventDetail as BaseInteractEventDetail, 12 | Payload, 13 | RequestParams, 14 | Response as AjaxResponse, 15 | SuccessEventDetail as BaseSuccessEventDetail, 16 | } from "../types"; 17 | import { Datagrid } from "../datagrid"; 18 | import { BeforeEvent, ErrorEvent, Payload as NajaPayload, SuccessEvent, InteractionEvent } from "naja"; 19 | 20 | export interface BaseRequestParams extends AjaxBaseRequestParams, Request { 21 | url: string; 22 | method: string; 23 | } 24 | 25 | export interface BeforeEventDetail extends BaseBeforeEventDetail { 26 | params: EventDetail & RequestParams; 27 | } 28 | 29 | export interface InteractEventDetail< 30 | E extends HTMLElement = HTMLElement 31 | > extends BaseInteractEventDetail, EventDetail { 32 | element: E; 33 | } 34 | 35 | export interface SuccessEventDetail< 36 | P = DatagridPayload 37 | > extends BaseSuccessEventDetail, EventDetail { 38 | params: BaseRequestParams; 39 | payload: Payload

& NajaPayload; 40 | response: AjaxResponse & Response; 41 | } 42 | 43 | export interface ErrorEventDetail< 44 | E extends Error = Error, 45 | > extends BaseErrorEventDetail, EventDetail { 46 | params: BaseRequestParams; 47 | response: (AjaxResponse & Response) | undefined; 48 | error: E; 49 | } 50 | 51 | export interface AjaxEventMap extends BaseAjaxEventMap { 52 | before: CustomEvent; 53 | interact: CustomEvent; 54 | snippetUpdate: CustomEvent; 55 | success: CustomEvent; 56 | error: CustomEvent; 57 | } 58 | 59 | export class NajaAjax extends EventTarget implements Ajax { 60 | constructor(public client: C) { 61 | if (!client.VERSION || client.VERSION < 2) { 62 | throw new Error("NajaAjax supports Naja 2 and higher" + (client.VERSION ? `(version ${client.VERSION} provided)` : '')) 63 | } 64 | super(); 65 | } 66 | 67 | onInit() { 68 | this.client.addEventListener('before', (e) => { 69 | return this.dispatch('before', { 70 | params: e.detail 71 | }); 72 | }) 73 | 74 | this.client.uiHandler.addEventListener('interaction', (e) => { 75 | if (!(e.detail.element instanceof HTMLElement)) { 76 | throw new Error("Element is not an instanceof HTMLElement"); 77 | } 78 | 79 | const dispatchResult = this.dispatch('interact', { 80 | ...e.detail, 81 | element: e.detail.element as HTMLElement // Naja's event has a type of HTMLElement 82 | }); 83 | 84 | if (!dispatchResult) { 85 | e.stopPropagation(); 86 | e.preventDefault(); 87 | } 88 | 89 | return dispatchResult; 90 | }) 91 | 92 | 93 | this.client.addEventListener('success', (e) => { 94 | return this.dispatch('success', { 95 | ...e.detail, 96 | params: e.detail.request, 97 | payload: e.detail.payload as Payload 98 | }); 99 | }) 100 | 101 | this.client.addEventListener('error', (e) => { 102 | return this.dispatch('error', { 103 | ...e.detail, 104 | params: e.detail.request, 105 | response: e.detail.response, 106 | }); 107 | }) 108 | 109 | this.client.addEventListener('complete', (e) => { 110 | return this.dispatch('complete', { 111 | ...e.detail, 112 | params: e.detail.request, 113 | response: e.detail.response, 114 | }); 115 | }) 116 | 117 | return this; 118 | } 119 | 120 | async request(args: RequestParams): Promise

{ 121 | return await this.client.makeRequest(args.method, args.url, args.data) as P; 122 | } 123 | 124 | async submitForm(element: E): Promise

{ 125 | return await this.client.uiHandler.submitForm(element) as P; 126 | } 127 | 128 | dispatch< 129 | K extends string, M extends BaseAjaxEventMap = AjaxEventMap 130 | >(type: K, detail: K extends keyof M ? EventDetail : any, options?: boolean): boolean { 131 | return this.dispatchEvent(new CustomEvent(type, { 132 | detail: detail, 133 | cancelable: true, 134 | })); 135 | 136 | } 137 | 138 | declare addEventListener: ( 139 | type: K, 140 | listener: EventListener, 141 | options?: boolean | AddEventListenerOptions 142 | ) => void; 143 | 144 | declare removeEventListener: ( 145 | type: K, 146 | listener: EventListener, 147 | options?: boolean | AddEventListenerOptions 148 | ) => void; 149 | 150 | declare dispatchEvent: ( 151 | event: K extends keyof M ? M[K] : CustomEvent 152 | ) => boolean; 153 | } 154 | -------------------------------------------------------------------------------- /src/AggregationFunction/TDatagridAggregationFunction.php: -------------------------------------------------------------------------------- 1 | hasColumnsSummary()) { 27 | throw new DatagridException('You can use either ColumnsSummary or AggregationFunctions'); 28 | } 29 | 30 | if (!$this->dataModel instanceof DataModel) { 31 | throw new DatagridException('You have to set a data source first.'); 32 | } 33 | 34 | if (isset($this->aggregationFunctions[$key])) { 35 | throw new DatagridException('There is already a AggregationFunction defined on column ' . $key); 36 | } 37 | 38 | if ($this->multipleAggregationFunction instanceof IMultipleAggregationFunction) { 39 | throw new DatagridException('You can not use both AggregationFunctions and MultipleAggregationFunction'); 40 | } 41 | 42 | $this->aggregationFunctions[$key] = $aggregationFunction; 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * @return static 49 | * @throws DatagridException 50 | */ 51 | public function setMultipleAggregationFunction( 52 | IMultipleAggregationFunction $multipleAggregationFunction 53 | ): self 54 | { 55 | if ($this->hasColumnsSummary()) { 56 | throw new DatagridException('You can use either ColumnsSummary or AggregationFunctions'); 57 | } 58 | 59 | if ($this->aggregationFunctions !== []) { 60 | throw new DatagridException('You can not use both AggregationFunctions and MultipleAggregationFunction'); 61 | } 62 | 63 | $this->multipleAggregationFunction = $multipleAggregationFunction; 64 | 65 | return $this; 66 | } 67 | 68 | /** 69 | * @throws DatagridException 70 | */ 71 | public function beforeDataModelFilter(IDataSource $dataSource): void 72 | { 73 | if (!$this->hasSomeAggregationFunction()) { 74 | return; 75 | } 76 | 77 | if (!$dataSource instanceof IAggregatable) { 78 | throw new DatagridException('Used DataSource has to implement IAggregatable for aggegations to work'); 79 | } 80 | 81 | if ($this->multipleAggregationFunction !== null) { 82 | $type = $this->multipleAggregationFunction->getFilterDataType(); 83 | 84 | if ($type === IAggregationFunction::DATA_TYPE_ALL) { 85 | $dataSource->processAggregation($this->multipleAggregationFunction); 86 | } 87 | 88 | return; 89 | } 90 | 91 | foreach ($this->aggregationFunctions as $aggregationFunction) { 92 | if ($aggregationFunction->getFilterDataType() === IAggregationFunction::DATA_TYPE_ALL) { 93 | $dataSource->processAggregation($aggregationFunction); 94 | } 95 | } 96 | } 97 | 98 | /** 99 | * @throws DatagridException 100 | */ 101 | public function afterDataModelFilter(IDataSource $dataSource): void 102 | { 103 | if (!$this->hasSomeAggregationFunction()) { 104 | return; 105 | } 106 | 107 | if (!$dataSource instanceof IAggregatable) { 108 | throw new DatagridException('Used DataSource has to implement IAggregatable for aggegations to work'); 109 | } 110 | 111 | if ($this->multipleAggregationFunction !== null) { 112 | if ($this->multipleAggregationFunction->getFilterDataType() === IAggregationFunction::DATA_TYPE_FILTERED) { 113 | $dataSource->processAggregation($this->multipleAggregationFunction); 114 | } 115 | 116 | return; 117 | } 118 | 119 | foreach ($this->aggregationFunctions as $aggregationFunction) { 120 | if ($aggregationFunction->getFilterDataType() === IAggregationFunction::DATA_TYPE_FILTERED) { 121 | $dataSource->processAggregation($aggregationFunction); 122 | } 123 | } 124 | } 125 | 126 | /** 127 | * @throws DatagridException 128 | */ 129 | public function afterDataModelPaginated(IDataSource $dataSource): void 130 | { 131 | if (!$this->hasSomeAggregationFunction()) { 132 | return; 133 | } 134 | 135 | if (!$dataSource instanceof IAggregatable) { 136 | throw new DatagridException('Used DataSource has to implement IAggregatable for aggegations to work'); 137 | } 138 | 139 | if ($this->multipleAggregationFunction !== null) { 140 | if ($this->multipleAggregationFunction->getFilterDataType() === IAggregationFunction::DATA_TYPE_PAGINATED) { 141 | $dataSource->processAggregation($this->multipleAggregationFunction); 142 | } 143 | 144 | return; 145 | } 146 | 147 | foreach ($this->aggregationFunctions as $aggregationFunction) { 148 | if ($aggregationFunction->getFilterDataType() === IAggregationFunction::DATA_TYPE_PAGINATED) { 149 | $dataSource->processAggregation($aggregationFunction); 150 | } 151 | } 152 | } 153 | 154 | public function hasSomeAggregationFunction(): bool 155 | { 156 | return $this->aggregationFunctions !== [] || $this->multipleAggregationFunction !== null; 157 | } 158 | 159 | /** 160 | * @return array 161 | */ 162 | public function getAggregationFunctions(): array 163 | { 164 | return $this->aggregationFunctions; 165 | } 166 | 167 | public function getMultipleAggregationFunction(): ?IMultipleAggregationFunction 168 | { 169 | return $this->multipleAggregationFunction; 170 | } 171 | 172 | } 173 | -------------------------------------------------------------------------------- /src/InlineEdit/InlineEdit.php: -------------------------------------------------------------------------------- 1 | */ 62 | protected array $dataAttributes = []; 63 | 64 | public function __construct(protected Datagrid $grid, protected ?string $primaryWhereColumn = null) 65 | { 66 | $this->title = 'contributte_datagrid.edit'; 67 | $this->class = sprintf('btn btn-xs %s ajax', $grid::$btnSecondaryClass); 68 | $this->icon = 'pencil pencil-alt'; 69 | 70 | $this->onControlAfterAdd[] = [$this, 'addControlsClasses']; 71 | } 72 | 73 | /** 74 | * @return static 75 | */ 76 | public function setItemId(mixed $id): self 77 | { 78 | $this->itemID = $id; 79 | 80 | return $this; 81 | } 82 | 83 | public function getItemId(): mixed 84 | { 85 | return $this->itemID; 86 | } 87 | 88 | public function getPrimaryWhereColumn(): ?string 89 | { 90 | return $this->primaryWhereColumn; 91 | } 92 | 93 | public function renderButton(Row $row): Html 94 | { 95 | $a = Html::el('a') 96 | ->href($this->grid->link('inlineEdit!', ['id' => $row->getId()])); 97 | 98 | $this->tryAddIcon($a, $this->getIcon(), $this->getText()); 99 | 100 | if ($this->dataAttributes !== []) { 101 | foreach ($this->dataAttributes as $key => $value) { 102 | $a->data($key, $value); 103 | } 104 | } 105 | 106 | $a->addText($this->text); 107 | 108 | if ($this->title !== null) { 109 | $a->setAttribute( 110 | 'title', 111 | $this->grid->getTranslator()->translate($this->title) 112 | ); 113 | } 114 | 115 | if ($this->class !== '') { 116 | $a->appendAttribute('class', $this->class); 117 | } 118 | 119 | $a->appendAttribute('class', 'datagrid-inline-edit-trigger'); 120 | 121 | return $a; 122 | } 123 | 124 | /** 125 | * Render row item detail button 126 | */ 127 | public function renderButtonAdd(): Html 128 | { 129 | $a = Html::el('a') 130 | ->href($this->grid->link('showInlineAdd!')); 131 | 132 | $this->tryAddIcon($a, $this->getIcon(), $this->getText()); 133 | 134 | if ($this->dataAttributes !== []) { 135 | foreach ($this->dataAttributes as $key => $value) { 136 | $a->data($key, $value); 137 | } 138 | } 139 | 140 | $a->addText($this->text); 141 | 142 | if ($this->title !== null) { 143 | $a->setAttribute( 144 | 'title', 145 | $this->grid->getTranslator()->translate($this->title) 146 | ); 147 | } 148 | 149 | if ($this->class !== '') { 150 | $a->appendAttribute('class', $this->class); 151 | } 152 | 153 | return $a; 154 | } 155 | 156 | /** 157 | * @return static 158 | */ 159 | public function setPositionTop(bool $positionTop = true): self 160 | { 161 | $this->positionTop = $positionTop; 162 | 163 | return $this; 164 | } 165 | 166 | public function isPositionTop(): bool 167 | { 168 | return $this->positionTop; 169 | } 170 | 171 | public function isPositionBottom(): bool 172 | { 173 | return !$this->positionTop; 174 | } 175 | 176 | public function addControlsClasses(Container $container): void 177 | { 178 | foreach ($container->getControls() as $key => $control) { 179 | switch ($key) { 180 | case 'submit': 181 | $control->setValidationScope([$container]); 182 | $control->setAttribute('class', 'btn btn-xs btn-primary'); 183 | 184 | break; 185 | 186 | case 'cancel': 187 | $control->setValidationScope([]); 188 | $control->setAttribute('class', 'btn btn-xs btn-danger'); 189 | 190 | break; 191 | 192 | default: 193 | if ($control->getControlPrototype()->getAttribute('class') === null) { 194 | $control->setAttribute('class', 'form-control form-control-sm'); 195 | } 196 | 197 | break; 198 | } 199 | } 200 | } 201 | 202 | /** 203 | * @return static 204 | */ 205 | public function setShowNonEditingColumns(bool $show = true): self 206 | { 207 | $this->showNonEditingColumns = $show; 208 | 209 | return $this; 210 | } 211 | 212 | public function showNonEditingColumns(): bool 213 | { 214 | return $this->showNonEditingColumns; 215 | } 216 | 217 | /** 218 | * @return static 219 | */ 220 | public function setDataAttribute(string $key, mixed $value): self 221 | { 222 | $this->dataAttributes[$key] = $value; 223 | 224 | return $this; 225 | } 226 | 227 | } 228 | -------------------------------------------------------------------------------- /src/DataSource/ElasticsearchDataSource.php: -------------------------------------------------------------------------------- 1 | searchParamsBuilder = new SearchParamsBuilder($indexName); 29 | 30 | if ($rowFactory === null) { 31 | $rowFactory = static fn (array $hit): array => $hit['_source']; 32 | } 33 | 34 | $this->rowFactory = $rowFactory; 35 | } 36 | 37 | public function getCount(): int 38 | { 39 | /** @var Elasticsearch $searchResult */ 40 | $searchResult = $this->client->search($this->searchParamsBuilder->buildParams()); 41 | 42 | if (!isset($searchResult['hits'])) { 43 | throw new UnexpectedValueException(); 44 | } 45 | 46 | return is_array($searchResult['hits']['total']) 47 | ? $searchResult['hits']['total']['value'] 48 | : $searchResult['hits']['total']; 49 | } 50 | 51 | /** 52 | * {@inheritDoc} 53 | */ 54 | public function getData(): array 55 | { 56 | /** @var Elasticsearch $searchResult */ 57 | $searchResult = $this->client->search($this->searchParamsBuilder->buildParams()); 58 | 59 | if (!isset($searchResult['hits'])) { 60 | throw new UnexpectedValueException(); 61 | } 62 | 63 | return array_map($this->rowFactory, $searchResult['hits']['hits']); 64 | } 65 | 66 | /** 67 | * {@inheritDoc} 68 | */ 69 | public function filterOne(array $condition): IDataSource 70 | { 71 | foreach ($condition as $value) { 72 | $this->searchParamsBuilder->addIdsQuery($value); 73 | } 74 | 75 | return $this; 76 | } 77 | 78 | public function limit(int $offset, int $limit): IDataSource 79 | { 80 | $this->searchParamsBuilder->setFrom($offset); 81 | $this->searchParamsBuilder->setSize($limit); 82 | 83 | return $this; 84 | } 85 | 86 | public function applyFilterDate(FilterDate $filter): void 87 | { 88 | foreach ($filter->getCondition() as $column => $value) { 89 | $timestampFrom = null; 90 | $timestampTo = null; 91 | 92 | if ($value) { 93 | $dateFrom = DateTimeHelper::tryConvertToDateTime($value, [$filter->getPhpFormat()]); 94 | $dateFrom->setTime(0, 0, 0); 95 | 96 | $timestampFrom = $dateFrom->getTimestamp(); 97 | 98 | $dateTo = DateTimeHelper::tryConvertToDateTime($value, [$filter->getPhpFormat()]); 99 | $dateTo->setTime(23, 59, 59); 100 | 101 | $timestampTo = $dateTo->getTimestamp(); 102 | 103 | $this->searchParamsBuilder->addRangeQuery($column, $timestampFrom, $timestampTo); 104 | } 105 | } 106 | } 107 | 108 | public function applyFilterDateRange(FilterDateRange $filter): void 109 | { 110 | foreach ($filter->getCondition() as $column => $values) { 111 | $timestampFrom = null; 112 | $timestampTo = null; 113 | 114 | if ($values['from']) { 115 | $dateFrom = DateTimeHelper::tryConvertToDateTime($values['from'], [$filter->getPhpFormat()]); 116 | $dateFrom->setTime(0, 0, 0); 117 | 118 | $timestampFrom = $dateFrom->getTimestamp(); 119 | } 120 | 121 | if ($values['to']) { 122 | $dateTo = DateTimeHelper::tryConvertToDateTime($values['to'], [$filter->getPhpFormat()]); 123 | $dateTo->setTime(23, 59, 59); 124 | 125 | $timestampTo = $dateTo->getTimestamp(); 126 | } 127 | 128 | if (is_int($timestampFrom) || is_int($timestampTo)) { 129 | $this->searchParamsBuilder->addRangeQuery($column, $timestampFrom, $timestampTo); 130 | } 131 | } 132 | } 133 | 134 | public function applyFilterRange(FilterRange $filter): void 135 | { 136 | foreach ($filter->getCondition() as $column => $value) { 137 | $this->searchParamsBuilder->addRangeQuery($column, $value['from'] ?? null, $value['to'] ?? null); 138 | } 139 | } 140 | 141 | public function applyFilterText(FilterText $filter): void 142 | { 143 | foreach ($filter->getCondition() as $column => $value) { 144 | if ($filter->isExactSearch()) { 145 | $this->searchParamsBuilder->addMatchQuery($column, $value); 146 | } else { 147 | $this->searchParamsBuilder->addPhrasePrefixQuery($column, $value); 148 | } 149 | } 150 | } 151 | 152 | public function applyFilterMultiSelect(FilterMultiSelect $filter): void 153 | { 154 | foreach ($filter->getCondition() as $column => $values) { 155 | $this->searchParamsBuilder->addBooleanMatchQuery($column, $values); 156 | } 157 | } 158 | 159 | public function applyFilterSelect(FilterSelect $filter): void 160 | { 161 | foreach ($filter->getCondition() as $column => $value) { 162 | $this->searchParamsBuilder->addMatchQuery($column, $value); 163 | } 164 | } 165 | 166 | /** 167 | * {@inheritDoc} 168 | * 169 | * @throws RuntimeException 170 | */ 171 | public function sort(Sorting $sorting): IDataSource 172 | { 173 | if (is_callable($sorting->getSortCallback())) { 174 | throw new RuntimeException('No can do - not implemented yet'); 175 | } 176 | 177 | foreach ($sorting->getSort() as $column => $order) { 178 | $this->searchParamsBuilder->setSort( 179 | [$column => ['order' => strtolower($order)]] 180 | ); 181 | } 182 | 183 | return $this; 184 | } 185 | 186 | public function getDataSource(): SearchParamsBuilder 187 | { 188 | return $this->searchParamsBuilder; 189 | } 190 | 191 | } 192 | -------------------------------------------------------------------------------- /assets/utils.ts: -------------------------------------------------------------------------------- 1 | import { Datagrid } from "./datagrid"; 2 | import { ExtendedWindow } from "./types"; 3 | 4 | export function isPromise(p: any): p is Promise { 5 | return typeof p === "object" && typeof p.then === "function"; 6 | } 7 | 8 | export function isInKeyRange(e: KeyboardEvent, min: number, max: number): boolean { 9 | const code = e.key.length === 1 ? e.key.charCodeAt(0) : 0; 10 | return code >= min && code <= max; 11 | } 12 | 13 | export function isEnter(e: KeyboardEvent): boolean { 14 | return e.key === "Enter"; 15 | } 16 | 17 | export function isEsc(e: KeyboardEvent): boolean { 18 | return e.key === "Escape"; 19 | } 20 | 21 | export function isFunctionKey(e: KeyboardEvent): boolean { 22 | return e.key.length === 2 && e.key.startsWith("F"); 23 | } 24 | 25 | export function window(): ExtendedWindow { 26 | return (window ?? {}) as unknown as ExtendedWindow; 27 | } 28 | 29 | export function slideDown(element: HTMLElement, cb?: (nextStateShown: boolean) => unknown) { 30 | element.style.height = 'auto'; 31 | 32 | let height = element.clientHeight + "px"; 33 | 34 | element.style.height = '0px'; 35 | 36 | setTimeout(function () { 37 | element.style.height = height; 38 | cb?.(true); 39 | }, 0); 40 | } 41 | 42 | export function slideUp(element: HTMLElement, cb?: (nextStateShown: boolean) => unknown) { 43 | element.style.height = '0px'; 44 | 45 | setTimeout(() => { 46 | cb?.(false); 47 | }, 250); // TODO 48 | } 49 | 50 | export function slideToggle(element: HTMLElement, isVisible: boolean, cb?: (nextStateShown: boolean) => unknown) { 51 | if (!isVisible) { 52 | slideDown(element, cb); 53 | } else { 54 | slideUp(element, cb); 55 | } 56 | } 57 | 58 | export function attachSlideToggle(element: HTMLElement, control: HTMLElement, cb?: (nextStateShown: boolean) => unknown) { 59 | if (!control.classList.contains("datagrid--slide-toggle")) { 60 | let sliding = false; 61 | control.classList.add("datagrid--slide-toggle"); 62 | 63 | slideDown(element, cb); 64 | 65 | control.addEventListener('click', () => { 66 | if (sliding) return; 67 | sliding = true; 68 | slideToggle(element, control.classList.contains('is-active'), (active) => { 69 | sliding = false 70 | if (active) { 71 | control.classList.add("is-active"); 72 | } else { 73 | control.classList.remove("is-active"); 74 | } 75 | }); 76 | }); 77 | } 78 | } 79 | 80 | export function qs(params: Record, prefix: string = ""): string { 81 | const encodedParams = []; 82 | 83 | for (const _key in params) { 84 | const value = params[_key]; 85 | // Cannot do !value as that would also exclude valid negative values such as 0 or false 86 | if (value === null || value === undefined) continue; 87 | 88 | const key = prefix ? `${prefix}[${_key}]` : _key; 89 | 90 | // Skip empty strings 91 | if (typeof value === "string" && value.trim().length < 1) continue; 92 | 93 | if (typeof value === "object") { 94 | const nestedParams = qs(value, key); 95 | // Don't include if object is empty 96 | if (nestedParams.length >= 1) { 97 | encodedParams.push(nestedParams); 98 | } 99 | 100 | continue; 101 | } 102 | 103 | encodedParams.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`) 104 | } 105 | 106 | return encodedParams.join("&").replace(/&+$/gm, "").replace(/&*$/, ""); 107 | } 108 | 109 | export function calculateCellLines(el: HTMLElement) { 110 | const cellPadding = el.style.padding ? parseInt(el.style.padding.replace(/[^-\d\.]/g, ""), 10) : 0; 111 | const cellHeight = el.getBoundingClientRect().height; 112 | const lineHeight = Math.round(parseFloat(el.style.lineHeight ?? "0")); 113 | const cellLines = Math.round((cellHeight - 2 * cellPadding) / lineHeight); 114 | 115 | return cellLines; 116 | } 117 | 118 | // A little better debounce ;) 119 | export function debounce unknown | Promise>( 120 | fn: TFun, 121 | slowdown: number = 200 122 | ): (...args: TArgs[]) => void { 123 | let timeout: ReturnType | null = null; 124 | let blockedByPromise: boolean = false; 125 | 126 | return (...args) => { 127 | if (blockedByPromise) return; 128 | 129 | timeout && clearTimeout(timeout); 130 | timeout = setTimeout(() => { 131 | const result = fn(...args); 132 | 133 | if (isPromise(result)) { 134 | blockedByPromise = true; 135 | result.finally(() => { 136 | blockedByPromise = false; 137 | }); 138 | } 139 | }, slowdown); 140 | }; 141 | } 142 | 143 | export function defaultDatagridNameResolver(this: Datagrid, datagrid: HTMLElement) { 144 | // This attribute is not present by default, though if you're going to use this library 145 | // it's recommended to add it, because when not present, the fallback way is to parse the datagrid- class, 146 | // which is definitely far from reliable. Alternatively (mainly in case of a custom datagrid class), 147 | // you can pass your own resolveDatagridName function to the option. 148 | const attrName = datagrid.getAttribute("data-datagrid-name"); 149 | if (attrName) return attrName; 150 | 151 | console.warn( 152 | "Deprecated name resolution for datagrid", 153 | datagrid, 154 | ": Please add a data-datagrid-name attribute instead!\n" + 155 | "Currently, the Datagrid library relies on matching the name from the 'datagrid-[name]' class, which is unreliable " + 156 | "and may cause bugs if the default class names are not used (eg. if you add a datagrid-xx class, or change the name class completely!)\n" + 157 | "Alternatively, you can customize the name resolution with the `resolveDatagridName` option. See TBD for more info." // TODO 158 | ); 159 | 160 | const classes = datagrid.classList.value.split(" "); 161 | 162 | // Returns the first datagrid-XXX match 163 | for (const className of classes) { 164 | if (!className.startsWith("datagrid-")) continue; 165 | 166 | const [, ...split] = className.split("-"); 167 | const name = split.join("-"); 168 | 169 | // In case nothing actually follows the prefix (className = "datagrid-") 170 | if (name.length < 1) { 171 | console.error(`Failed to resolve datagrid name - ambigious class name '${className}'`); 172 | return null; 173 | } 174 | 175 | return name; 176 | } 177 | 178 | return null; 179 | } 180 | -------------------------------------------------------------------------------- /src/templates/datagrid_tree.latte: -------------------------------------------------------------------------------- 1 | {** 2 | * @param array $columns Available columns 3 | * @param array $actions Available actions 4 | * @param array $exports Available exports 5 | * @param Row[] $rows List of rows (each contain a item from data source) 6 | * @param Datagrid $control Parent (Datagrid) 7 | * @param string $originalTemplate Original template file path 8 | * @param string $iconPrefix Icon prefix (fa fa-) 9 | *} 10 | 11 | {extends $originalTemplate} 12 | 13 |

isSortable()}data-sortable-tree data-sortable-url="{plink $control->getSortableHandler()}" data-sortable-parent-path="{$control->getSortableParentPath()}"{/if}> 14 | {snippetArea items} 15 |
16 |
17 | 18 | {foreach $toolbarButtons as $toolbarButton}{$toolbarButton->renderButton()}{/foreach} 19 | 20 |
21 |
22 |
23 | {foreach $columns as $key => $column} 24 | {$column->getName()|translate} 25 | {breakIf TRUE} 26 | {/foreach} 27 |
28 | 29 |
30 |
31 | {foreach $columns as $key => $column} 32 | {continueIf $iterator->isFirst()} 33 |
34 | {$column->getName()|translate} 35 |
36 | {/foreach} 37 |
38 |
39 |
40 | {var $tmpRow = reset($rows)} 41 | 42 | {foreach $actions as $key => $action} 43 | {if $tmpRow->hasAction($key)} 44 | {if $action->hasTemplate()} 45 | {include $action->getTemplate(), item => $tmpRow->getItem(), (expand) $action->getTemplateVariables(), row => $tmpRow} 46 | {else} 47 | {$action->render($tmpRow)|noescape} 48 | {/if} 49 | {/if} 50 | {/foreach} 51 | 52 | 53 | 54 | 55 |
56 |
57 |
58 |
59 |
60 | 61 | {foreach $rows as $row} 62 | {var $hasChildren = $control->hasTreeViewChildrenCallback() ? $control->treeViewChildrenCallback($row->getItem()) : $row->getValue($treeViewHasChildrenColumn)} 63 | {var $item = $row->getItem()} 64 | 65 |
66 |
67 |
68 | 69 | 70 | 71 | {foreach $columns as $key => $column} 72 | {var $col = 'col-' . $key} 73 | {php $column = $row->applyColumnCallback($key, clone $column)} 74 | 75 | {if $column->hasTemplate()} 76 | {include $column->getTemplate(), item => $item, (expand) $column->getTemplateVariables()} 77 | {else} 78 | {ifset #$col} 79 | {include #$col, item => $item} 80 | {else} 81 | {if $column->isTemplateEscaped()} 82 | {$column->render($row)} 83 | {else} 84 | {$column->render($row)|noescape} 85 | {/if} 86 | {/ifset} 87 | {/if} 88 | 89 | {breakIf TRUE} 90 | {/foreach} 91 |
92 |
93 |
94 | {foreach $columns as $key => $column} 95 | {continueIf $iterator->isFirst()} 96 | 97 |
98 | {var $col = 'col-' . $key} 99 | {php $column = $row->applyColumnCallback($key, clone $column)} 100 | 101 | {if $column->hasTemplate()} 102 | {include $column->getTemplate(), row => $row, item => $item, (expand) $column->getTemplateVariables()} 103 | {else} 104 | {ifset #$col} 105 | {include #$col, item => $item} 106 | {else} 107 | {if $column->isTemplateEscaped()} 108 | {$column->render($row)} 109 | {else} 110 | {$column->render($row)|noescape} 111 | {/if} 112 | {/ifset} 113 | {/if} 114 |
115 | {/foreach} 116 |
117 |
118 |
119 | {foreach $actions as $key => $action} 120 | {if $row->hasAction($key)} 121 | {if $action->hasTemplate()} 122 | {include $action->getTemplate(), item => $item, (expand) $action->getTemplateVariables(), row => $row} 123 | {else} 124 | {$action->render($row)|noescape} 125 | {/if} 126 | {/if} 127 | {/foreach} 128 | 129 | 130 | 131 | 132 |
133 |
134 |
135 |
136 |
isSortable()}data-sortable-tree data-sortable-url="{plink $control->getSortableHandler()}"{/if}>
137 |
138 | {/foreach} 139 | {if !$rows} 140 | {='contributte_datagrid.no_item_found'|translate} 141 | {/if} 142 | {/snippetArea} 143 |
144 | --------------------------------------------------------------------------------