From 79dda2e1ff4ee599f64b96d90006c8d73b8d78f6 Mon Sep 17 00:00:00 2001 From: Dan Harrin Date: Tue, 2 Dec 2025 19:32:37 +0000 Subject: [PATCH] Relative date and datetime constraint for query builder (#18635) * feature: Relative date constraint filtering * clean up * time * clean up * Update 04-query-builder.md * add new column to snapshots --- .../resources/lang/en/query-builder.php | 72 + .../src/Constraints/DateConstraint.php | 15 + .../Constraints/DateConstraint/DateUnit.php | 36 + .../Operators/IsAfterOperator.php | 247 ++- .../Operators/IsBeforeOperator.php | 247 ++- .../docs/03-filters/04-query-builder.md | 11 + ...erate_a_form_schema_class_for_a_model.snap | 2 + ..._form_component_with_generated_fields.snap | 2 + ...he_form_and_table_of_a_resource_class.snap | 4 + ..._content_embedded_in_a_resource_class.snap | 8 + ...an_generate_the_resource_form_content.snap | 2 + ...enerate_the_resource_infolist_content.snap | 3 + ...n_generate_the_resource_table_content.snap | 3 + ...able_component_with_generated_columns.snap | 3 + ...e_component_with_generated_columns__2.snap | 3 + .../migrations/0004_create_posts_table.php | 1 + .../Livewire/PostsQueryBuilderTable.php | 2 + tests/src/Tables/Filters/QueryBuilderTest.php | 1760 +++++++++++++++++ 18 files changed, 2409 insertions(+), 12 deletions(-) create mode 100644 packages/query-builder/src/Constraints/DateConstraint/DateUnit.php diff --git a/packages/query-builder/resources/lang/en/query-builder.php b/packages/query-builder/resources/lang/en/query-builder.php index 3b60de2bba..ea881fd202 100644 --- a/packages/query-builder/resources/lang/en/query-builder.php +++ b/packages/query-builder/resources/lang/en/query-builder.php @@ -146,6 +146,50 @@ return [ ], + 'unit_labels' => [ + 'second' => 'Seconds', + 'minute' => 'Minutes', + 'hour' => 'Hours', + 'day' => 'Days', + 'week' => 'Weeks', + 'month' => 'Months', + 'quarter' => 'Quarters', + 'year' => 'Years', + ], + + 'presets' => [ + 'past_decade' => 'Past decade', + 'past_5_years' => 'Past 5 years', + 'past_2_years' => 'Past 2 years', + 'past_year' => 'Past year', + 'past_6_months' => 'Past 6 months', + 'past_quarter' => 'Past quarter', + 'past_month' => 'Past month', + 'past_2_weeks' => 'Past 2 weeks', + 'past_week' => 'Past week', + 'past_hour' => 'Past hour', + 'past_minute' => 'Past minute', + 'this_decade' => 'This decade', + 'this_year' => 'This year', + 'this_quarter' => 'This quarter', + 'this_month' => 'This month', + 'today' => 'Today', + 'this_hour' => 'This hour', + 'this_minute' => 'This minute', + 'next_minute' => 'Next minute', + 'next_hour' => 'Next hour', + 'next_week' => 'Next week', + 'next_2_weeks' => 'Next 2 weeks', + 'next_month' => 'Next month', + 'next_quarter' => 'Next quarter', + 'next_6_months' => 'Next 6 months', + 'next_year' => 'Next year', + 'next_2_years' => 'Next 2 years', + 'next_5_years' => 'Next 5 years', + 'next_decade' => 'Next decade', + 'custom' => 'Custom', + ], + 'form' => [ 'date' => [ @@ -160,6 +204,34 @@ return [ 'label' => 'Year', ], + 'mode' => [ + 'label' => 'Date type', + 'options' => [ + 'absolute' => 'Specific date', + 'relative' => 'Rolling window', + ], + ], + + 'preset' => [ + 'label' => 'Time period', + ], + + 'relative_value' => [ + 'label' => 'How many', + ], + + 'relative_unit' => [ + 'label' => 'Time unit', + ], + + 'tense' => [ + 'label' => 'Tense', + 'options' => [ + 'past' => 'Past', + 'future' => 'Future', + ], + ], + ], ], diff --git a/packages/query-builder/src/Constraints/DateConstraint.php b/packages/query-builder/src/Constraints/DateConstraint.php index fc3736ee43..b72a863277 100644 --- a/packages/query-builder/src/Constraints/DateConstraint.php +++ b/packages/query-builder/src/Constraints/DateConstraint.php @@ -2,6 +2,7 @@ namespace Filament\QueryBuilder\Constraints; +use Closure; use Filament\QueryBuilder\Constraints\DateConstraint\Operators\IsAfterOperator; use Filament\QueryBuilder\Constraints\DateConstraint\Operators\IsBeforeOperator; use Filament\QueryBuilder\Constraints\DateConstraint\Operators\IsDateOperator; @@ -16,6 +17,20 @@ class DateConstraint extends Constraint { use Concerns\CanBeNullable; + protected bool | Closure $hasTime = false; + + public function time(bool | Closure $condition = true): static + { + $this->hasTime = $condition; + + return $this; + } + + public function hasTime(): bool + { + return (bool) $this->evaluate($this->hasTime); + } + protected function setUp(): void { parent::setUp(); diff --git a/packages/query-builder/src/Constraints/DateConstraint/DateUnit.php b/packages/query-builder/src/Constraints/DateConstraint/DateUnit.php new file mode 100644 index 0000000000..5101911674 --- /dev/null +++ b/packages/query-builder/src/Constraints/DateConstraint/DateUnit.php @@ -0,0 +1,36 @@ + __('filament-query-builder::query-builder.operators.date.unit_labels.second'), + self::Minute => __('filament-query-builder::query-builder.operators.date.unit_labels.minute'), + self::Hour => __('filament-query-builder::query-builder.operators.date.unit_labels.hour'), + self::Day => __('filament-query-builder::query-builder.operators.date.unit_labels.day'), + self::Week => __('filament-query-builder::query-builder.operators.date.unit_labels.week'), + self::Month => __('filament-query-builder::query-builder.operators.date.unit_labels.month'), + self::Quarter => __('filament-query-builder::query-builder.operators.date.unit_labels.quarter'), + self::Year => __('filament-query-builder::query-builder.operators.date.unit_labels.year'), + }; + } + + public function isTimeUnit(): bool + { + return in_array($this, [self::Second, self::Minute, self::Hour], strict: true); + } +} diff --git a/packages/query-builder/src/Constraints/DateConstraint/Operators/IsAfterOperator.php b/packages/query-builder/src/Constraints/DateConstraint/Operators/IsAfterOperator.php index 39ed67fb01..f2fb057200 100644 --- a/packages/query-builder/src/Constraints/DateConstraint/Operators/IsAfterOperator.php +++ b/packages/query-builder/src/Constraints/DateConstraint/Operators/IsAfterOperator.php @@ -4,9 +4,15 @@ namespace Filament\QueryBuilder\Constraints\DateConstraint\Operators; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\DateTimePicker; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\QueryBuilder\Constraints\DateConstraint; +use Filament\QueryBuilder\Constraints\DateConstraint\DateUnit; use Filament\QueryBuilder\Constraints\Operators\Operator; use Filament\Schemas\Components\Component; +use Filament\Schemas\Components\FusedGroup; +use Filament\Schemas\Components\Utilities\Get; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; @@ -28,13 +34,36 @@ class IsAfterOperator extends Operator public function getSummary(): string { + $settings = $this->getSettings(); + $constraint = $this->getConstraint(); + $hasTime = $constraint instanceof DateConstraint && $constraint->hasTime(); + + // Check if using relative mode + if (($settings['mode'] ?? null) === 'relative') { + $resolvedDate = $this->resolveRelativeDate($settings, $hasTime); + $parsedDate = Carbon::parse($resolvedDate); + $isTimeBasedFilter = $this->isTimeBasedFilter($settings); + + return __( + $this->isInverse() ? + 'filament-query-builder::query-builder.operators.date.is_after.summary.inverse' : + 'filament-query-builder::query-builder.operators.date.is_after.summary.direct', + [ + 'attribute' => $constraint->getAttributeLabel(), + 'date' => $hasTime && $isTimeBasedFilter ? $parsedDate->toFormattedDayDateString() . ' ' . $parsedDate->format('H:i:s') : $parsedDate->toFormattedDateString(), + ], + ); + } + + $parsedDate = Carbon::parse($settings['date']); + return __( $this->isInverse() ? 'filament-query-builder::query-builder.operators.date.is_after.summary.inverse' : 'filament-query-builder::query-builder.operators.date.is_after.summary.direct', [ - 'attribute' => $this->getConstraint()->getAttributeLabel(), - 'date' => Carbon::parse($this->getSettings()['date'])->toFormattedDateString(), + 'attribute' => $constraint->getAttributeLabel(), + 'date' => $hasTime ? $parsedDate->toFormattedDayDateString() . ' ' . $parsedDate->format('H:i:s') : $parsedDate->toFormattedDateString(), ], ); } @@ -44,15 +73,221 @@ class IsAfterOperator extends Operator */ public function getFormSchema(): array { + $constraint = $this->getConstraint(); + $hasTime = $constraint instanceof DateConstraint && $constraint->hasTime(); + return [ - DatePicker::make('date') + Select::make('mode') + ->label(__('filament-query-builder::query-builder.operators.date.form.mode.label')) + ->selectablePlaceholder(false) + ->live() + ->options([ + 'absolute' => __('filament-query-builder::query-builder.operators.date.form.mode.options.absolute'), + 'relative' => __('filament-query-builder::query-builder.operators.date.form.mode.options.relative'), + ]) + ->default('absolute'), + DateTimePicker::make('date') ->label(__('filament-query-builder::query-builder.operators.date.form.date.label')) - ->required(), + ->time($hasTime) + ->hidden(fn (Get $get): bool => $get('mode') === 'relative') + ->required(fn (Get $get): bool => $get('mode') !== 'relative'), + Select::make('preset') + ->label(__('filament-query-builder::query-builder.operators.date.form.preset.label')) + ->selectablePlaceholder(false) + ->live() + ->options($this->getPresetOptions($hasTime)) + ->default('past_month') + ->hidden(fn (Get $get): bool => $get('mode') !== 'relative') + ->required(fn (Get $get): bool => $get('mode') === 'relative'), + FusedGroup::make([ + Select::make('tense') + ->label(__('filament-query-builder::query-builder.operators.date.form.tense.label')) + ->selectablePlaceholder(false) + ->options([ + 'past' => __('filament-query-builder::query-builder.operators.date.form.tense.options.past'), + 'future' => __('filament-query-builder::query-builder.operators.date.form.tense.options.future'), + ]) + ->default('past') + ->required(), + TextInput::make('relative_value') + ->label(__('filament-query-builder::query-builder.operators.date.form.relative_value.label')) + ->numeric() + ->minValue(1) + ->default(1) + ->required(), + Select::make('relative_unit') + ->label(__('filament-query-builder::query-builder.operators.date.form.relative_unit.label')) + ->options( + collect(DateUnit::cases()) + ->reject(fn (DateUnit $unit): bool => (! $hasTime) && $unit->isTimeUnit()) + ->mapWithKeys(fn (DateUnit $unit): array => [$unit->value => $unit->getLabel()]) + ->all() + ) + ->default(DateUnit::Day->value) + ->required(), + ]) + ->columns(3) + ->columnSpanFull() + ->hidden(fn (Get $get): bool => $get('mode') !== 'relative' || $get('preset') !== 'custom'), ]; } + /** + * @return array + */ + protected function getPresetOptions(bool $hasTime): array + { + $options = [ + 'past_decade' => __('filament-query-builder::query-builder.operators.date.presets.past_decade'), + 'past_5_years' => __('filament-query-builder::query-builder.operators.date.presets.past_5_years'), + 'past_2_years' => __('filament-query-builder::query-builder.operators.date.presets.past_2_years'), + 'past_year' => __('filament-query-builder::query-builder.operators.date.presets.past_year'), + 'past_6_months' => __('filament-query-builder::query-builder.operators.date.presets.past_6_months'), + 'past_quarter' => __('filament-query-builder::query-builder.operators.date.presets.past_quarter'), + 'past_month' => __('filament-query-builder::query-builder.operators.date.presets.past_month'), + 'past_2_weeks' => __('filament-query-builder::query-builder.operators.date.presets.past_2_weeks'), + 'past_week' => __('filament-query-builder::query-builder.operators.date.presets.past_week'), + ]; + + if ($hasTime) { + $options['past_hour'] = __('filament-query-builder::query-builder.operators.date.presets.past_hour'); + $options['past_minute'] = __('filament-query-builder::query-builder.operators.date.presets.past_minute'); + } + + $options += [ + 'this_decade' => __('filament-query-builder::query-builder.operators.date.presets.this_decade'), + 'this_year' => __('filament-query-builder::query-builder.operators.date.presets.this_year'), + 'this_quarter' => __('filament-query-builder::query-builder.operators.date.presets.this_quarter'), + 'this_month' => __('filament-query-builder::query-builder.operators.date.presets.this_month'), + 'today' => __('filament-query-builder::query-builder.operators.date.presets.today'), + ]; + + if ($hasTime) { + $options['this_hour'] = __('filament-query-builder::query-builder.operators.date.presets.this_hour'); + $options['this_minute'] = __('filament-query-builder::query-builder.operators.date.presets.this_minute'); + $options['next_minute'] = __('filament-query-builder::query-builder.operators.date.presets.next_minute'); + $options['next_hour'] = __('filament-query-builder::query-builder.operators.date.presets.next_hour'); + } + + $options += [ + 'next_week' => __('filament-query-builder::query-builder.operators.date.presets.next_week'), + 'next_2_weeks' => __('filament-query-builder::query-builder.operators.date.presets.next_2_weeks'), + 'next_month' => __('filament-query-builder::query-builder.operators.date.presets.next_month'), + 'next_quarter' => __('filament-query-builder::query-builder.operators.date.presets.next_quarter'), + 'next_6_months' => __('filament-query-builder::query-builder.operators.date.presets.next_6_months'), + 'next_year' => __('filament-query-builder::query-builder.operators.date.presets.next_year'), + 'next_2_years' => __('filament-query-builder::query-builder.operators.date.presets.next_2_years'), + 'next_5_years' => __('filament-query-builder::query-builder.operators.date.presets.next_5_years'), + 'next_decade' => __('filament-query-builder::query-builder.operators.date.presets.next_decade'), + 'custom' => __('filament-query-builder::query-builder.operators.date.presets.custom'), + ]; + + return $options; + } + public function apply(Builder $query, string $qualifiedColumn): Builder { - return $query->whereDate($qualifiedColumn, $this->isInverse() ? '<' : '>=', $this->getSettings()['date']); + $settings = $this->getSettings(); + $constraint = $this->getConstraint(); + $hasTime = $constraint instanceof DateConstraint && $constraint->hasTime(); + + // Check if using relative mode - only if mode is explicitly set to 'relative' + if (($settings['mode'] ?? null) === 'relative') { + $dateTime = $this->resolveRelativeDate($settings, $hasTime); + + if ($hasTime && $this->isTimeBasedFilter($settings)) { + return $query->where($qualifiedColumn, $this->isInverse() ? '<' : '>=', $dateTime); + } + + return $query->whereDate($qualifiedColumn, $this->isInverse() ? '<' : '>=', $dateTime); + } + + return $query->whereDate($qualifiedColumn, $this->isInverse() ? '<' : '>=', $settings['date']); + } + + /** + * @param array $settings + */ + protected function isTimeBasedFilter(array $settings): bool + { + $preset = $settings['preset'] ?? 'custom'; + + if (in_array($preset, ['this_minute', 'this_hour', 'past_minute', 'past_hour', 'next_minute', 'next_hour'], true)) { + return true; + } + + if ($preset === 'custom') { + $unit = $settings['relative_unit'] ?? DateUnit::Day->value; + + return in_array($unit, [DateUnit::Second->value, DateUnit::Minute->value, DateUnit::Hour->value], true); + } + + return false; + } + + /** + * @param array $settings + */ + protected function resolveRelativeDate(array $settings, bool $hasTime = false): string + { + $preset = $settings['preset'] ?? 'custom'; + + return match ($preset) { + 'past_decade' => Carbon::now()->subYears(10)->toDateString(), + 'past_5_years' => Carbon::now()->subYears(5)->toDateString(), + 'past_2_years' => Carbon::now()->subYears(2)->toDateString(), + 'past_year' => Carbon::now()->subYear()->toDateString(), + 'past_6_months' => Carbon::now()->subMonths(6)->toDateString(), + 'past_quarter' => Carbon::now()->subQuarter()->toDateString(), + 'past_month' => Carbon::now()->subMonth()->toDateString(), + 'past_2_weeks' => Carbon::now()->subWeeks(2)->toDateString(), + 'past_week' => Carbon::now()->subWeek()->toDateString(), + 'past_hour' => Carbon::now()->subHour()->toDateTimeString(), + 'past_minute' => Carbon::now()->subMinute()->toDateTimeString(), + 'this_decade' => Carbon::now()->startOfDecade()->toDateString(), + 'this_year' => Carbon::now()->startOfYear()->toDateString(), + 'this_quarter' => Carbon::now()->startOfQuarter()->toDateString(), + 'this_month' => Carbon::now()->startOfMonth()->toDateString(), + 'today' => Carbon::today()->toDateString(), + 'this_hour' => Carbon::now()->startOfHour()->toDateTimeString(), + 'this_minute' => Carbon::now()->startOfMinute()->toDateTimeString(), + 'next_minute' => Carbon::now()->addMinute()->toDateTimeString(), + 'next_hour' => Carbon::now()->addHour()->toDateTimeString(), + 'next_week' => Carbon::now()->addWeek()->toDateString(), + 'next_2_weeks' => Carbon::now()->addWeeks(2)->toDateString(), + 'next_month' => Carbon::now()->addMonth()->toDateString(), + 'next_quarter' => Carbon::now()->addQuarter()->toDateString(), + 'next_6_months' => Carbon::now()->addMonths(6)->toDateString(), + 'next_year' => Carbon::now()->addYear()->toDateString(), + 'next_2_years' => Carbon::now()->addYears(2)->toDateString(), + 'next_5_years' => Carbon::now()->addYears(5)->toDateString(), + 'next_decade' => Carbon::now()->addYears(10)->toDateString(), + 'custom' => $this->resolveCustomRelativeDate($settings, $hasTime), + default => Carbon::today()->toDateString(), + }; + } + + /** + * @param array $settings + */ + protected function resolveCustomRelativeDate(array $settings, bool $hasTime = false): string + { + $value = (int) ($settings['relative_value'] ?? 1); + $unit = $settings['relative_unit'] ?? DateUnit::Day->value; + $tense = $settings['tense'] ?? 'past'; + + $method = $tense === 'future' ? 'add' : 'sub'; + + return match ($unit) { + DateUnit::Second->value => Carbon::now()->{$method . 'Seconds'}($value)->toDateTimeString(), + DateUnit::Minute->value => Carbon::now()->{$method . 'Minutes'}($value)->toDateTimeString(), + DateUnit::Hour->value => Carbon::now()->{$method . 'Hours'}($value)->toDateTimeString(), + DateUnit::Day->value => Carbon::now()->{$method . 'Days'}($value)->toDateString(), + DateUnit::Week->value => Carbon::now()->{$method . 'Weeks'}($value)->toDateString(), + DateUnit::Month->value => Carbon::now()->{$method . 'Months'}($value)->toDateString(), + DateUnit::Quarter->value => Carbon::now()->{$method . 'Quarters'}($value)->toDateString(), + DateUnit::Year->value => Carbon::now()->{$method . 'Years'}($value)->toDateString(), + default => Carbon::today()->toDateString(), + }; } } diff --git a/packages/query-builder/src/Constraints/DateConstraint/Operators/IsBeforeOperator.php b/packages/query-builder/src/Constraints/DateConstraint/Operators/IsBeforeOperator.php index 1d9cfd1acf..bd3af01fbd 100644 --- a/packages/query-builder/src/Constraints/DateConstraint/Operators/IsBeforeOperator.php +++ b/packages/query-builder/src/Constraints/DateConstraint/Operators/IsBeforeOperator.php @@ -4,9 +4,15 @@ namespace Filament\QueryBuilder\Constraints\DateConstraint\Operators; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\DateTimePicker; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\QueryBuilder\Constraints\DateConstraint; +use Filament\QueryBuilder\Constraints\DateConstraint\DateUnit; use Filament\QueryBuilder\Constraints\Operators\Operator; use Filament\Schemas\Components\Component; +use Filament\Schemas\Components\FusedGroup; +use Filament\Schemas\Components\Utilities\Get; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; @@ -28,13 +34,36 @@ class IsBeforeOperator extends Operator public function getSummary(): string { + $settings = $this->getSettings(); + $constraint = $this->getConstraint(); + $hasTime = $constraint instanceof DateConstraint && $constraint->hasTime(); + + // Check if using relative mode + if (($settings['mode'] ?? null) === 'relative') { + $resolvedDate = $this->resolveRelativeDate($settings, $hasTime); + $parsedDate = Carbon::parse($resolvedDate); + $isTimeBasedFilter = $this->isTimeBasedFilter($settings); + + return __( + $this->isInverse() ? + 'filament-query-builder::query-builder.operators.date.is_before.summary.inverse' : + 'filament-query-builder::query-builder.operators.date.is_before.summary.direct', + [ + 'attribute' => $constraint->getAttributeLabel(), + 'date' => $hasTime && $isTimeBasedFilter ? $parsedDate->toFormattedDayDateString() . ' ' . $parsedDate->format('H:i:s') : $parsedDate->toFormattedDateString(), + ], + ); + } + + $parsedDate = Carbon::parse($settings['date']); + return __( $this->isInverse() ? 'filament-query-builder::query-builder.operators.date.is_before.summary.inverse' : 'filament-query-builder::query-builder.operators.date.is_before.summary.direct', [ - 'attribute' => $this->getConstraint()->getAttributeLabel(), - 'date' => Carbon::parse($this->getSettings()['date'])->toFormattedDateString(), + 'attribute' => $constraint->getAttributeLabel(), + 'date' => $hasTime ? $parsedDate->toFormattedDayDateString() . ' ' . $parsedDate->format('H:i:s') : $parsedDate->toFormattedDateString(), ], ); } @@ -44,15 +73,221 @@ class IsBeforeOperator extends Operator */ public function getFormSchema(): array { + $constraint = $this->getConstraint(); + $hasTime = $constraint instanceof DateConstraint && $constraint->hasTime(); + return [ - DatePicker::make('date') + Select::make('mode') + ->label(__('filament-query-builder::query-builder.operators.date.form.mode.label')) + ->selectablePlaceholder(false) + ->live() + ->options([ + 'absolute' => __('filament-query-builder::query-builder.operators.date.form.mode.options.absolute'), + 'relative' => __('filament-query-builder::query-builder.operators.date.form.mode.options.relative'), + ]) + ->default('absolute'), + DateTimePicker::make('date') ->label(__('filament-query-builder::query-builder.operators.date.form.date.label')) - ->required(), + ->time($hasTime) + ->hidden(fn (Get $get): bool => $get('mode') === 'relative') + ->required(fn (Get $get): bool => $get('mode') !== 'relative'), + Select::make('preset') + ->label(__('filament-query-builder::query-builder.operators.date.form.preset.label')) + ->selectablePlaceholder(false) + ->live() + ->options($this->getPresetOptions($hasTime)) + ->default('past_month') + ->hidden(fn (Get $get): bool => $get('mode') !== 'relative') + ->required(fn (Get $get): bool => $get('mode') === 'relative'), + FusedGroup::make([ + Select::make('tense') + ->label(__('filament-query-builder::query-builder.operators.date.form.tense.label')) + ->selectablePlaceholder(false) + ->options([ + 'past' => __('filament-query-builder::query-builder.operators.date.form.tense.options.past'), + 'future' => __('filament-query-builder::query-builder.operators.date.form.tense.options.future'), + ]) + ->default('past') + ->required(), + TextInput::make('relative_value') + ->label(__('filament-query-builder::query-builder.operators.date.form.relative_value.label')) + ->numeric() + ->minValue(1) + ->default(1) + ->required(), + Select::make('relative_unit') + ->label(__('filament-query-builder::query-builder.operators.date.form.relative_unit.label')) + ->options( + collect(DateUnit::cases()) + ->reject(fn (DateUnit $unit): bool => (! $hasTime) && $unit->isTimeUnit()) + ->mapWithKeys(fn (DateUnit $unit): array => [$unit->value => $unit->getLabel()]) + ->all() + ) + ->default(DateUnit::Day->value) + ->required(), + ]) + ->columns(3) + ->columnSpanFull() + ->hidden(fn (Get $get): bool => $get('mode') !== 'relative' || $get('preset') !== 'custom'), ]; } + /** + * @return array + */ + protected function getPresetOptions(bool $hasTime): array + { + $options = [ + 'past_decade' => __('filament-query-builder::query-builder.operators.date.presets.past_decade'), + 'past_5_years' => __('filament-query-builder::query-builder.operators.date.presets.past_5_years'), + 'past_2_years' => __('filament-query-builder::query-builder.operators.date.presets.past_2_years'), + 'past_year' => __('filament-query-builder::query-builder.operators.date.presets.past_year'), + 'past_6_months' => __('filament-query-builder::query-builder.operators.date.presets.past_6_months'), + 'past_quarter' => __('filament-query-builder::query-builder.operators.date.presets.past_quarter'), + 'past_month' => __('filament-query-builder::query-builder.operators.date.presets.past_month'), + 'past_2_weeks' => __('filament-query-builder::query-builder.operators.date.presets.past_2_weeks'), + 'past_week' => __('filament-query-builder::query-builder.operators.date.presets.past_week'), + ]; + + if ($hasTime) { + $options['past_hour'] = __('filament-query-builder::query-builder.operators.date.presets.past_hour'); + $options['past_minute'] = __('filament-query-builder::query-builder.operators.date.presets.past_minute'); + } + + $options += [ + 'this_decade' => __('filament-query-builder::query-builder.operators.date.presets.this_decade'), + 'this_year' => __('filament-query-builder::query-builder.operators.date.presets.this_year'), + 'this_quarter' => __('filament-query-builder::query-builder.operators.date.presets.this_quarter'), + 'this_month' => __('filament-query-builder::query-builder.operators.date.presets.this_month'), + 'today' => __('filament-query-builder::query-builder.operators.date.presets.today'), + ]; + + if ($hasTime) { + $options['this_hour'] = __('filament-query-builder::query-builder.operators.date.presets.this_hour'); + $options['this_minute'] = __('filament-query-builder::query-builder.operators.date.presets.this_minute'); + $options['next_minute'] = __('filament-query-builder::query-builder.operators.date.presets.next_minute'); + $options['next_hour'] = __('filament-query-builder::query-builder.operators.date.presets.next_hour'); + } + + $options += [ + 'next_week' => __('filament-query-builder::query-builder.operators.date.presets.next_week'), + 'next_2_weeks' => __('filament-query-builder::query-builder.operators.date.presets.next_2_weeks'), + 'next_month' => __('filament-query-builder::query-builder.operators.date.presets.next_month'), + 'next_quarter' => __('filament-query-builder::query-builder.operators.date.presets.next_quarter'), + 'next_6_months' => __('filament-query-builder::query-builder.operators.date.presets.next_6_months'), + 'next_year' => __('filament-query-builder::query-builder.operators.date.presets.next_year'), + 'next_2_years' => __('filament-query-builder::query-builder.operators.date.presets.next_2_years'), + 'next_5_years' => __('filament-query-builder::query-builder.operators.date.presets.next_5_years'), + 'next_decade' => __('filament-query-builder::query-builder.operators.date.presets.next_decade'), + 'custom' => __('filament-query-builder::query-builder.operators.date.presets.custom'), + ]; + + return $options; + } + public function apply(Builder $query, string $qualifiedColumn): Builder { - return $query->whereDate($qualifiedColumn, $this->isInverse() ? '>' : '<=', $this->getSettings()['date']); + $settings = $this->getSettings(); + $constraint = $this->getConstraint(); + $hasTime = $constraint instanceof DateConstraint && $constraint->hasTime(); + + // Check if using relative mode - only if mode is explicitly set to 'relative' + if (($settings['mode'] ?? null) === 'relative') { + $dateTime = $this->resolveRelativeDate($settings, $hasTime); + + if ($hasTime && $this->isTimeBasedFilter($settings)) { + return $query->where($qualifiedColumn, $this->isInverse() ? '>' : '<=', $dateTime); + } + + return $query->whereDate($qualifiedColumn, $this->isInverse() ? '>' : '<=', $dateTime); + } + + return $query->whereDate($qualifiedColumn, $this->isInverse() ? '>' : '<=', $settings['date']); + } + + /** + * @param array $settings + */ + protected function isTimeBasedFilter(array $settings): bool + { + $preset = $settings['preset'] ?? 'custom'; + + if (in_array($preset, ['this_minute', 'this_hour', 'past_minute', 'past_hour', 'next_minute', 'next_hour'], true)) { + return true; + } + + if ($preset === 'custom') { + $unit = $settings['relative_unit'] ?? DateUnit::Day->value; + + return in_array($unit, [DateUnit::Second->value, DateUnit::Minute->value, DateUnit::Hour->value], true); + } + + return false; + } + + /** + * @param array $settings + */ + protected function resolveRelativeDate(array $settings, bool $hasTime = false): string + { + $preset = $settings['preset'] ?? 'custom'; + + return match ($preset) { + 'past_decade' => Carbon::now()->subYears(10)->toDateString(), + 'past_5_years' => Carbon::now()->subYears(5)->toDateString(), + 'past_2_years' => Carbon::now()->subYears(2)->toDateString(), + 'past_year' => Carbon::now()->subYear()->toDateString(), + 'past_6_months' => Carbon::now()->subMonths(6)->toDateString(), + 'past_quarter' => Carbon::now()->subQuarter()->toDateString(), + 'past_month' => Carbon::now()->subMonth()->toDateString(), + 'past_2_weeks' => Carbon::now()->subWeeks(2)->toDateString(), + 'past_week' => Carbon::now()->subWeek()->toDateString(), + 'past_hour' => Carbon::now()->subHour()->toDateTimeString(), + 'past_minute' => Carbon::now()->subMinute()->toDateTimeString(), + 'this_decade' => Carbon::now()->startOfDecade()->toDateString(), + 'this_year' => Carbon::now()->startOfYear()->toDateString(), + 'this_quarter' => Carbon::now()->startOfQuarter()->toDateString(), + 'this_month' => Carbon::now()->startOfMonth()->toDateString(), + 'today' => Carbon::today()->toDateString(), + 'this_hour' => Carbon::now()->startOfHour()->toDateTimeString(), + 'this_minute' => Carbon::now()->startOfMinute()->toDateTimeString(), + 'next_minute' => Carbon::now()->addMinute()->toDateTimeString(), + 'next_hour' => Carbon::now()->addHour()->toDateTimeString(), + 'next_week' => Carbon::now()->addWeek()->toDateString(), + 'next_2_weeks' => Carbon::now()->addWeeks(2)->toDateString(), + 'next_month' => Carbon::now()->addMonth()->toDateString(), + 'next_quarter' => Carbon::now()->addQuarter()->toDateString(), + 'next_6_months' => Carbon::now()->addMonths(6)->toDateString(), + 'next_year' => Carbon::now()->addYear()->toDateString(), + 'next_2_years' => Carbon::now()->addYears(2)->toDateString(), + 'next_5_years' => Carbon::now()->addYears(5)->toDateString(), + 'next_decade' => Carbon::now()->addYears(10)->toDateString(), + 'custom' => $this->resolveCustomRelativeDate($settings, $hasTime), + default => Carbon::today()->toDateString(), + }; + } + + /** + * @param array $settings + */ + protected function resolveCustomRelativeDate(array $settings, bool $hasTime = false): string + { + $value = (int) ($settings['relative_value'] ?? 1); + $unit = $settings['relative_unit'] ?? DateUnit::Day->value; + $tense = $settings['tense'] ?? 'past'; + + $method = $tense === 'future' ? 'add' : 'sub'; + + return match ($unit) { + DateUnit::Second->value => Carbon::now()->{$method . 'Seconds'}($value)->toDateTimeString(), + DateUnit::Minute->value => Carbon::now()->{$method . 'Minutes'}($value)->toDateTimeString(), + DateUnit::Hour->value => Carbon::now()->{$method . 'Hours'}($value)->toDateTimeString(), + DateUnit::Day->value => Carbon::now()->{$method . 'Days'}($value)->toDateString(), + DateUnit::Week->value => Carbon::now()->{$method . 'Weeks'}($value)->toDateString(), + DateUnit::Month->value => Carbon::now()->{$method . 'Months'}($value)->toDateString(), + DateUnit::Quarter->value => Carbon::now()->{$method . 'Quarters'}($value)->toDateString(), + DateUnit::Year->value => Carbon::now()->{$method . 'Years'}($value)->toDateString(), + default => Carbon::today()->toDateString(), + }; } } diff --git a/packages/tables/docs/03-filters/04-query-builder.md b/packages/tables/docs/03-filters/04-query-builder.md index dcce693669..257ba07e62 100644 --- a/packages/tables/docs/03-filters/04-query-builder.md +++ b/packages/tables/docs/03-filters/04-query-builder.md @@ -179,6 +179,17 @@ By default, the following operators are available: - Is year - filters a column to be in the same year as the searched year - Is not year - filters a column to not be in the same year as the searched year +#### Datetime constraints + +By default, date constraints only filter by date. If you have a datetime column and want to enable time-based filtering, you can use the `time()` method: + +```php +use Filament\QueryBuilder\Constraints\DateConstraint; + +DateConstraint::make('published_at') + ->time() +``` + ### Select constraints Select constraints allow you to filter fields using a select field. They can be used to filter any field, including via relationships. diff --git a/tests/.pest/snapshots/src/Forms/Commands/MakeFormCommandTest/it_can_generate_a_form_schema_class_for_a_model.snap b/tests/.pest/snapshots/src/Forms/Commands/MakeFormCommandTest/it_can_generate_a_form_schema_class_for_a_model.snap index e42036ffff..7df65abd70 100644 --- a/tests/.pest/snapshots/src/Forms/Commands/MakeFormCommandTest/it_can_generate_a_form_schema_class_for_a_model.snap +++ b/tests/.pest/snapshots/src/Forms/Commands/MakeFormCommandTest/it_can_generate_a_form_schema_class_for_a_model.snap @@ -2,6 +2,7 @@ namespace App\Filament\Schemas; +use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Textarea; @@ -39,6 +40,7 @@ class PostForm ->columnSpanFull(), Select::make('string_backed_enum') ->options(StringBackedEnum::class), + DateTimePicker::make('published_at'), ]); } } diff --git a/tests/.pest/snapshots/src/Forms/Commands/MakeLivewireFormCommandTest/it_can_generate_a_livewire_form_component_with_generated_fields.snap b/tests/.pest/snapshots/src/Forms/Commands/MakeLivewireFormCommandTest/it_can_generate_a_livewire_form_component_with_generated_fields.snap index f9744e4db7..b909a4d4ba 100644 --- a/tests/.pest/snapshots/src/Forms/Commands/MakeLivewireFormCommandTest/it_can_generate_a_livewire_form_component_with_generated_fields.snap +++ b/tests/.pest/snapshots/src/Forms/Commands/MakeLivewireFormCommandTest/it_can_generate_a_livewire_form_component_with_generated_fields.snap @@ -4,6 +4,7 @@ namespace App\Livewire; use Filament\Actions\Concerns\InteractsWithActions; use Filament\Actions\Contracts\HasActions; +use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Textarea; @@ -55,6 +56,7 @@ class CreatePostWithFields extends Component implements HasActions, HasSchemas ->columnSpanFull(), Select::make('string_backed_enum') ->options(StringBackedEnum::class), + DateTimePicker::make('published_at'), ]) ->statePath('data') ->model(Post::class); diff --git a/tests/.pest/snapshots/src/Panels/Commands/Legacy/LegacyMakeResourceCommandTest/it_can_generate_the_form_and_table_of_a_resource_class.snap b/tests/.pest/snapshots/src/Panels/Commands/Legacy/LegacyMakeResourceCommandTest/it_can_generate_the_form_and_table_of_a_resource_class.snap index 8e8d27699f..9d04fb1bba 100644 --- a/tests/.pest/snapshots/src/Panels/Commands/Legacy/LegacyMakeResourceCommandTest/it_can_generate_the_form_and_table_of_a_resource_class.snap +++ b/tests/.pest/snapshots/src/Panels/Commands/Legacy/LegacyMakeResourceCommandTest/it_can_generate_the_form_and_table_of_a_resource_class.snap @@ -47,6 +47,7 @@ class PostResource extends Resource ->columnSpanFull(), Forms\Components\Select::make('string_backed_enum') ->options(StringBackedEnum::class), + Forms\Components\DateTimePicker::make('published_at'), ]); } @@ -68,6 +69,9 @@ class PostResource extends Resource Tables\Columns\TextColumn::make('string_backed_enum') ->badge() ->searchable(), + Tables\Columns\TextColumn::make('published_at') + ->dateTime() + ->sortable(), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable() diff --git a/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_form__infolist__and_table_content_embedded_in_a_resource_class.snap b/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_form__infolist__and_table_content_embedded_in_a_resource_class.snap index d0e01355a4..0bbc5ee92d 100644 --- a/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_form__infolist__and_table_content_embedded_in_a_resource_class.snap +++ b/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_form__infolist__and_table_content_embedded_in_a_resource_class.snap @@ -11,6 +11,7 @@ use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Actions\ViewAction; +use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Textarea; @@ -59,6 +60,7 @@ class PostResource extends Resource ->columnSpanFull(), Select::make('string_backed_enum') ->options(StringBackedEnum::class), + DateTimePicker::make('published_at'), ]); } @@ -94,6 +96,9 @@ class PostResource extends Resource TextEntry::make('string_backed_enum') ->badge() ->placeholder('-'), + TextEntry::make('published_at') + ->dateTime() + ->placeholder('-'), TextEntry::make('created_at') ->dateTime() ->placeholder('-'), @@ -124,6 +129,9 @@ class PostResource extends Resource TextColumn::make('string_backed_enum') ->badge() ->searchable(), + TextColumn::make('published_at') + ->dateTime() + ->sortable(), TextColumn::make('created_at') ->dateTime() ->sortable() diff --git a/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_form_content.snap b/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_form_content.snap index cb596071f9..bde5246852 100644 --- a/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_form_content.snap +++ b/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_form_content.snap @@ -2,6 +2,7 @@ namespace App\Filament\Resources\Posts\Schemas; +use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Textarea; @@ -38,6 +39,7 @@ class PostForm ->columnSpanFull(), Select::make('string_backed_enum') ->options(StringBackedEnum::class), + DateTimePicker::make('published_at'), ]); } } diff --git a/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_infolist_content.snap b/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_infolist_content.snap index 6eea1e8e4d..1cbd6fe1ef 100644 --- a/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_infolist_content.snap +++ b/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_infolist_content.snap @@ -41,6 +41,9 @@ class PostInfolist TextEntry::make('string_backed_enum') ->badge() ->placeholder('-'), + TextEntry::make('published_at') + ->dateTime() + ->placeholder('-'), TextEntry::make('created_at') ->dateTime() ->placeholder('-'), diff --git a/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_table_content.snap b/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_table_content.snap index 9cef924ca0..708f8cc49e 100644 --- a/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_table_content.snap +++ b/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_table_content.snap @@ -29,6 +29,9 @@ class PostsTable TextColumn::make('string_backed_enum') ->badge() ->searchable(), + TextColumn::make('published_at') + ->dateTime() + ->sortable(), TextColumn::make('created_at') ->dateTime() ->sortable() diff --git a/tests/.pest/snapshots/src/Tables/Commands/MakeLivewireTableCommandTest/it_can_generate_a_Livewire_table_component_with_generated_columns.snap b/tests/.pest/snapshots/src/Tables/Commands/MakeLivewireTableCommandTest/it_can_generate_a_Livewire_table_component_with_generated_columns.snap index 7c331d4c85..9e0d01c543 100644 --- a/tests/.pest/snapshots/src/Tables/Commands/MakeLivewireTableCommandTest/it_can_generate_a_Livewire_table_component_with_generated_columns.snap +++ b/tests/.pest/snapshots/src/Tables/Commands/MakeLivewireTableCommandTest/it_can_generate_a_Livewire_table_component_with_generated_columns.snap @@ -42,6 +42,9 @@ class ListPostsWithColumns extends Component implements HasActions, HasSchemas, TextColumn::make('string_backed_enum') ->badge() ->searchable(), + TextColumn::make('published_at') + ->dateTime() + ->sortable(), TextColumn::make('created_at') ->dateTime() ->sortable() diff --git a/tests/.pest/snapshots/src/Tables/Commands/MakeLivewireTableCommandTest/it_can_generate_a_Livewire_table_component_with_generated_columns__2.snap b/tests/.pest/snapshots/src/Tables/Commands/MakeLivewireTableCommandTest/it_can_generate_a_Livewire_table_component_with_generated_columns__2.snap index 7c331d4c85..9e0d01c543 100644 --- a/tests/.pest/snapshots/src/Tables/Commands/MakeLivewireTableCommandTest/it_can_generate_a_Livewire_table_component_with_generated_columns__2.snap +++ b/tests/.pest/snapshots/src/Tables/Commands/MakeLivewireTableCommandTest/it_can_generate_a_Livewire_table_component_with_generated_columns__2.snap @@ -42,6 +42,9 @@ class ListPostsWithColumns extends Component implements HasActions, HasSchemas, TextColumn::make('string_backed_enum') ->badge() ->searchable(), + TextColumn::make('published_at') + ->dateTime() + ->sortable(), TextColumn::make('created_at') ->dateTime() ->sortable() diff --git a/tests/database/migrations/0004_create_posts_table.php b/tests/database/migrations/0004_create_posts_table.php index e88933d48c..02a559c0f9 100644 --- a/tests/database/migrations/0004_create_posts_table.php +++ b/tests/database/migrations/0004_create_posts_table.php @@ -21,6 +21,7 @@ return new class extends Migration $table->json('json')->nullable(); $table->json('json_array_of_objects')->nullable(); $table->string('string_backed_enum')->nullable(); + $table->timestamp('published_at')->nullable(); $table->timestamps(); $table->softDeletes(); }); diff --git a/tests/src/Fixtures/Livewire/PostsQueryBuilderTable.php b/tests/src/Fixtures/Livewire/PostsQueryBuilderTable.php index 6efbf8cd69..b68865349a 100644 --- a/tests/src/Fixtures/Livewire/PostsQueryBuilderTable.php +++ b/tests/src/Fixtures/Livewire/PostsQueryBuilderTable.php @@ -52,6 +52,8 @@ class PostsQueryBuilderTable extends Component implements HasActions, HasSchemas ->attribute('rating') ->integer(), DateConstraint::make('created_at'), + DateConstraint::make('published_at') + ->time(), SelectConstraint::make('rating_select') ->attribute('rating') ->options([ diff --git a/tests/src/Tables/Filters/QueryBuilderTest.php b/tests/src/Tables/Filters/QueryBuilderTest.php index 805e77f2ec..4c787d0127 100644 --- a/tests/src/Tables/Filters/QueryBuilderTest.php +++ b/tests/src/Tables/Filters/QueryBuilderTest.php @@ -2184,3 +2184,1763 @@ it('can filter records using number constraint with aggregate and inverse operat ->assertCanSeeTableRecords([$lowAvgUser]) ->assertCanNotSeeTableRecords([$highAvgUser]); }); + +// Relative Date Filtering Tests - IsAfterOperator + +it('can filter records using date constraint with is after operator in `absolute` mode', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(5), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(5), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'absolute', + 'date' => now()->toDateString(), + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using date constraint with is after operator with `today` preset', function (): void { + $todayPosts = Post::factory()->count(3)->create([ + 'created_at' => now()->startOfDay(), + ]); + + $futurePosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(5), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(5), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($todayPosts->merge($futurePosts)->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'today', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($todayPosts->merge($futurePosts)) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using date constraint with is after operator with `this_year` preset', function (): void { + $thisYearPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->startOfYear()->addDays(30), + ]); + + $lastYearPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subYear(), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($thisYearPosts->merge($lastYearPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'this_year', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($thisYearPosts) + ->assertCanNotSeeTableRecords($lastYearPosts); +}); + +it('can filter records using date constraint with is after operator with `past_week` preset', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(3), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(10), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'past_week', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using date constraint with is after operator with `past_2_weeks` preset', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(10), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(20), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'past_2_weeks', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using date constraint with is after operator with `past_month` preset', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(15), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(45), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'past_month', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using date constraint with is after operator with `past_quarter` preset', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subMonths(2), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subMonths(4), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'past_quarter', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using date constraint with is after operator with `past_6_months` preset', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subMonths(4), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subMonths(8), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'past_6_months', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using date constraint with is after operator with `past_year` preset', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subMonths(6), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subMonths(18), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'past_year', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using date constraint with is after operator with `next_week` preset', function (): void { + $farFuturePosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(14), + ]); + + $nearFuturePosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(3), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($farFuturePosts->merge($nearFuturePosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'next_week', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($farFuturePosts) + ->assertCanNotSeeTableRecords($nearFuturePosts); +}); + +it('can filter records using date constraint with is after operator with `next_month` preset', function (): void { + $farFuturePosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addMonths(2), + ]); + + $nearFuturePosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(15), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($farFuturePosts->merge($nearFuturePosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'next_month', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($farFuturePosts) + ->assertCanNotSeeTableRecords($nearFuturePosts); +}); + +it('can filter records using date constraint with is after operator with custom relative `day` unit past', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(3), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(15), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'past', + 'relative_value' => 10, + 'relative_unit' => 'day', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using date constraint with is after operator with custom relative `week` unit past', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(10), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subWeeks(5), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'past', + 'relative_value' => 3, + 'relative_unit' => 'week', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using date constraint with is after operator with custom relative `month` unit past', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subMonths(2), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subMonths(6), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'past', + 'relative_value' => 4, + 'relative_unit' => 'month', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using date constraint with is after operator with custom relative `year` unit past', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subYear(), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subYears(4), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'past', + 'relative_value' => 2, + 'relative_unit' => 'year', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using date constraint with is after operator with custom relative `day` unit future', function (): void { + $farFuturePosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(20), + ]); + + $nearFuturePosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(3), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($farFuturePosts->merge($nearFuturePosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'future', + 'relative_value' => 10, + 'relative_unit' => 'day', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($farFuturePosts) + ->assertCanNotSeeTableRecords($nearFuturePosts); +}); + +it('can filter records using date constraint with is after operator with custom relative `month` unit future', function (): void { + $farFuturePosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addMonths(5), + ]); + + $nearFuturePosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addMonth(), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($farFuturePosts->merge($nearFuturePosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'future', + 'relative_value' => 3, + 'relative_unit' => 'month', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($farFuturePosts) + ->assertCanNotSeeTableRecords($nearFuturePosts); +}); + +it('can filter records using date constraint with is after operator inverse with preset', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(3), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(15), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter.inverse', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'past_week', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($oldPosts) + ->assertCanNotSeeTableRecords($recentPosts); +}); + +it('can filter records using date constraint with is after operator inverse with custom relative', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(3), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(20), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter.inverse', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'past', + 'relative_value' => 10, + 'relative_unit' => 'day', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($oldPosts) + ->assertCanNotSeeTableRecords($recentPosts); +}); + +// Relative Date Filtering Tests - IsBeforeOperator + +it('can filter records using date constraint with is before operator in `absolute` mode', function (): void { + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(5), + ]); + + $futurePosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(5), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($oldPosts->merge($futurePosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'absolute', + 'date' => now()->toDateString(), + ], + ], + ], + ])) + ->assertCanSeeTableRecords($oldPosts) + ->assertCanNotSeeTableRecords($futurePosts); +}); + +it('can filter records using date constraint with is before operator with `today` preset', function (): void { + $todayPosts = Post::factory()->count(3)->create([ + 'created_at' => now()->startOfDay(), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(5), + ]); + + $futurePosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(5), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($todayPosts->merge($oldPosts)->merge($futurePosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'today', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($todayPosts->merge($oldPosts)) + ->assertCanNotSeeTableRecords($futurePosts); +}); + +it('can filter records using date constraint with is before operator with `next_week` preset', function (): void { + $soonPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(3), + ]); + + $laterPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(14), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($soonPosts->merge($laterPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'next_week', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($soonPosts) + ->assertCanNotSeeTableRecords($laterPosts); +}); + +it('can filter records using date constraint with is before operator with `next_2_weeks` preset', function (): void { + $soonPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(7), + ]); + + $laterPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(21), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($soonPosts->merge($laterPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'next_2_weeks', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($soonPosts) + ->assertCanNotSeeTableRecords($laterPosts); +}); + +it('can filter records using date constraint with is before operator with `next_month` preset', function (): void { + $soonPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(15), + ]); + + $laterPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addMonths(2), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($soonPosts->merge($laterPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'next_month', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($soonPosts) + ->assertCanNotSeeTableRecords($laterPosts); +}); + +it('can filter records using date constraint with is before operator with `next_quarter` preset', function (): void { + $soonPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addMonths(2), + ]); + + $laterPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addMonths(5), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($soonPosts->merge($laterPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'next_quarter', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($soonPosts) + ->assertCanNotSeeTableRecords($laterPosts); +}); + +it('can filter records using date constraint with is before operator with `next_6_months` preset', function (): void { + $soonPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addMonths(4), + ]); + + $laterPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addMonths(8), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($soonPosts->merge($laterPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'next_6_months', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($soonPosts) + ->assertCanNotSeeTableRecords($laterPosts); +}); + +it('can filter records using date constraint with is before operator with `next_year` preset', function (): void { + $soonPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addMonths(6), + ]); + + $laterPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addMonths(18), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($soonPosts->merge($laterPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'next_year', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($soonPosts) + ->assertCanNotSeeTableRecords($laterPosts); +}); + +it('can filter records using date constraint with is before operator with `past_week` preset', function (): void { + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(14), + ]); + + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(3), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($oldPosts->merge($recentPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'past_week', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($oldPosts) + ->assertCanNotSeeTableRecords($recentPosts); +}); + +it('can filter records using date constraint with is before operator with `past_month` preset', function (): void { + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subMonths(2), + ]); + + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(15), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($oldPosts->merge($recentPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'past_month', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($oldPosts) + ->assertCanNotSeeTableRecords($recentPosts); +}); + +it('can filter records using date constraint with is before operator with custom relative `day` unit future', function (): void { + $soonPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(3), + ]); + + $laterPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(20), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($soonPosts->merge($laterPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'future', + 'relative_value' => 10, + 'relative_unit' => 'day', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($soonPosts) + ->assertCanNotSeeTableRecords($laterPosts); +}); + +it('can filter records using date constraint with is before operator with custom relative `week` unit future', function (): void { + $soonPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(7), + ]); + + $laterPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addWeeks(5), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($soonPosts->merge($laterPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'future', + 'relative_value' => 3, + 'relative_unit' => 'week', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($soonPosts) + ->assertCanNotSeeTableRecords($laterPosts); +}); + +it('can filter records using date constraint with is before operator with custom relative `month` unit future', function (): void { + $soonPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addMonths(2), + ]); + + $laterPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addMonths(6), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($soonPosts->merge($laterPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'future', + 'relative_value' => 4, + 'relative_unit' => 'month', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($soonPosts) + ->assertCanNotSeeTableRecords($laterPosts); +}); + +it('can filter records using date constraint with is before operator with custom relative `year` unit future', function (): void { + $soonPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addYear(), + ]); + + $laterPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addYears(4), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($soonPosts->merge($laterPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'future', + 'relative_value' => 2, + 'relative_unit' => 'year', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($soonPosts) + ->assertCanNotSeeTableRecords($laterPosts); +}); + +it('can filter records using date constraint with is before operator with custom relative `day` unit past', function (): void { + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(20), + ]); + + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(3), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($oldPosts->merge($recentPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'past', + 'relative_value' => 10, + 'relative_unit' => 'day', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($oldPosts) + ->assertCanNotSeeTableRecords($recentPosts); +}); + +it('can filter records using date constraint with is before operator with custom relative `month` unit past', function (): void { + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subMonths(6), + ]); + + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subMonths(2), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($oldPosts->merge($recentPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'past', + 'relative_value' => 4, + 'relative_unit' => 'month', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($oldPosts) + ->assertCanNotSeeTableRecords($recentPosts); +}); + +it('can filter records using date constraint with is before operator inverse with preset', function (): void { + $soonPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(3), + ]); + + $laterPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(14), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($soonPosts->merge($laterPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore.inverse', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'next_week', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($laterPosts) + ->assertCanNotSeeTableRecords($soonPosts); +}); + +it('can filter records using date constraint with is before operator inverse with custom relative', function (): void { + $soonPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(5), + ]); + + $laterPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(25), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($soonPosts->merge($laterPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore.inverse', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'future', + 'relative_value' => 15, + 'relative_unit' => 'day', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($laterPosts) + ->assertCanNotSeeTableRecords($soonPosts); +}); + +// Combined relative date tests + +it('can filter records using combined `relative` date constraints', function (): void { + // Posts created in the last month but before next week + $matchingPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(10), + ]); + + // Posts created more than a month ago + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subMonths(2), + ]); + + // Posts created in the future + $futurePosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addWeeks(2), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($matchingPosts->merge($oldPosts)->merge($futurePosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'past_month', + ], + ], + ], + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'next_week', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($matchingPosts) + ->assertCanNotSeeTableRecords($oldPosts->merge($futurePosts)); +}); + +it('can filter records using `relative` date constraint with relationship', function (): void { + $recentAuthor = User::factory()->create(['email_verified_at' => now()->subDays(5)]); + $recentPosts = Post::factory()->count(5)->create(['author_id' => $recentAuthor->id]); + + $oldAuthor = User::factory()->create(['email_verified_at' => now()->subMonths(3)]); + $oldPosts = Post::factory()->count(5)->create(['author_id' => $oldAuthor->id]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'author.email_verified_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'past_month', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using `relative` date constraint with OR rules', function (): void { + // Posts created in the past week + $recentPosts = Post::factory()->count(3)->create([ + 'created_at' => now()->subDays(3), + 'is_published' => false, + ]); + + // Published posts (regardless of date) + $publishedPosts = Post::factory()->count(3)->create([ + 'created_at' => now()->subMonths(3), + 'is_published' => true, + ]); + + // Old unpublished posts + $oldUnpublishedPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subMonths(2), + 'is_published' => false, + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($publishedPosts)->merge($oldUnpublishedPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'or', + 'data' => [ + 'groups' => [ + [ + 'rules' => [ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'past_week', + ], + ], + ], + ], + ], + [ + 'rules' => [ + [ + 'type' => 'is_published', + 'data' => [ + 'operator' => 'isTrue', + 'settings' => [], + ], + ], + ], + ], + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts->merge($publishedPosts)) + ->assertCanNotSeeTableRecords($oldUnpublishedPosts); +}); + +it('can filter records using `relative` date without `mode` defaults to `absolute` for backwards compatibility', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->addDays(5), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'created_at' => now()->subDays(5), + ]); + + // When mode is not specified, it should default to absolute behavior + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'created_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => ['date' => now()->toDateString()], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +// Time-Based Date Filtering Tests + +it('can filter records using datetime constraint with is after operator with `this_minute` preset', function (): void { + $currentMinutePosts = Post::factory()->count(3)->create([ + 'published_at' => now()->startOfMinute()->addSeconds(30), + ]); + + $futurePosts = Post::factory()->count(5)->create([ + 'published_at' => now()->addMinutes(5), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subMinutes(5), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($currentMinutePosts->merge($futurePosts)->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'this_minute', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($currentMinutePosts->merge($futurePosts)) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using datetime constraint with is after operator with `this_hour` preset', function (): void { + $currentHourPosts = Post::factory()->count(3)->create([ + 'published_at' => now()->startOfHour()->addMinutes(30), + ]); + + $futurePosts = Post::factory()->count(5)->create([ + 'published_at' => now()->addHours(5), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subHours(5), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($currentHourPosts->merge($futurePosts)->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'this_hour', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($currentHourPosts->merge($futurePosts)) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using datetime constraint with is after operator with `past_minute` preset', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subSeconds(30), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subMinutes(5), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'past_minute', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using datetime constraint with is after operator with `past_hour` preset', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subMinutes(30), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subHours(5), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'past_hour', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using datetime constraint with is after operator with `next_minute` preset', function (): void { + $farFuturePosts = Post::factory()->count(5)->create([ + 'published_at' => now()->addMinutes(5), + ]); + + $nearFuturePosts = Post::factory()->count(5)->create([ + 'published_at' => now()->addSeconds(30), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($farFuturePosts->merge($nearFuturePosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'next_minute', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($farFuturePosts) + ->assertCanNotSeeTableRecords($nearFuturePosts); +}); + +it('can filter records using datetime constraint with is after operator with `next_hour` preset', function (): void { + $farFuturePosts = Post::factory()->count(5)->create([ + 'published_at' => now()->addHours(5), + ]); + + $nearFuturePosts = Post::factory()->count(5)->create([ + 'published_at' => now()->addMinutes(30), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($farFuturePosts->merge($nearFuturePosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'next_hour', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($farFuturePosts) + ->assertCanNotSeeTableRecords($nearFuturePosts); +}); + +it('can filter records using datetime constraint with is after operator with `custom` preset and `second` unit in past tense', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subSeconds(15), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subMinutes(5), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'past', + 'relative_value' => 30, + 'relative_unit' => 'second', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using datetime constraint with is after operator with `custom` preset and `minute` unit in past tense', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subMinutes(5), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subMinutes(30), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'past', + 'relative_value' => 10, + 'relative_unit' => 'minute', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using datetime constraint with is after operator with `custom` preset and `hour` unit in past tense', function (): void { + $recentPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subHours(1), + ]); + + $oldPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subHours(5), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($recentPosts->merge($oldPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'past', + 'relative_value' => 3, + 'relative_unit' => 'hour', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($recentPosts) + ->assertCanNotSeeTableRecords($oldPosts); +}); + +it('can filter records using datetime constraint with is after operator with `custom` preset and `second` unit in future tense', function (): void { + $farFuturePosts = Post::factory()->count(5)->create([ + 'published_at' => now()->addMinutes(5), + ]); + + $nearFuturePosts = Post::factory()->count(5)->create([ + 'published_at' => now()->addSeconds(15), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($farFuturePosts->merge($nearFuturePosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'future', + 'relative_value' => 30, + 'relative_unit' => 'second', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($farFuturePosts) + ->assertCanNotSeeTableRecords($nearFuturePosts); +}); + +it('can filter records using datetime constraint with is after operator with `custom` preset and `minute` unit in future tense', function (): void { + $farFuturePosts = Post::factory()->count(5)->create([ + 'published_at' => now()->addMinutes(30), + ]); + + $nearFuturePosts = Post::factory()->count(5)->create([ + 'published_at' => now()->addMinutes(5), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($farFuturePosts->merge($nearFuturePosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'future', + 'relative_value' => 10, + 'relative_unit' => 'minute', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($farFuturePosts) + ->assertCanNotSeeTableRecords($nearFuturePosts); +}); + +it('can filter records using datetime constraint with is after operator with `custom` preset and `hour` unit in future tense', function (): void { + $farFuturePosts = Post::factory()->count(5)->create([ + 'published_at' => now()->addHours(5), + ]); + + $nearFuturePosts = Post::factory()->count(5)->create([ + 'published_at' => now()->addHours(1), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($farFuturePosts->merge($nearFuturePosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isAfter', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'future', + 'relative_value' => 3, + 'relative_unit' => 'hour', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($farFuturePosts) + ->assertCanNotSeeTableRecords($nearFuturePosts); +}); + +// IsBeforeOperator Time-Based Tests + +it('can filter records using datetime constraint with is before operator with `this_minute` preset', function (): void { + $oldPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subMinutes(5), + ]); + + $currentMinutePosts = Post::factory()->count(3)->create([ + 'published_at' => now()->startOfMinute()->addSeconds(30), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($oldPosts->merge($currentMinutePosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'this_minute', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($oldPosts) + ->assertCanNotSeeTableRecords($currentMinutePosts); +}); + +it('can filter records using datetime constraint with is before operator with `this_hour` preset', function (): void { + $oldPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subHours(5), + ]); + + $currentHourPosts = Post::factory()->count(3)->create([ + 'published_at' => now()->startOfHour()->addMinutes(30), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($oldPosts->merge($currentHourPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'this_hour', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($oldPosts) + ->assertCanNotSeeTableRecords($currentHourPosts); +}); + +it('can filter records using datetime constraint with is before operator with `past_minute` preset', function (): void { + $veryOldPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subMinutes(5), + ]); + + $recentPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subSeconds(30), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($veryOldPosts->merge($recentPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'past_minute', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($veryOldPosts) + ->assertCanNotSeeTableRecords($recentPosts); +}); + +it('can filter records using datetime constraint with is before operator with `past_hour` preset', function (): void { + $veryOldPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subHours(5), + ]); + + $recentPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subMinutes(30), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($veryOldPosts->merge($recentPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'past_hour', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($veryOldPosts) + ->assertCanNotSeeTableRecords($recentPosts); +}); + +it('can filter records using datetime constraint with is before operator with `custom` preset and `second` unit', function (): void { + $veryOldPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subMinutes(5), + ]); + + $recentPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subSeconds(15), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($veryOldPosts->merge($recentPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'past', + 'relative_value' => 30, + 'relative_unit' => 'second', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($veryOldPosts) + ->assertCanNotSeeTableRecords($recentPosts); +}); + +it('can filter records using datetime constraint with is before operator with `custom` preset and `minute` unit', function (): void { + $veryOldPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subMinutes(30), + ]); + + $recentPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subMinutes(5), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($veryOldPosts->merge($recentPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'past', + 'relative_value' => 10, + 'relative_unit' => 'minute', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($veryOldPosts) + ->assertCanNotSeeTableRecords($recentPosts); +}); + +it('can filter records using datetime constraint with is before operator with `custom` preset and `hour` unit', function (): void { + $veryOldPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subHours(5), + ]); + + $recentPosts = Post::factory()->count(5)->create([ + 'published_at' => now()->subHours(1), + ]); + + livewire(PostsQueryBuilderTable::class) + ->assertCanSeeTableRecords($veryOldPosts->merge($recentPosts)) + ->tap(applyQueryBuilderFilter([ + [ + 'type' => 'published_at', + 'data' => [ + 'operator' => 'isBefore', + 'settings' => [ + 'mode' => 'relative', + 'preset' => 'custom', + 'tense' => 'past', + 'relative_value' => 3, + 'relative_unit' => 'hour', + ], + ], + ], + ])) + ->assertCanSeeTableRecords($veryOldPosts) + ->assertCanNotSeeTableRecords($recentPosts); +});