Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion app/Services/BrandService.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Kirki\Ecommerce\App\Concerns\HasSortableColumns;
use Kirki\Ecommerce\App\Models\Brand;
use Kirki\Ecommerce\App\Supports\ListPaginator;
use Kirki\Ecommerce\App\Constants\Pagination;
use Kirki\Ecommerce\Framework\Collections\Collection;
use Kirki\Ecommerce\Framework\Database\Query\Paginator;
Expand Down Expand Up @@ -58,7 +59,9 @@ protected function sortable_columns()
*/
public function paginated(ListFilterDTO $filters)
{
return $this->list_query($filters)->paginate($filters->limit ?? Pagination::LIMIT, $filters->page ?? 1);
$page = $this->list_query($filters)->paginate($filters->limit ?? Pagination::LIMIT, $filters->page ?? 1);

return ListPaginator::from($page, Brand::query());
}

/**
Expand Down
5 changes: 4 additions & 1 deletion app/Services/CategoryService.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Kirki\Ecommerce\App\Concerns\HasSortableColumns;
use Kirki\Ecommerce\App\Models\Category;
use Kirki\Ecommerce\App\Supports\ListPaginator;
use Kirki\Ecommerce\App\Constants\Pagination;
use Kirki\Ecommerce\Framework\Collections\Collection;
use Kirki\Ecommerce\Framework\Database\Query\Paginator;
Expand Down Expand Up @@ -70,7 +71,9 @@ protected function sortable_columns()
*/
public function paginated(ListFilterDTO $filters)
{
return $this->list_query($filters)->paginate($filters->limit ?? Pagination::LIMIT, $filters->page ?? 1);
$page = $this->list_query($filters)->paginate($filters->limit ?? Pagination::LIMIT, $filters->page ?? 1);

return ListPaginator::from($page, Category::query());
}

/**
Expand Down
5 changes: 4 additions & 1 deletion app/Services/CollectionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Kirki\Ecommerce\App\Concerns\HasSortableColumns;
use Kirki\Ecommerce\App\Models\Collection;
use Kirki\Ecommerce\App\Supports\ListPaginator;
use Kirki\Ecommerce\App\Constants\Pagination;
use Kirki\Ecommerce\App\DTO\Collection\CreateCollectionDTO;
use Kirki\Ecommerce\App\DTO\Collection\UpdateCollectionDTO;
Expand Down Expand Up @@ -82,7 +83,9 @@ protected function sortable_columns()
*/
public function paginated(ListFilterDTO $filters)
{
return $this->list_query($filters)->paginate($filters->limit ?? Pagination::LIMIT, $filters->page ?? 1);
$page = $this->list_query($filters)->paginate($filters->limit ?? Pagination::LIMIT, $filters->page ?? 1);

return ListPaginator::from($page, Collection::query());
}

/**
Expand Down
5 changes: 4 additions & 1 deletion app/Services/CouponService.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Kirki\Ecommerce\App\Constants\Pagination;
use Kirki\Ecommerce\App\DTO\Coupon\CouponFilterDTO;
use Kirki\Ecommerce\App\Models\Coupon;
use Kirki\Ecommerce\App\Supports\ListPaginator;
use Kirki\Ecommerce\Framework\Database\Query\Paginator;
use Kirki\Ecommerce\Framework\Database\Query\QueryBuilder;
use Kirki\Ecommerce\Framework\Collections\Collection;
Expand Down Expand Up @@ -81,7 +82,9 @@ protected function sortable_columns()
*/
public function paginated(CouponFilterDTO $filters)
{
return $this->list_query($filters)->paginate($filters->limit ?? Pagination::LIMIT, $filters->page ?? 1);
$page = $this->list_query($filters)->paginate($filters->limit ?? Pagination::LIMIT, $filters->page ?? 1);

return ListPaginator::from($page, Coupon::query());
}

/**
Expand Down
5 changes: 4 additions & 1 deletion app/Services/CustomerService.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Kirki\Ecommerce\App\Models\Address;
use Kirki\Ecommerce\App\Constants\Order\PaymentStatus;
use Kirki\Ecommerce\App\Models\Customer;
use Kirki\Ecommerce\App\Supports\ListPaginator;
use Kirki\Ecommerce\App\Constants\Pagination;
use Kirki\Ecommerce\Framework\Database\Query\Paginator;
use Kirki\Ecommerce\Framework\Database\Query\QueryBuilder;
Expand Down Expand Up @@ -72,7 +73,9 @@ protected function sortable_columns()
*/
public function paginated(ListFilterDTO $filters)
{
return $this->list_query($filters)->paginate($filters->limit ?? Pagination::LIMIT, $filters->page ?? 1);
$page = $this->list_query($filters)->paginate($filters->limit ?? Pagination::LIMIT, $filters->page ?? 1);

return ListPaginator::from($page, Customer::query());
}

/**
Expand Down
5 changes: 3 additions & 2 deletions app/Services/DashboardService.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ protected function get_metrics()
'COUNT(*) AS orders_count, '
. 'COALESCE(SUM(base_total), 0) AS base_total, '
. 'COALESCE(SUM(base_tax_total), 0) AS base_tax_total, '
. 'COALESCE(SUM(base_shipping_total), 0) AS base_shipping_total'
. 'COALESCE(SUM(base_shipping_total), 0) AS base_shipping_total, '
. 'COALESCE(SUM(base_shipping_tax_amount), 0) AS base_shipping_tax_amount'
)
->first();

Expand All @@ -84,7 +85,7 @@ protected function get_metrics()
$base_total_sales = (int) ($totals->base_total ?? 0);
$base_net_sales = $base_total_sales
- (int) ($totals->base_tax_total ?? 0)
- (int) ($totals->base_shipping_total ?? 0)
- ((int) ($totals->base_shipping_total ?? 0) - (int) ($totals->base_shipping_tax_amount ?? 0))
- $this->get_base_refunded_total();

return [
Expand Down
9 changes: 8 additions & 1 deletion app/Services/OrderService.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use Kirki\Ecommerce\App\DTO\Order\UpdateOrderDTO;
use Kirki\Ecommerce\App\DTO\Order\UpdateOrderItemDTO;
use Kirki\Ecommerce\App\Resources\Site\Order\OrderListResource;
use Kirki\Ecommerce\App\Supports\ListPaginator;
use Kirki\Ecommerce\App\Supports\OrderNumberGenerator;
use Kirki\Ecommerce\Framework\Exceptions\NotFoundException;
use Kirki\Ecommerce\Framework\Http\Response;
Expand Down Expand Up @@ -117,9 +118,15 @@ public function get_current_customer_orders($filters)
*/
public function paginated_orders(OrderListFilterDTO $dto)
{
return $this->list_query($dto)
$page = $this->list_query($dto)
->with('items')
->paginate($dto->limit ?? Pagination::LIMIT, $dto->page ?? 1);

$has_records = Order::query()->when(is_numeric($dto->customer_id), function (QueryBuilder $query) use ($dto) {
return $query->where('customer_id', $dto->customer_id);
});
Comment on lines +125 to +127

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this?


return ListPaginator::from($page, $has_records);
}

/**
Expand Down
5 changes: 4 additions & 1 deletion app/Services/ProductService.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Kirki\Ecommerce\App\DTO\Product\UpdateProductDTO;
use Kirki\Ecommerce\App\DTO\Product\CreateProductDTO;
use Kirki\Ecommerce\App\Jobs\PublishScheduledProductJob;
use Kirki\Ecommerce\App\Supports\ListPaginator;
use Kirki\Ecommerce\App\Supports\Url;
use Kirki\Ecommerce\Framework\Exceptions\NotFoundException;
use Kirki\Ecommerce\Framework\Http\Response;
Expand Down Expand Up @@ -70,7 +71,9 @@ public function paginated(ProductListFilterDTO $filters)
{
$query = $this->list_query();

return $this->apply_filters($query, $filters)->paginate($filters->limit ?? Pagination::LIMIT, $filters->page ?? 1);
$page = $this->apply_filters($query, $filters)->paginate($filters->limit ?? Pagination::LIMIT, $filters->page ?? 1);

return ListPaginator::from($page, Product::query()->where_not('status', ProductStatus::TRASHED));
}

/**
Expand Down
5 changes: 4 additions & 1 deletion app/Services/TagService.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Kirki\Ecommerce\App\Concerns\HasSortableColumns;
use Kirki\Ecommerce\App\Models\Tag;
use Kirki\Ecommerce\App\Supports\ListPaginator;
use Kirki\Ecommerce\App\Constants\Pagination;
use Kirki\Ecommerce\Framework\Collections\Collection;
use Kirki\Ecommerce\Framework\Database\Query\Paginator;
Expand Down Expand Up @@ -58,7 +59,9 @@ protected function sortable_columns()
*/
public function paginated(ListFilterDTO $filters)
{
return $this->list_query($filters)->paginate($filters->limit ?? Pagination::LIMIT, $filters->page ?? 1);
$page = $this->list_query($filters)->paginate($filters->limit ?? Pagination::LIMIT, $filters->page ?? 1);

return ListPaginator::from($page, Tag::query());
}

/**
Expand Down
5 changes: 4 additions & 1 deletion app/Services/VariantService.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Kirki\Ecommerce\App\Constants\Product\ProductStatus;
use Kirki\Ecommerce\App\DTO\Variant\VariantListFilterDTO;
use Kirki\Ecommerce\App\Models\Variant;
use Kirki\Ecommerce\App\Supports\ListPaginator;
use Kirki\Ecommerce\App\Constants\Pagination;
use Kirki\Ecommerce\Framework\Collections\Collection;
use Kirki\Ecommerce\Framework\Database\Query\Paginator;
Expand Down Expand Up @@ -80,7 +81,9 @@ public function all(VariantListFilterDTO $filters)
*/
public function paginated(VariantListFilterDTO $filters)
{
return $this->list_query($filters)->paginate($filters->limit ?? Pagination::LIMIT, $filters->page ?? 1);
$page = $this->list_query($filters)->paginate($filters->limit ?? Pagination::LIMIT, $filters->page ?? 1);

return ListPaginator::from($page, Variant::query());
}

/**
Expand Down
66 changes: 66 additions & 0 deletions app/Supports/ListPaginator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

namespace Kirki\Ecommerce\App\Supports;

defined('ABSPATH') || exit;

use Kirki\Ecommerce\Framework\Database\Query\Paginator;
use Kirki\Ecommerce\Framework\Database\Query\QueryBuilder;

/**
* A paginator that also reports if the table holds any record at all.
*
* `has_records` ignores the search and filters of the request, so a list page can tell
* an empty table from a search that matched nothing.
*
* @since 1.0.0
*/
class ListPaginator extends Paginator
{
/** @var bool */
protected $has_records = false;

/**
* Create a list paginator from a finished paginator.
*
* A query is run with exists() only when the page total is 0. When the page has
* rows, the table is known to hold records and no extra query runs. Pass a fresh,
* unfiltered query, never the filtered list query, or the flag would mean
* "has a match" instead of "has any record".
*
* @since 1.0.0
*
* @param Paginator $paginator The paginator to convert.
* @param bool|QueryBuilder $has_records Whether the table holds a record, or the query that tells.
* @return static
*/
public static function from(Paginator $paginator, $has_records)
{
$list = new static(
$paginator->items(),
$paginator->total(),
$paginator->get_per_page(),
$paginator->get_current_page()
);

if ($has_records instanceof QueryBuilder) {
$has_records = $paginator->total() > 0 || $has_records->exists();
}

$list->has_records = (bool) $has_records;

return $list;
}

/**
* Convert the paginator to an array, with the record presence flag.
*
* @since 1.0.0
*
* @return array<string, mixed>
*/
public function to_array()
{
return array_merge(parent::to_array(), ['has_records' => $this->has_records]);
}
}
50 changes: 33 additions & 17 deletions docs/data-table.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,16 @@ must also decide whether to show the table at all

```tsx
const Brands = () => {
const { params, hasActiveFilters } = useDataTableParams(brandListOptions);
const { params } = useDataTableParams(brandListOptions);
const { data, isFetching } = useBrandsQuery(params);

const hasNoRecord = !isFetching && !hasActiveFilters && data?.total === 0;
const hasRecord = data?.has_records !== false;

return (
<Page>
<PageHeading text={__('Brands', 'kirki-ecommerce')} actions={!hasNoRecord && <NewBrandButton />} />
<PageHeading text={__('Brands', 'kirki-ecommerce')} actions={hasRecord && <NewBrandButton />} />
<PageContent>
{hasNoRecord ? (
{!hasRecord ? (
<NotFoundState ... />
) : (
<BrandTable data={data} isLoading={isFetching} />
Expand Down Expand Up @@ -297,22 +297,23 @@ An empty list is one of two situations, and they are handled in two places:

| Situation | Handled by | Shown | Toolbar, headers, pagination |
| --- | --- | --- | --- |
| No filters, `total` of `0` (first use) | The **page** | `NotFoundState` instead of the table | Not rendered; the page card holds only the message |
| The table holds no record (`has_records` is `false`) | The **page** | `NotFoundState` instead of the table | Not rendered; the page card holds only the message |
| Filters active, or any other case with no rows | `DataTable` | `emptyState` in the table body | Kept, so the criteria can be changed |

**First use belongs to the page.** `DataTable` does not know about first use and
has no `total`-based branch for it. The page already holds the query and the
filters, so it makes the call and hides the table:
filters, so it makes the call and hides the table. The call reads
`has_records` from the list response:

```tsx
const { params, hasActiveFilters } = useDataTableParams(productListOptions);
const { params } = useDataTableParams(productListOptions);
const { data, isFetching } = useProductsQuery(params);

const hasNoRecord = !isFetching && !hasActiveFilters && data?.total === 0;
const hasRecord = data?.has_records !== false;

<PageHeading text=... actions={!hasNoRecord && <AddProductButton />} />
<PageHeading text=... actions={hasRecord && <AddProductButton />} />
<PageContent>
{hasNoRecord ? (
{!hasRecord ? (
<NotFoundState
icon={<ProductEmptyStateIcon />}
text={__('Add a product', 'kirki-ecommerce')}
Expand All @@ -326,8 +327,8 @@ const hasNoRecord = !isFetching && !hasActiveFilters && data?.total === 0;
```

The page's header action is hidden while `NotFoundState` is shown, so the
screen offers one call to action, not two. The page number plays no part:
`total` of `0` with no filters is first use on every page.
screen offers one call to action, not two. Neither the page number nor the
filters play a part: only the server says if the table is empty.

**Filtered-empty belongs to the table.** The table passes `emptyState` to
`DataTable`, which renders it in a single full-width row of the body, with the
Expand Down Expand Up @@ -357,10 +358,25 @@ const { ..., clearFilters } = useDataTableParams(productListOptions);
`emptyState` is omitted, `DataTable` renders that default. The entity icons used
by `NotFoundState` live in `@/icons/empty-state`.

**Wait for `isFetching`.** `hasNoRecord` requires `!isFetching`, so a refetch
never flashes the first-use screen. A failed request leaves `data` undefined, and
`data?.total === 0` is then false, so an error never reads as "add your first
item".
**Only an explicit `false` shows the first-use screen.** While the first request
loads, or after a failed request, `data` is undefined and `has_records` is
undefined. The check `!== false` keeps the table, so an error never reads as "add
your first item", and a refetch never flashes the first-use screen.

**Where `has_records` comes from.** The nine top-level list endpoints (products,
orders, inventory, collections, categories, tags, brands, customers, coupons)
return `has_records` in the `data` object, beside `total`. It is `true` when the
table holds at least one record, whatever the search or filters in the request.
A search that matches nothing therefore gives `total` of `0` and `has_records` of
`true`, and the page keeps the table and shows the filtered-empty message.

The service builds it with `ListPaginator::from( $page, $query )`. The query is a
fresh, unfiltered query, for example `Brand::query()`. It runs only when the page
total is `0`; when the page has rows, no extra query runs. To change which
records count, change that query in the service. Products pass
`Product::query()->where_not( 'status', ProductStatus::TRASHED )`, so a store with
only trashed products gets the first-use screen. The `limit=-1` branch (all
records, for pickers) and storefront lists do not return the key.

**`hasActiveFilters` and `clearFilters`.** `useDataTableParams` returns both.
The flag is true when the search term, a date range or any declared filter holds
Expand Down Expand Up @@ -391,7 +407,7 @@ page calls the hook and the table calls it again for `clearFilters`.
- **Row identity is `row.id`.** `getRowId` returns `String(row.id)`, so selection
survives a refetch that reorders rows. Every item must have an `id`.
- **An empty result is not one state.** TanStack has no notion of "no rows".
Here first use (no filters, `total` of `0`) replaces the whole table with a
Here first use (the server reports `has_records` as `false`) replaces the whole table with a
page-level `NotFoundState`, while a filtered-empty table keeps its toolbar and
headers and shows `emptyState` in the body. `DataTable` itself only knows the
second case. See [7. Empty states](#7-empty-states).
Expand Down
2 changes: 1 addition & 1 deletion docs/home.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ processing and cancelled payments are never counted. All figures are **all time*
| Card | Value |
|---|---|
| Total sales | Sum of `base_total` of counted orders. |
| Net sales | Total sales − sum of `base_tax_total` − sum of `base_shipping_total` − completed refunds of counted orders, in the base currency. |
| Net sales | Total sales − sum of `base_tax_total` − (sum of `base_shipping_total` − sum of `base_shipping_tax_amount`) − completed refunds of counted orders, in the base currency. `base_tax_total` and `base_shipping_total` both hold the shipping tax, so it is taken off once. |
| Orders | Number of counted orders. |
| Products sold | Sum of order item `quantity` of counted orders. |

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-16
Loading
Loading