mrcatz/datatable
Opinionated DataTable + CRUD framework for Laravel Livewire. Build admin pages fast with pagination, sorting, filtering, smart search, inline editing, bulk actions, expandable rows, exports, and a programmatic form builder. Includes artisan scaffolding; supports Tailwind + DaisyUI.
HasExport::buildExportQuery blows up with Call to a member function whereIn() on null when an export runs against a filter whose callback mutates the query in place. v1.29.28 fixed the same class of bug inside the live datatable engine (MrCatzDataTables::applyFilter*), but the export pipeline carries its own near-identical filter loop in HasExport::buildExportQuery that wasn't updated. All four user-callback callsites there (date_range, date, check, and the legacy select fallback) did $query = $callback($query, $value) — so a callback like function ($q, $v) { $q->whereDate('foo', $v); } (no return) overwrote $query with null, and the very next statement — the bulk-selection guard or a subsequent filter's where — exploded on the null builder. The four callsites now route through a new private trait helper exportApplyBuilderCallback($cb, $query, $value) that mirrors the engine helper: it returns the callback's result when non-null, otherwise hands back the original $query. Behaviour stays identical for callbacks that already returned the modified builder; void-returning callbacks (which previously worked everywhere else in Laravel's query API) now stop crashing the export count + download flow.createDateWithCallback / createCheckWithCallback filter blows up with TypeError when the user callback mutates $query in place and returns void/null. All seven filter-callback invocations inside MrCatzDataTables::applyFilter* did $this->dataBuilder = $callback($this->dataBuilder, $value), directly assigning the callback's return value to a typed property (Builder|Builder|array). The documented signature ($query, $value) suggests a Laravel-idiomatic mutate-via-reference style (matching where(function ($q) { $q->where(...); }) and the entire query-scope ecosystem), so users naturally wrote fn($q, $v) => $q->whereDate('foo', $v) — fine — and function ($q, $v) { $q->whereDate('foo', $v); } — boom, Cannot assign null to property of type Builder|Builder|array. The check-filter and date-range variants had the same fault. The fix routes every callback invocation through a single new helper applyBuilderCallback($cb, $value) that runs the callback against $this->dataBuilder and only reassigns when the callback returned a non-null result; legacy callbacks that return the modified builder keep working untouched, while callbacks that just mutate the builder and fall through to an implicit return null no longer crash. Covered by two new feature tests (test_date_filter_callback_mutates_in_place_without_return, test_check_filter_callback_mutates_in_place_without_return).select filter sibling. Three of the four filter widgets in datatable-filter.blade.php rendered visibly taller than the regular select select-sm filter when placed in the same toolbar row: the date_range and check trigger buttons used px-3 py-1.5 text-sm border which composes to a 34px outer height (20px line-height + 12px padding + 2px border, box-sizing: border-box), and the date <input type="..."> — despite already carrying input-sm — rendered slightly taller than its select-sm neighbour in some browsers because native date pickers ship their own internal line-height for the calendar control and don't always honour daisyUI's height: 2rem exactly. The 1–2px stair-step was invisible on a single filter but produced a jagged baseline when a CRUD page combined check / date-range / date with plain select filters in the same row. All three widgets are now pinned to h-8 (height: 2rem) — the date input keeps input-sm for padding/typography and just adds h-8 to override the browser's native sizing, and both popover trigger buttons drop py-1.5 in favour of h-8 so flex items-center centers the text-sm label inside an explicit 32px box. Matches select-sm exactly; popovers still anchor against getBoundingClientRect() of the trigger, just from a 2px-shorter rect.createCheck filter selection silently exports zero rows. HasExport::buildExportQuery() fell through to the generic select-style fallback where($df['key'], $df['condition'], $activeValue) for type === 'check' filters, but a check filter stores condition = 'whereIn' (a builder method name, not a SQL operator) and activeValue as a list array of selected option values. The resulting query compiled to WHERE col whereIn ? with an array binding — silently producing zero matches in MySQL / SQLite — so the export-modal count read 0 and the downloaded file came out empty even though the main datatable showed 4 visible rows. The export now has its own check branch that mirrors MrCatzDataTables::applyCheckFilter: callback variant gets ($query, array $values) (even when empty, matching engine semantics); non-callback variant resolves the builder method from condition + the active entry's exclude_mode (whereIn / whereNotIn flip on Exclude), then calls $query->{$method}($key, $values). Also captures the full active-filter entry (not just value) so exclude_mode is available — the previous code only read $af['value'], which would have missed the Exclude bit even if the where-call had been valid.items-start on the option row + mt-0.5 on the checkbox + leading-snug (1.375) on the label, which visually nudged the checkbox down but left its center a few pixels off from the first text line's center — a small but noticeable misalignment once rows wrapped to two or three lines. The checkbox now drops the manual mt-0.5 and the label span switches leading-snug → leading-5 (20px line-box, matching daisyUI 5's checkbox-sm 1.25rem size). With both elements top-aligned via items-start and sharing the same 20px cross-axis dimension, the first-line center aligns exactly with the checkbox center, while subsequent wrapped lines continue below at the same 20px line-height.createCheck filter popover. The option rows inside the checkbox filter popover (datatable-filter.blade.php, [@elseif](https://github.com/elseif)($type === 'check') branch) used flex items-center on the <label> and flex-1 truncate on the label span, so any option text that exceeded the 20rem popover width was clipped to a single line with ellipsis — which hit real-world Kategori / SKPD taxonomies where names routinely run past 30 characters and share common prefixes (so the ellipsis landed at a point that made sibling options indistinguishable). The row now uses flex items-start so the checkbox stays top-aligned while the label wraps to as many lines as needed; the label span swaps truncate for min-w-0 break-words leading-snug (wrap on word boundary, break inside unbroken strings like long codes, tighter line-height to keep multi-line rows compact); and the checkbox input picks up mt-0.5 shrink-0 so it vertical-nudges to visually center against the first text line and never compresses under long labels. First-paint popover-height estimate in _estimateHeight() still uses optH = 36; a $nextTick pass replaces it with the real offsetHeight measurement, so multi-line rows reposition correctly on the second frame.<span class="truncate text-left" x-text="triggerText()">) still truncates on purpose — wrapping the trigger would shift toolbar row height — but long summaries like "Kategori: 5 selected (Tingkat Kemiskinan, Angka Harapan Hidup, …)" were unreadable without opening the popover. The wrapper span now carries :title="triggerText()", so hovering the trigger surfaces the full summary in a native tooltip without any layout change.MrCatzDataTablesComponent::inlineUpdate() dispatches two browser events around each save — inline-validation-error and inline-save-done — with a cellId that every inline-edit Alpine cell on the page listens for via x-on:inline-*.window. Pre-v1.29.24 the cellId was {rowIndex}_{columnKey}, so two datatables that happened to share a column name (e.g. both had an aktif column at row 0) would both toggle their loading/error UI when EITHER table saved that cell: Table B's spinner would flicker during a save to Table A, and a validation error fired from Table A would flip Table B's matching cell into edit mode with the sibling's error text. cellId is now prefixed with the originating table's setPageName() ({pageName}_{rowIndex}_{columnKey}) on both the server dispatch in MrCatzDataTablesComponent::inlineUpdate() and the three blade comparison sites in resources/views/components/ui/partials/table-content.blade.php (desktop table + mobile card first-field + mobile card rest-fields). Single-CRUD pages pick up a page_ prefix too, but since the value is only ever compared for equality between the dispatch and the listener, consumer behaviour is unchanged.datatable-bulk-action.blade.php modal is pulled in from datatable-form.blade.php, so a page with two form partials (one per CRUD, each [@extends](https://github.com/extends)('mrcatz::components.ui.datatable-form')) was emitting TWO copies of the dialog — both bound to the same page-level $activeBulkActionId state. Under manual-blade bulk form mode (setBulkForm() returning a [@section](https://github.com/section)('bulk-*') name), each copy would yield its own partial's section content, stacking unrelated bulk-change-status bodies on top of each other when the user clicked a bulk-action chip. The modal now takes an $ownerSuffix (passed through from the calling form partial's $modalSuffix) and only activates when $this->currentCrudPageName matches the owner's pageName — so exactly one dialog shows, and it's the dialog tied to the datatable that triggered the action.setBulkForm() called inconsistently with / without $pageName on the same request. processBulkAction() (submit) invoked setBulkForm($id, $this->currentCrudPageName), but the render-time helpers getBulkFormFields() / getBulkFormSection() called it as setBulkForm($id) — so a user override that widened its signature to branch on pageName received the value at submit but null at render, producing a mismatched form schema. Both render-time calls now pass $this->currentCrudPageName positionally, matching the submit path. Consumers whose override is still the default 1-arg signature see no behaviour change — PHP discards the extra argument.resources/views/components/ui/datatable-form.blade.php now forwards its own $modalSuffix into the bulk-action include ([@include](https://github.com/include)('mrcatz::components.ui.datatable-bulk-action', ['ownerSuffix' => $modalSuffix])) so the modal can resolve its owner pageName. Single-CRUD pages (no $modalSuffix declared) pass '' → owner 'page' → legacy behaviour unchanged.modal-data / modal-data-delete / modal-export / modal-reset-confirm / modal-mobile-columns / modal-mobile-preset / modal-mobile-expand / modal-bulk-delete DOM ids. The namespace is taken from each datatable's setPageName() (the same knob that v1.29.20 uses for URL state), so opting in for a page is a one-line change on each table component. Every lifecycle event now carries pageName in its payload, every modal DOM id is suffixed with the pageName when non-default, and the listener wrappers on MrCatzComponent (listenAddData / listenEditData / listenDeleteData / listenBulkDeleteData / listenInlineUpdate / listenRowClick / onBulkActionOpen) set $currentCrudPageName before invoking the user hook so downstream helpers can see which datatable triggered the flow.$currentCrudPageName state on MrCatzComponent. Populated by the listen-wrappers whenever a CRUD event fires. saveData() / dropData() / form-builder rendering / setForm() / setBulkForm() / processBulkActionData() all read from this so consumers can branch per-datatable in a single page component.$pageName parameter on every overridable lifecycle hook — without a parent-class signature change. The parent stubs (prepareAddData, prepareEditData, prepareDeleteData, dropBulkData, onInlineUpdate, onRowClick, setForm, setBulkForm, processBulkActionData) keep their pre-v1.29.22 signatures so existing consumer overrides stay LSP-valid and continue to load. The engine now invokes each hook with the pageName appended positionally — PHP silently discards the extra argument for the older signatures — so existing code is byte-compatible. Consumers who want to receive the value can widen their override by ADDING an optional parameter (e.g. setForm($pageName = null): array / prepareAddData($pageName = null)); LSP allows a child to add optional parameters on top of a zero-arg parent signature, and the positional call hands the value through. Alternatively, read $this->currentCrudPageName inside any hook — the listen-wrappers set it before the user hook fires.$showCardOnMobile public property on MrCatzDataTablesComponent. Default true (unchanged layout — mobile renders each row as a stacked card). Set to false on a child component to disable the mobile card list and show the same desktop table layout (with horizontal scroll) at every breakpoint; the toolbar also drops its hidden md:flex gate so search / filter / bulk-action chips stay visible, and the mobile-expand modal — triggered only from the card list — stops rendering.$modalSuffix variable contract in mrcatz::components.ui.datatable-form. For manual-blade form partials, declare [@php](https://github.com/php)($modalSuffix = '-yourPageName') before [@extends](https://github.com/extends)('mrcatz::components.ui.datatable-form') so each CRUD gets its own <dialog id="modal-data-yourPageName">. Omit it on single-CRUD pages — the default $modalSuffix = '' produces ids byte-identical to pre-v1.29.22.setPageName() as a trailing POSITIONAL argument: ADD_DATA, EDIT_DATA, DELETE_DATA, BULK_DELETE, BULK_ACTION_OPEN, BULK_ACTION_DONE, INLINE_UPDATE, ROW_CLICK, OPEN_EXPORT_MODAL, plus the dispatch_to_view REFRESH_DATA / SHOW_NOTIF dispatches carry $this->currentCrudPageName in their payload array so the JS layer can close the correct namespaced modal.datatable-scripts.blade JS listeners now read the pageName off the payload (d[0] or d[1] depending on the event shape), forward it to prepareAddData / Edit / Delete, and resolve the correct modal-data-<pageName> / modal-data-delete-<pageName> / modal-export-<pageName> dialog when calling .show() / .showModal() / .close(). Unknown pageName (payload missing) defaults to 'page' → empty suffix → legacy behaviour.HasCustomBulkActions::onBulkActionDone($pageName = null) now ignores dispatches meant for a sibling datatable ($pageName !== $this->setPageName()), so a multi-CRUD page no longer clears every table's selection whenever any bulk action finishes. A null $pageName preserves the pre-v1.29.22 "clear all" behaviour for legacy callers.stubs/page.stub — the scaffolded prepareAddData / prepareEditData / prepareDeleteData / dropBulkData methods now include the optional $pageName = null parameter, and the comment on setForm() documents the new signature + multi-CRUD branching.stubs/form-blade.stub — prepended with a commented-out [@php](https://github.com/php)($modalSuffix = '-<pageName>') and a note explaining when to uncomment it.setPageName() still returns 'page' by default, which the suffix logic treats as "no namespace". DOM ids stay byte-identical, dispatch payloads just pick up an ignored pageName: 'page' entry, and existing prepareAddData() / setForm() methods keep working because PHP silently discards the extra argument.setPageName() (e.g. 'tahun', 'indikator'); this already namespaces the URL state per v1.29.20. (2) If you use manual blade forms, add [@php](https://github.com/php)($modalSuffix = '-yourPageName') at the top of each form partial. (3) Update setForm() / prepareAddData() / etc. to accept $pageName and branch on it, or read $this->currentCrudPageName inside saveData() / dropData().setPageName(). All 8 internal $this->setPage(1) call-sites across MrCatzDataTablesComponent (searchData / resetData / paginate) and HasFilters (change / applyCheck / changeDateRange / and the other filter-state mutators) used the 2-arg setPage($page, $pageName = 'page') method from Livewire's HandlesPagination trait without passing the component's actual page name. With the v1.29.20 setPageName()-driven URL prefix in effect, the real paginator lives under a non-default key (e.g. 'desaPage'), so these calls were quietly resetting the wrong paginator and leaving the user stranded on an out-of-bounds page after an applied filter — showing an empty table instead of the filtered page 1 result. Now every internal setPage(1, ...) passes $this->setPageName() so it always hits the paginator actually in use.<livewire:*-table /> instances hosted on a single page used to share every query-string key (?search=, ?filter=, ?col_hidden=, ?col_order=, ?col_widths=, ?sort=, ?dir=, ?sort_multi=, ?per_page=), because the parent component declared them with #[Url(as: '...')] — a compile-time constant, same for every subclass. Hiding column 1 in table A immediately hid column 1 in table B; searching in one table drove results in both. The fix re-routes the same 9 aliases through the legacy queryString() method (still fully supported in Livewire 3 via SupportQueryString::getQueryString()), which is free to build keys at runtime from urlPrefix().urlPrefix() method on MrCatzDataTablesComponent. Default implementation returns setPageName() . '_' when the page name has been overridden to a non-default value (anything other than 'page' / '' / null), or an empty string otherwise. Consumers can override for custom schemes (shorter prefixes, different separators) without also redefining queryString().queryString() method on MrCatzDataTablesComponent. Returns the dynamic URL alias map for all 9 persisted properties, prefixed with urlPrefix().setPageName() still returns 'page' by default, so urlPrefix() returns '', and query-string keys stay exactly as they were (?search=, ?filter=, etc.). Existing bookmarks and shared URLs on single-datatable pages continue to work.setPageName() per table. Declaring public function setPageName() { return 'penyediaPage'; } on one child and 'swakelolaPage' on the other routes their URL state to ?penyediaPage_search=… and ?swakelolaPage_search=… respectively. Pagination already uses the same page name, so the namespace is consistent across all state.#[Url(...)] attributes and the use Livewire\Attributes\Url; import from MrCatzDataTablesComponent.php. The property declarations themselves are unchanged so child-class overrides stay source-compatible.setPageName() on each child component. No other code changes are required; the package rewrites the aliases on the next request. Old bookmarks that hit the collided URLs won't rehydrate — they pointed at a bug and there's no backwards-compatible way to preserve them without reintroducing the collision.setPageName() on a table for pagination purposes only: your pagination URL (?yourPageName=2) is unchanged, but the other URL params now prefix onto the same name (?yourPageName_search=foo). Shared links that passed both ?yourPageName=… and the old unprefixed ?search=… will keep their page but lose their search term. Update shared links or accept the one-time break.setFilterData() extended signature — the method now accepts five optional runtime-override args beyond $data:
?string $value — column name used for each option's value?string $option — column name used for each option's label?string $key — DB column the engine filters on?string $condition — SQL condition (=, LIKE, whereIn, …)?string $callback — method name on the component (not a closure — raw closures can't round-trip through Livewire state). Resolved to [$this, $method] and wrapped as \Closure::fromCallable() at query time so the engine's strict ?\Closure type hints still match.null keeps the value the factory produced in setFilter(). Backwards compatible — existing setFilterData($id, $data) call-sites unchanged.setFilterDateBounds($id, ?min, ?max, ?condition, ?callback) — new method, same pattern, but targeted at date / date_range filters. Throws MrCatzException::setFilterDateBoundsNonDate on non-date targets.clearFilterOverride($id, ?array $keys = null) — remove previously-set overrides. $keys = null wipes all; pass a list like ['min', 'max'] to clear specific fields. Valid keys: 'key', 'condition', 'value', 'option', 'min', 'max', 'callback'.HasFilters that persist through Livewire roundtrips — filterKeyOverrides, filterConditionOverrides, filterValueColOverrides, filterOptionColOverrides, filterMinDateOverrides, filterMaxDateOverrides, filterCallbackOverrides. Necessary because setFilter() re-runs fresh every render, so runtime overrides would otherwise be lost.applyFilterOverrides() — protected method on HasFilters, called from render() right after getDataFilter(). Patches both $dataFilters and $activeFilters from the override maps so the engine sees the final effective filter config before building its query. Also zeros allow_exclude on any check filter whose callback override is active (the toggle is meaningless when a closure owns the WHERE clause — symmetric with createCheckWithCallback() rejecting ->allowExclude() at factory time).MrCatzException::setFilterDateBoundsNonDate, MrCatzException::filterCallbackMethodNotFound, and MrCatzException::invalidCheckMode (carry-over refinement).applyFilterOverrides patching, engine-side key/condition swap, callback method-name resolution, ?\Closure type-hint regression on check-filter callback path, setFilterDateBounds validation, clearFilterOverride semantics, allow_exclude auto-hide, and a URL-boot integration test.onFilterChanged called resetFilter. Scenario: loading ?filter[category_source]=category&filter[category][]=1 would render the first page without the category filter applied; only a second Livewire roundtrip (any interaction) showed the correct data. Root cause: bootFilters Phase 2 (onFilterChanged fan-out) could trigger resetFilter → findData, caching a MrCatzDataTables built from pre-restore activeFilters; Phase 3 restored the URL value but left the engine cache stale. Fix: invalidate $this->mrCatzDataTables at the end of the URL-params block so render()'s getData() rebuilds against the final restored state plus any queued overrides.setFilterData / setFilterDateBounds changes until a page refresh. wire:key used to hash only the applied value + mode, so when a driver filter mutated filterData or picker bounds while the target had no active value, the hash stayed stable, Livewire preserved the DOM, and Alpine kept its old x-data config. Fix: wire:key now also hashes the option list, the effective value/option column names, the allow_exclude flag, and (for date_range) the min/max bounds. Draft edits inside the popover still keep the hash stable so the popover doesn't close mid-edit.whereIn ↔ whereNotIn at the engine, but the engine never routes exclude_mode through callback invocations — so the toggle was purely cosmetic noise. Now auto-hides while filterCallbackOverrides[$id] is set and returns to its factory value once the override is cleared.setFilterData param table, a stale-value gotcha callout, a new setFilterDateBounds section, and clearFilterOverride usage.change, changeDateRange, applyCheck, toggleCheck, setCheckMode).DemoProductTable.select / date / date_range. Two factories on MrCatzDataTableFilter:
createCheck($id, $label, $data, $value, $option, $key, $condition = 'whereIn', $show = true) — standard whereIn / whereNotIn.createCheckWithCallback($id, $label, $data, $value, $option, $callback, $show = true) — callback receives ($query, array $values) for custom SQL (joins, whereHas, etc).->allowExclude() — adds an Include/Exclude mode toggle to the popover; engine flips whereIn ↔ whereNotIn atomically. Rejected on createCheckWithCallback (callback owns its own SQL).->allowSearchWhen(?int $count = 5) — shows an in-popover search box once option count exceeds the threshold. null disables search entirely. Default 5 is aligned with the list area's visual scroll break.<body> (mirrors the date_range pattern). Includes sticky search box with case-insensitive <mark> highlight (XSS-safe via per-chunk escaping), scrollable list (max-h-[16rem]), selected/total counter, and Select-all / Clear-selection shortcuts that respect the current search filter.applyCheck($id, $values, $mode) Livewire method, running findData() exactly once). ESC / outside-click discards the draft; Reset filter and the trigger's clear-x both clear applied state and close the popover."Status: Active, Pending" for include mode, "Status: NOT (Archived, Draft)" for exclude mode. check_box icon used for the check filter's row in the banner.key IN [...] / key NOT IN [...] expressions via maybePushCheckFilter(). Callback variants fall back to SQL (matches date-filter convention). Empty selection is a no-op (nothing pushed, nothing filtered).filter_check_pick, filter_check_search, filter_check_no_match, filter_check_selected, filter_check_select_all, filter_check_clear, filter_check_apply, filter_check_reset, filter_check_mode_include, filter_check_mode_exclude, filter_check_not_prefix, filter_check_plus_more.invalidCheckCondition, allowExcludeOnCallback, invalidCheckMode.mrcatzDateRange and mrcatzCheckFilter now measure real offsetHeight on the $nextTick refine pass instead of relying on a hardcoded approximation. The flip-above case no longer leaves a large visible gap when the actual popover is shorter than the estimate.HasFilters::filterValueIsSet — teaches the check now accepts list-style arrays ([1, 2, 3]) instead of only date-range shapes (['from' => ..., 'to' => ...]). Empty lists correctly register as "unset" so they don't sync to URL params.activeFilters[] entries now include an exclude_mode boolean alongside type / format. bootFilters + change + changeDateRange updated to write the field uniformly so engine + export code can rely on it.dateRange() field was silently skipped inside sections flagged with ->asCard(). The multi-card layout branch in form-builder.blade.php was missing the date_range case that the single-grid branch already had, so any form using card-grouped sections (including the standalone playground) rendered every other field but dropped the date range.monthYear() and year() min / max parameters are now enforced at runtime. monthYear() gains native min / max attributes on its <input type="month"> plus a JS clamp; year() (<input type="number">) gains a JS clamp so values typed past the range snap back before Livewire syncs (native number inputs only validate on submit).min / max on time() and datetime-local() pickers are only validated natively on form-submit; the picker itself still lets users scroll past the range. Input now clamps the value on change and re-dispatches input / change so Livewire syncs the clamped value, giving real-time enforcement to match the date() behavior.MrCatzFormField::date() gains minDate / maxDate, datetime() gains minDateTime / maxDateTime, and time() gains minTime / maxTime. Values are emitted as native min / max attributes on the picker input so browsers clamp selection on both mouse and keyboard.description key) in the default inline-SVG icon set (mrcatz_icon_svg()), and mapped to document-text / fa-solid fa-file-csv in the Heroicon and Font Awesome icon sets — so the export modal's new CSV card renders a matching glyph regardless of the configured icon_set.MrCatzExport now accepts setFormat() and setHasIndexCol(); the export blade reuses the same HTML template for XLSX and CSV but pads rows with empty cells instead of using colspan, and (on CSV) shifts the title / meta banner one column to the right when the first column is an index/No column so that column doesn't get auto-sized to the title string's width.export_banner_exported, export_banner_total, export_banner_rows translation keys (EN / ID). Excel and PDF export banners now use mrcatz_lang() instead of the hard-coded Indonesian "Diekspor / Total / data" string.styles() on MrCatzExport (and the published App\Exports\DatatableExport stub) now early-returns for CSV. mergeCells() is a spreadsheet mutation, not pure styling — for CSV it collapsed the shifted title-banner cells into the empty anchor column, producing three empty rows in the output.description icon hint in config/mrcatz.php under the Export Modal section, mapped to fa-file-csv — so projects using the FontAwesome fallback icon set get a matching CSV glyph in the export modal instead of a missing icon.\Maatwebsite\Excel\Excel::CSV writer and downloads a .csv file. No extra dependency — maatwebsite/excel already handles CSV out of the box. Requested in a user-filed issue.MrCatzFormField::mapPicker() gains a forceTheme parameter ('light' | 'dark' | null). When set, the Leaflet tile layer is pinned to that theme regardless of the host app's <html data-theme>. Null (default) preserves the existing behavior of following data-theme. Leaflet-only — Google Maps ignores this flag because its default rendering doesn't hot-swap styles.withColumnImage() with a table-prefixed key (e.g. 'demo_products.image_url') rendered the fallback initial instead of the image because the callback looked up $data->{'demo_products.image_url'} on a row object whose property is just image_url. The callback now strips the table prefix the same way getData() does, and applies the same rule to the fallback key. getExpandView() image and text field readers get the same treatment so their key / fallback entries can be qualified too.x-data only initializes val once and Livewire's morph preserves Alpine state across re-renders, so the input opened with stale data even though the Blade-rendered display was correct. The wrappers now carry a data-current-value attribute (which morphs cleanly), and the double-click / mobile click handlers reset val from it before opening the editor.errorKey field metadata (set to bulkFormData.{id} for bulk fields, falls back to id for the standard edit form) is consumed by every form partial via [@error](https://github.com/error)($errorKey ?? $id).clearSelection() (e.g. clicking Cancel) because Livewire's morph only updates the checked HTML attribute, not the live DOM property. Now bound via Alpine :checked="$wire.selectAll" so it stays in sync across server-driven state changes.$showBulkDeleteAction = true) is always the mobile primary; if disabled, the first custom action takes its place.… + native tooltip (title attribute) so very long labels never overflow the button.btn_more translation key (More / Lainnya).MrCatzEvent::BULK_ACTION_DONE event — page-side modal dispatches it after a successful submit, the table clears its selection via an #[On] listener on HasCustomBulkActions.$selectedRows immediately. Rows stay selected while the modal is open, so cancelling doesn't force the user to re-pick. Selection is cleared only after a successful processBulkActionData() run.buttonColor parameter on MrCatzBulkAction::create() — pick any DaisyUI theme color (primary, secondary, accent, neutral, info, success, warning, error, ghost) for the toolbar button outline and the modal's submit button. Defaults to 'primary'. The modal's header icon + background tint follow the same color automatically.setBulkForm($id) and processBulkActionData() hooks resolve correctly (they're defined on the page). The initial 1.29.0 release mistakenly colocated the modal with the table component, which caused the modal to fall back to the Edit form's fields.<dialog open> with DaisyUI .modal.modal-open classes so visibility is driven entirely by Livewire state ($activeBulkActionId).form-builder.blade.php) now accepts an optional $formFields include parameter via isset() check so callers (e.g. the bulk modal) can pass a pre-built, namespaced field set without clashing with the Edit form's $this->getFormFields().HasCustomBulkActions trait into two: HasCustomBulkActions on the Table component (toolbar buttons + dispatch), and HasCustomBulkActionModal on MrCatzComponent (modal state + form rendering + submit handling).MrCatzEvent::BULK_ACTION_OPEN event carries action metadata + selectedRows from the table to the page when a bulk button is clicked.MrCatzBulkAction class + setBulkAction() hook on the table component, paired with setBulkForm($id) and processBulkActionData($id, $selectedRows, $bulkFormData) hooks on the page component. Two modes supported:
'confirmation' — simple confirm dialog (e.g. "Delete Selected Data?").'form' — opens a modal rendering either a Form Builder form (setBulkForm() returns MrCatzFormField[], auto-bound to $bulkFormData and auto-validated) or a blade [@yield](https://github.com/yield) escape hatch (setBulkForm() returns a section name, user wires wire:model="bulkFormData.*" manually).
Buttons render in the existing bulk toolbar alongside the built-in delete button. Example:public function setBulkAction(): array
{
return [
MrCatzBulkAction::create('bulk_category', 'form', 'Update Selected Data', null, null, 'edit'),
MrCatzBulkAction::create('bulk_delete', 'confirmation', 'Delete Selected Data?', null, 'Selected data will be permanently deleted.', 'delete'),
];
}
$showBulkDeleteAction property on the table component (default true) to hide the built-in bulk delete button when you want full control over bulk operations via custom actions.$bulkFormData array property on MrCatzComponent holding form values submitted through bulk form modals.MrCatzEvent::BULK_ACTION event constant for the new table→page handoff.form-builder.blade.php now accepts an optional $formFields include variable, enabling callers to render a pre-built, differently-namespaced field set (used internally by the bulk action modal so fields bind to bulkFormData.*). Existing call sites continue to use $this->getFormFields() unchanged.$urlPrefix parameter on getImageView() (default null, backward compatible). When supplied, the helper runs $url through resolveImageUrl($url, $urlPrefix) so callers can pass a bare DB value (e.g. "avatar.jpg") and let the helper build the final URL — the same contract withColumnImage() already exposes. Leaving $urlPrefix null preserves the legacy behavior where $url is rendered as-is.$gravity parameter on withColumnImage() and getImageView() (default 'center', preserving current behavior). Accepts 'left', 'center', 'right' and controls the horizontal alignment of the image within its table cell. datatable-image.blade.php maps it to justify-start / justify-center / justify-end on the outer flex wrapper. Useful when a table cell needs the thumbnail flush against the left edge instead of centered.enableAutoExpand() helper and the automatic setData() fallback added in v1.23.6. The auto-fallback silently wired up expand content whenever $expandableRows was set, which felt too magical for callers who prefer to drive expand content explicitly via enableExpand(). The feature will return as an explicit opt-in if the need comes up again.MrCatzDataTables::enableAutoExpand() — builds an expand view automatically from every plain withColumn() (columns with a real $key and no type). Skips index, image, action and custom callback columns. Useful when you want the mobile "more details" drawer without manually listing fields. Reverted in v1.23.7.MrCatzDataTablesComponent::setData(): if a component sets $expandableRows to 'mobile'/'desktop'/'both' but never calls enableExpand() in setTable(), enableAutoExpand() is invoked for you. Tables get a free expand drawer by flipping a single property. Reverted in v1.23.7.withCustomColumn() gains a new $type option. Pass type: 'action' to route a custom callback column into the mobile card's top-right actions slot, same placement as withActionColumn(). This is the escape hatch for callers who need a custom pre-render step (for example, fetching related data and mutating $data before calling getActionView()) but still want the column to behave as an action column on mobile.withActionColumn() is now a thin wrapper over withCustomColumn(..., type: 'action'), removing the duplicated dataTableSet mutation.withCustomColumn() without a $key as an action column. Previously, custom columns without a data key (status badges, computed display cells, etc.) were rendered in the top-right actions slot alongside the real edit/delete buttons. Classification now checks for getColumnType() === 'action' — a tag set exclusively by withActionColumn() — so display-only custom columns flow into the card body as normal pills.withActionColumn() now tags its column with type = 'action' on dataTableSet, mirroring how withColumnImage() tags image columns.->withCustomColumn('Aksi', fn ($d, $i) => MrCatzDataTables::getActionView(...)) pattern will no longer appear in the mobile card's top-right actions slot (they'll render as body pills instead). Migrate to ->withActionColumn() to restore the top-right placement and the keyboard shortcuts.MrCatzDataTables::withActionColumn(string $head = 'Aksi', bool $editable = true, bool $deletable = true) — registers the built-in edit/delete action column AND records hasEditAction / hasDeleteAction on the engine so the rest of the UI can react to which actions are actually exposed.enableKeyboardNav always wired Enter/Delete/Backspace regardless of whether edit/delete actions were exposed, which caused read-only tables (no Aksi column, or editable: false / deletable: false) to still open the form modal when a focused row received Enter.Enter / Del/⌫ hints when the corresponding action is unavailable.->withCustomColumn('Aksi', fn ($d, $i) => MrCatzDataTables::getActionView($d, $i, $editable, $deletable)) should switch to ->withActionColumn(editable: $editable, deletable: $deletable) so keyboard shortcuts light up automatically. The old form still works for backward compat but leaves the engine unaware of the action buttons, which disables the shortcuts.MrCatzDataTableFilter::create() and createWithCallback() now accept string|iterable for $data (was string|array). Any Traversable is accepted, including Laravel Collection.get(). Callers can pass raw DB::table(...)->get() (Collection of stdClass), Model::all() (Collection of Models), arrays of stdClass, or arrays of arrays — all work without manual casting. Previously, passing a Collection of stdClass caused Cannot use object of type stdClass as array in the filter view.HasExport::buildExportData() no longer drops display-only custom columns from exports. The previous skip heuristic (key === null && index === null && !editable) was too broad: it treated every withCustomColumn() without a $key as an action column and excluded it from PDF/Excel output, which silently discarded important data like dynamic year columns, computed per-row values, and formatted display cells. Classification now relies on the explicit type tag introduced in v1.23.5 — only columns marked as type: 'action' (via withActionColumn() or withCustomColumn(..., type: 'action')) are skipped, alongside image columns. If you still have legacy withCustomColumn('Actions', fn ($d, $i) => MrCatzDataTables::getActionView(...)) call sites without a type tag, migrate to withActionColumn() or add type: 'action' to keep the action buttons out of exports.setSearchWord() now accepts ?string and coerces null to empty string in both MrCatzDataTables and MrCatzDataTablesComponent. Prevents TypeError when a row column used by withColumn() contains NULL in the database — the internal pluck path in getData() previously passed raw null values into the strict-typed setSearchWord().col_hidden)inlineUpdateData event to Page componentwithColumn() new editable parameter for inline edit supportonInlineUpdate($rowData, $columnKey, $newValue) override-able hook on Page componentaddSort($key, $order) method and multiSort URL-persistent statesetMultiSort() on engine for multi-column ordering$stickyHeader = true to keep thead visible on scrollonRowClick($data) override-able method on Table componenttypeSearchDelay format on mount$enableColumnVisibility property to show/hide column toggle button$stickyHeader propertyMrCatzEvent::INLINE_UPDATE constantcol_visibility (EN: "Columns", ID: "Kolom")col_order) instead of localStoragemrcatz::exports.datatable-pdf) — no longer requires user to create templatearia-sort on sortable column headers, aria-modal + aria-labelledby on all modalsx-trap) on all modals (export, reset, bulk delete, form, delete confirm)aria-label on bulk checkboxes (header + per-row), aria-live on toast containerrole="grid" + aria-label on data tablebeforeExport($headers, $rows, $format, $scope) and afterExport($format, $scope)col_order (#[Url])exports.datatable-pdf doesn't exist in user's projectmrcatz_lang() now uses config('mrcatz.locale') instead of app()->getLocale()en, id) from config/mrcatz.php — config now only stores locale settinglang/vendor/mrcatz/)mrcatz_lang() helper normalizes replacement keys (both :key and key formats work)mrcatz_lang() graceful fallback when translator service is unavailableorchestra/testbench with SQLite in-memory database (71 tests, 168 assertions)MrCatzExport class inside package (no longer requires \App\Exports\DatatableExport)lang/en/mrcatz.php, lang/id/mrcatz.php)php artisan vendor:publish --tag=mrcatz-langCHANGELOG.mdMrCatzExport class by default, falls back to \App\Exports\DatatableExport if existssaveData(), dropData(), prepareEditData(), baseQuery(), setTable(), setFilter(), onFilterChanged(), etc. to prevent Declaration compatibility errors in existing projectsMrCatzEvent constants class — replaces all magic string event names ('refresh-data' → MrCatzEvent::REFRESH_DATA)HasFilters trait — extracted filter logic from MrCatzDataTablesComponentHasExport trait — extracted export logic from MrCatzDataTablesComponentHasBulkActions trait — extracted bulk selection logic from MrCatzDataTablesComponentphpunit.xml configuration.gitignore fileMrCatzDataTablesComponent reduced from 517 to ~170 lines using traitsMrCatzDataTables — strict types on fluent API methodsMrCatzDataTableFilter — strict types on properties and factory methodsmrcatz:make, mrcatz:remove)How can I help you explore Laravel packages today?