Преглед изворни кода

给产品 search 增加按页码跳转,避免只能靠游标逐页翻。

Co-authored-by: Cursor <cursoragent@cursor.com>
chengwl пре 19 часа
родитељ
комит
7677fae97c

+ 10 - 0
packages/Webkul/BagistoApi/src/Dto/ProductSearch/ProductSearchPageInfoDto.php

@@ -14,4 +14,14 @@ class ProductSearchPageInfoDto
     public ?string $startCursor = null;
 
     public ?string $endCursor = null;
+
+    /**
+     * Current page number (1-based). Available when using the `page` arg.
+     */
+    public int $current_page = 1;
+
+    /**
+     * Total number of pages. Available when using the `page` arg.
+     */
+    public int $total_pages = 1;
 }

+ 1 - 0
packages/Webkul/BagistoApi/src/Models/ProductSearch.php

@@ -41,6 +41,7 @@ use Webkul\BagistoApi\Resolver\ProductSearchResolver;
                 'last'    => ['type' => 'Int'],
                 'after'   => ['type' => 'String'],
                 'before'  => ['type' => 'String'],
+                'page'    => ['type' => 'Int', 'description' => '1-based page number for direct page navigation. Overrides after/before.'],
                 'locale'  => ['type' => 'String'],
                 'channel' => ['type' => 'String'],
             ],

+ 36 - 6
packages/Webkul/BagistoApi/src/Resolver/ProductSearchResolver.php

@@ -20,10 +20,28 @@ class ProductSearchResolver implements QueryItemResolverInterface
     public function __invoke(?object $item, array $context): object
     {
         $args = $context['args'] ?? [];
+        $productArgs = $args;
+
+        // The product provider only understands Relay cursor args (after/before),
+        // so translate a direct `page` arg into an equivalent `after` cursor.
+        if (! empty($productArgs['page'])) {
+            $page = max(1, (int) $productArgs['page']);
+            $limit = max(1, (int) ($productArgs['first'] ?? $productArgs['last'] ?? 30));
+            $offset = ($page - 1) * $limit;
+
+            unset($productArgs['after'], $productArgs['before']);
+
+            if ($offset > 0) {
+                $productArgs['after'] = base64_encode((string) ($offset - 1));
+            }
+
+            unset($productArgs['page']);
+        }
+
         $paginator = $this->productProvider->provide(
             $context['operation'],
             [],
-            ['args' => $args]
+            ['args' => $productArgs]
         );
 
         if (! $paginator instanceof PaginatorInterface) {
@@ -34,6 +52,7 @@ class ProductSearchResolver implements QueryItemResolverInterface
         $result->total_count = (int) $paginator->getTotalItems();
 
         $offset = $this->resolveOffset($args, $result->total_count);
+        $limit = max(1, (int) ($args['first'] ?? $args['last'] ?? 30));
 
         foreach ($paginator as $index => $product) {
             $edge = new ProductSearchEdgeDto;
@@ -42,7 +61,7 @@ class ProductSearchResolver implements QueryItemResolverInterface
             $result->edges[] = $edge;
         }
 
-        $result->page_info = $this->buildPageInfo($result->edges, $offset, $paginator);
+        $result->page_info = $this->buildPageInfo($result->edges, $offset, $limit, $result->total_count, $paginator);
 
         $metadata = request()->attributes->get('bagistoapi.search_metadata');
 
@@ -55,17 +74,24 @@ class ProductSearchResolver implements QueryItemResolverInterface
         return $result;
     }
 
+    /**
+     * Resolve the current page offset.
+     *
+     * Supports both Relay cursor args (after/before) and a direct `page` arg.
+     * When `page` is provided it takes precedence over after/before.
+     */
     private function resolveOffset(array $args, int $total): int
     {
         $limit = max(1, (int) ($args['first'] ?? $args['last'] ?? 30));
         $offset = 0;
 
-        if (! empty($args['after'])) {
+        if (! empty($args['page'])) {
+            $page = max(1, (int) $args['page']);
+            $offset = ($page - 1) * $limit;
+        } elseif (! empty($args['after'])) {
             $decoded = base64_decode($args['after'], true);
             $offset = ctype_digit((string) $decoded) ? ((int) $decoded + 1) : 0;
-        }
-
-        if (! empty($args['before'])) {
+        } elseif (! empty($args['before'])) {
             $decoded = base64_decode($args['before'], true);
             $cursor = ctype_digit((string) $decoded) ? (int) $decoded : 0;
             $offset = max(0, $cursor - $limit);
@@ -81,6 +107,8 @@ class ProductSearchResolver implements QueryItemResolverInterface
     private function buildPageInfo(
         array $edges,
         int $offset,
+        int $limit,
+        int $total,
         PaginatorInterface $paginator
     ): ProductSearchPageInfoDto {
         $pageInfo = new ProductSearchPageInfoDto;
@@ -90,6 +118,8 @@ class ProductSearchResolver implements QueryItemResolverInterface
             : $paginator->getCurrentPage() < $paginator->getLastPage();
         $pageInfo->startCursor = $edges[0]->cursor ?? null;
         $pageInfo->endCursor = $edges[array_key_last($edges)]->cursor ?? null;
+        $pageInfo->current_page = $total > 0 ? (int) floor($offset / $limit) + 1 : 1;
+        $pageInfo->total_pages = $total > 0 ? (int) ceil($total / $limit) : 1;
 
         return $pageInfo;
     }

+ 26 - 4
packages/Webkul/BagistoApi/tests/Feature/ProductSearchGraphQLTest.php

@@ -133,14 +133,15 @@ class ProductSearchGraphQLTest extends TestCase
               search(query: "ready", first: 1) {
                 totalCount
                 edges { cursor node { sku } }
-                pageInfo { hasNextPage hasPreviousPage }
+                pageInfo { hasNextPage hasPreviousPage currentPage totalPages }
               }
             }
             GQL);
 
         $page
             ->assertOk()
-            ->assertJsonPath('data.search.pageInfo.hasPreviousPage', false);
+            ->assertJsonPath('data.search.pageInfo.hasPreviousPage', false)
+            ->assertJsonPath('data.search.pageInfo.currentPage', 1);
 
         $total = $page->json('data.search.totalCount');
         $cursor = $page->json('data.search.edges.0.cursor');
@@ -151,6 +152,7 @@ class ProductSearchGraphQLTest extends TestCase
         }
 
         $page->assertJsonPath('data.search.pageInfo.hasNextPage', $total > 1);
+        $page->assertJsonPath('data.search.pageInfo.totalPages', (int) ceil($total / 1));
 
         if ($total > 1) {
             $nextPage = $this->graphQL(
@@ -159,7 +161,7 @@ class ProductSearchGraphQLTest extends TestCase
                       search(query: "ready", first: 1, after: $after) {
                         totalCount
                         edges { node { sku } }
-                        pageInfo { hasNextPage hasPreviousPage }
+                        pageInfo { hasNextPage hasPreviousPage currentPage totalPages }
                       }
                     }
                     GQL,
@@ -169,9 +171,29 @@ class ProductSearchGraphQLTest extends TestCase
             $nextPage
                 ->assertOk()
                 ->assertJsonPath('data.search.totalCount', $total)
-                ->assertJsonPath('data.search.pageInfo.hasPreviousPage', true);
+                ->assertJsonPath('data.search.pageInfo.hasPreviousPage', true)
+                ->assertJsonPath('data.search.pageInfo.currentPage', 2)
+                ->assertJsonPath('data.search.pageInfo.totalPages', (int) ceil($total / 1));
 
             $this->assertNotSame($firstSku, $nextPage->json('data.search.edges.0.node.sku'));
+
+            $byPage = $this->graphQL(<<<'GQL'
+                query ProductSearchByPage {
+                  search(query: "ready", first: 1, page: 2) {
+                    totalCount
+                    edges { node { sku } }
+                    pageInfo { hasPreviousPage currentPage totalPages }
+                  }
+                }
+                GQL);
+
+            $byPage
+                ->assertOk()
+                ->assertJsonPath('data.search.totalCount', $total)
+                ->assertJsonPath('data.search.pageInfo.hasPreviousPage', true)
+                ->assertJsonPath('data.search.pageInfo.currentPage', 2)
+                ->assertJsonPath('data.search.pageInfo.totalPages', (int) ceil($total / 1))
+                ->assertJsonPath('data.search.edges.0.node.sku', $nextPage->json('data.search.edges.0.node.sku'));
         }
 
         $filtered = $this->graphQL(

+ 100 - 0
packages/Webkul/BagistoApi/tests/Unit/Resolver/ProductSearchResolverTest.php

@@ -0,0 +1,100 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Resolver;
+
+use ApiPlatform\Laravel\Eloquent\Paginator;
+use ApiPlatform\Metadata\GraphQl\Query;
+use Illuminate\Pagination\LengthAwarePaginator;
+use Mockery;
+use Tests\TestCase;
+use Webkul\BagistoApi\Models\Product;
+use Webkul\BagistoApi\Resolver\ProductSearchResolver;
+use Webkul\BagistoApi\State\ProductGraphQLProvider;
+
+class ProductSearchResolverTest extends TestCase
+{
+    protected function tearDown(): void
+    {
+        Mockery::close();
+        parent::tearDown();
+    }
+
+    public function test_page_arg_is_translated_into_an_offset_cursor(): void
+    {
+        $second = $this->product('second');
+        $third = $this->product('third');
+        $paginator = new Paginator(new LengthAwarePaginator([$second, $third], 5, 2, 2));
+
+        $provider = Mockery::mock(ProductGraphQLProvider::class);
+        $provider->shouldReceive('provide')
+            ->once()
+            ->withArgs(function ($operation, array $uriVariables, array $context): bool {
+                return $uriVariables === []
+                    && ($context['args']['query'] ?? null) === 'ready'
+                    && ($context['args']['first'] ?? null) === 2
+                    && ($context['args']['after'] ?? null) === base64_encode('1')
+                    && ! array_key_exists('page', $context['args'])
+                    && ! array_key_exists('before', $context['args']);
+            })
+            ->andReturn($paginator);
+
+        $result = (new ProductSearchResolver($provider))(null, [
+            'operation' => new Query,
+            'args'      => [
+                'query'  => 'ready',
+                'first'  => 2,
+                'page'   => 2,
+                'after'  => base64_encode('99'),
+                'before' => base64_encode('4'),
+            ],
+        ]);
+
+        $this->assertSame(5, $result->total_count);
+        $this->assertSame(base64_encode('2'), $result->edges[0]->cursor);
+        $this->assertSame(base64_encode('3'), $result->edges[1]->cursor);
+        $this->assertSame($second, $result->edges[0]->node);
+        $this->assertSame($third, $result->edges[1]->node);
+        $this->assertTrue($result->page_info->hasPreviousPage);
+        $this->assertTrue($result->page_info->hasNextPage);
+        $this->assertSame(2, $result->page_info->current_page);
+        $this->assertSame(3, $result->page_info->total_pages);
+    }
+
+    public function test_first_page_does_not_send_an_after_cursor(): void
+    {
+        $paginator = new Paginator(new LengthAwarePaginator([$this->product('first')], 1, 2, 1));
+
+        $provider = Mockery::mock(ProductGraphQLProvider::class);
+        $provider->shouldReceive('provide')
+            ->once()
+            ->withArgs(function ($operation, array $uriVariables, array $context): bool {
+                return ! array_key_exists('page', $context['args'])
+                    && ! array_key_exists('after', $context['args'])
+                    && ! array_key_exists('before', $context['args']);
+            })
+            ->andReturn($paginator);
+
+        $result = (new ProductSearchResolver($provider))(null, [
+            'operation' => new Query,
+            'args'      => [
+                'query'  => 'ready',
+                'first'  => 2,
+                'page'   => 1,
+                'after'  => base64_encode('9'),
+            ],
+        ]);
+
+        $this->assertSame(1, $result->page_info->current_page);
+        $this->assertSame(1, $result->page_info->total_pages);
+        $this->assertFalse($result->page_info->hasPreviousPage);
+        $this->assertSame(base64_encode('0'), $result->edges[0]->cursor);
+    }
+
+    private function product(string $sku): Product
+    {
+        $product = new Product;
+        $product->sku = $sku;
+
+        return $product;
+    }
+}