Просмотр исходного кода

feat(dashboard): Dashboard collection list (#3456)

Michael Bromley 9 месяцев назад
Родитель
Сommit
d3b0f50fd0

+ 145 - 0
docs/docs/guides/developer-guide/scheduled-tasks/index.md

@@ -0,0 +1,145 @@
+---
+title: "Scheduled Tasks"
+showtoc: true
+---
+
+Scheduled tasks are a way of executing some code at pre-defined intervals. There are many examples of work that can be done using scheduled tasks, 
+such as:
+
+- Generating a sitemap
+- Synchronizing data between different systems
+- Sending abandoned cart emails
+- Cleaning up old data
+
+In Vendure you can create scheduled tasks by defining a [standalone script](/guides/developer-guide/stand-alone-scripts/) which can then 
+be executed via any scheduling mechanism you like, such as a cron job or similar mechanism provided by your hosting provider.
+
+## Creating a Scheduled Task
+
+Let's imagine that you have created a plugin that exposes a `SitemapService` which generates a sitemap for your store. You want to run this
+task every night at midnight. 
+
+First we need to create a standalone script which will run the task. This script will look something like this:
+
+```ts title="scheduled-tasks.ts"
+import { bootstrapWorker, Logger, RequestContextService } from '@vendure/core';
+import { SitemapService } from './plugins/sitemap';
+
+import { config } from './vendure-config';
+
+if (require.main === module) {
+    generateSitemap()
+        .then(() => process.exit(0))
+        .catch(err => {
+            Logger.error(err);
+            process.exit(1);
+        });
+}
+
+async function generateSitemap() {
+    // This will bootstrap an instance of the Vendure Worker, providing
+    // us access to all of the services defined in the Vendure core.
+    // (but without the unnecessary overhead of the API layer).
+    const { app } = await bootstrapWorker(config);
+
+    // Using `app.get()` we can grab an instance of _any_ provider defined in the
+    // Vendure core as well as by our plugins.
+    const sitemapService = app.get(SitemapService);
+
+    // For most service methods, we'll need to pass a RequestContext object.
+    // We can use the RequestContextService to create one.
+    const ctx = await app.get(RequestContextService).create({
+        apiType: 'admin',
+    });
+    
+    await sitemapService.generateSitemap(ctx);
+
+    Logger.info(`Completed sitemap generation`);
+}
+```
+
+### Schedule the task
+
+Each hosting provider has its own way of scheduling tasks. A common way is to use a cron job. 
+For example, to run the above script every night at midnight, you could add the following line to your crontab:
+
+```bash
+0 0 * * * node /path/to/scheduled-tasks.js
+```
+
+This will run the script `/path/to/scheduled-tasks.js` every night at midnight.
+
+### Long-running tasks
+
+What if the scheduled task does a significant amount of work that would take many minutes to complete? In this case
+you should consider using the [job queue](/guides/developer-guide/worker-job-queue/#using-job-queues-in-a-plugin) to
+execute the work on the worker.
+
+Taking the above example, let's now imagine that the `SitemapService` exposes a `triggerGenerate()` method which
+adds a new job to the job queue. The job queue will then execute the task in the background, allowing the scheduled
+task to complete quickly.
+
+```ts title="scheduled-tasks.ts"
+import { bootstrapWorker, Logger, RequestContextService } from '@vendure/core';
+import { SitemapService } from './plugins/sitemap';
+
+import { config } from './vendure-config';
+
+if (require.main === module) {
+    generateSitemap()
+        .then(() => process.exit(0))
+        .catch(err => {
+            Logger.error(err);
+            process.exit(1);
+        });
+}
+
+async function generateSitemap() {
+    const { app } = await bootstrapWorker(config);
+    const sitemapService = app.get(SitemapService);
+    const ctx = await app.get(RequestContextService).create({
+        apiType: 'admin',
+    });
+    
+    await sitemapService.triggerGenerate(ctx);
+
+    Logger.info(`Sitemap generation triggered`);
+}
+```
+
+## Using @nestjs/schedule
+
+NestJS provides a [dedicated package for scheduling tasks](https://docs.nestjs.com/techniques/task-scheduling), called `@nestjs/schedule`. 
+
+You can also use this approach to schedule tasks, but you need to aware of a very important caveat:
+
+:::warning
+When using `@nestjs/schedule`, any method decorated with the `@Cron()` decorator will run
+on _all_ instances of the application. This means it will run on the server _and_ on the 
+worker. If you are running multiple instances, then it will run on all instances.
+:::
+
+You can, for instance, inject the ProcessContext into the service and check if the current instance is the worker or the server.
+
+```ts
+import { Injectable } from '@nestjs/common';
+import { Cron } from '@nestjs/schedule';
+
+
+@Injectable()
+export class SitemapService {
+    constructor(private processContext: ProcessContext) {}
+
+    @Cron('0 0 * * *')
+    async generateSitemap() {
+        if (this.processContext.isWorker) {
+            // Only run on the worker
+            await this.triggerGenerate();
+        }
+    }
+}
+```
+
+The above code will run the `generateSitemap()` method every night at midnight, but only on the worker instance.
+
+Again, if you have multiple worker instances running, it would run on all instances.

+ 224 - 0
docs/docs/guides/developer-guide/translateable/index.md

@@ -0,0 +1,224 @@
+---
+title: "Implementing Translatable"
+showtoc: true
+---
+
+## Defining translatable entities
+
+Making an entity translatable means that string properties of the entity can have a different values for multiple languages.
+To make an entity translatable, you need to implement the [`Translatable`](/reference/typescript-api/entities/interfaces/#translatable) interface and add a `translations` property to the entity.
+
+```ts title="src/plugins/requests/entities/product-request.entity.ts"
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { VendureEntity, Product, EntityId, ID, Translatable } from '@vendure/core';
+import { Column, Entity, ManyToOne } from 'typeorm';
+
+import { ProductRequestTranslation } from './product-request-translation.entity';
+
+@Entity()
+class ProductRequest extends VendureEntity implements Translatable {
+    constructor(input?: DeepPartial<ProductRequest>) {
+        super(input);
+    }
+// highlight-start
+    text: LocaleString;
+// highlight-end
+    
+    @ManyToOne(type => Product)
+    product: Product;
+
+    @EntityId()
+    productId: ID;
+
+
+// highlight-start
+    @OneToMany(() => ProductRequestTranslation, translation => translation.base, { eager: true })
+    translations: Array<Translation<ProductRequest>>;
+// highlight-end
+}
+```
+
+The `translations` property is a `OneToMany` relation to the translations. Any fields that are to be translated are of type `LocaleString`, and **do not have a `@Column()` decorator**.
+This is because the `text` field here does not in fact exist in the database in the `product_request` table. Instead, it belongs to the `product_request_translations` table of the `ProductRequestTranslation` entity:
+
+```ts title="src/plugins/requests/entities/product-request-translation.entity.ts"
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { HasCustomFields, Translation, VendureEntity, LanguageCode } from '@vendure/core';
+import { Column, Entity, Index, ManyToOne } from 'typeorm';
+
+import { ProductRequest } from './release-note.entity';
+
+@Entity()
+export class ProductRequestTranslation
+    extends VendureEntity
+    implements Translation<ProductRequest>, HasCustomFields
+{
+    constructor(input?: DeepPartial<Translation<ProductRequestTranslation>>) {
+        super(input);
+    }
+
+    @Column('varchar')
+    languageCode: LanguageCode;
+
+    @Column('varchar')
+// highlight-start
+    text: string; // same name as the translatable field in the base entity
+// highlight-end
+    @Index()
+    @ManyToOne(() => ProductRequest, base => base.translations, { onDelete: 'CASCADE' })
+    base: ProductRequest;
+}
+```
+
+Thus there is a one-to-many relation between `ProductRequest` and `ProductRequestTranslation`, which allows Vendure to handle multiple translations of the same entity. The `ProductRequestTranslation` entity also implements the `Translation` interface, which requires the `languageCode` field and a reference to the base entity.
+
+### Translations in the GraphQL schema
+Since the `text` field is getting hydrated with the translation it should be exposed in the GraphQL Schema. Additionally, the `ProductRequestTranslation` type should
+be defined as well, to access other translations as well:
+```graphql title="src/plugins/requests/api/types.ts"
+type ProductRequestTranslation {
+    id: ID!
+    createdAt: DateTime!
+    updatedAt: DateTime!
+// highlight-start
+    languageCode: LanguageCode!
+    text: String!
+// highlight-end
+}
+
+type ProductRequest implements Node {
+    id: ID!
+    createdAt: DateTime!
+    updatedAt: DateTime!
+    # Will be filled with the translation for the current language
+    text: String!
+// highlight-next-line
+    translations: [ProductRequestTranslation!]!
+}
+
+```
+
+## Creating translatable entities
+
+Creating a translatable entity is usually done by using the [`TranslateableSaver`](/reference/typescript-api/service-helpers/translatable-saver/). This injectable service provides a `create` and `update` method which can be used to save or update a translatable entity.
+
+```ts title="src/plugins/requests/service/product-request.service.ts"
+export class RequestService {
+
+    constructor(private translatableSaver: TranslatableSaver) {}
+
+    async create(ctx: RequestContext, input: CreateProductRequestInput): Promise<ProductRequest> {
+        const request = await this.translatableSaver.create({
+            ctx,
+            input,
+            entityType: ProductRequest,
+            translationType: ProductRequestTranslation,
+            beforeSave: async f => {
+                // Assign relations here
+            },
+        });
+        return request;
+    }
+}
+```
+
+Important for the creation of translatable entities is the input object. The input object should contain a `translations` array with the translations for the entity. This can be done
+by defining the types like `CreateRequestInput` inside the GraphQL schema:
+
+```graphql title="src/plugins/requests/api/types.ts"
+input ProductRequestTranslationInput {
+    # Only defined for update mutations
+    id: ID
+// highlight-start
+    languageCode: LanguageCode!
+    text: String!
+// highlight-end
+}
+
+input CreateProductRequestInput {
+    text: String!
+// highlight-next-line
+    translations: [ProductRequestTranslationInput!]!
+}
+```
+
+## Updating translatable entities
+
+Updating a translatable entity is done in a similar way as creating one. The [`TranslateableSaver`](/reference/typescript-api/service-helpers/translatable-saver/) provides an `update` method which can be used to update a translatable entity.
+
+```ts title="src/plugins/requests/service/product-request.service.ts"
+export class RequestService {
+
+    constructor(private translatableSaver: TranslatableSaver) {}
+
+    async update(ctx: RequestContext, input: UpdateProductRequestInput): Promise<ProductRequest> {
+        const updatedEntity = await this.translatableSaver.update({
+            ctx,
+            input,
+            entityType: ProductRequest,
+            translationType: ProductRequestTranslation,
+            beforeSave: async f => {
+                // Assign relations here
+            },
+        });
+        return updatedEntity;
+    }
+}
+```
+
+Once again it's important to provide the `translations` array in the input object. This array should contain the translations for the entity.
+
+```graphql title="src/plugins/requests/api/types.ts"
+
+input UpdateProductRequestInput {
+    text: String
+// highlight-next-line
+    translations: [ProductRequestTranslationInput!]
+}
+```
+
+## Loading translatable entities
+
+If your plugin needs to load a translatable entity, you will need to use the [`TranslatorService`](/reference/typescript-api/service-helpers/translator-service/) to hydrate all the `LocaleString` fields will the actual translated values from the correct translation.
+
+```ts title="src/plugins/requests/service/product-request.service.ts"
+export class RequestService {
+
+    constructor(private translator: TranslatorService) {}
+
+    findAll(
+        ctx: RequestContext,
+        options?: ListQueryOptions<ProductRequest>,
+        relations?: RelationPaths<ProductRequest>,
+    ): Promise<PaginatedList<Translated<ProductRequest>>> {
+        return this.listQueryBuilder
+            .build(ProductRequest, options, {
+                relations,
+                ctx,
+            })
+            .getManyAndCount()
+            .then(([items, totalItems]) => {
+                return {
+// highlight-next-line
+                    items: items.map(item => this.translator.translate(item, ctx)),
+                    totalItems,
+                };
+            });
+    }
+    
+    findOne(
+        ctx: RequestContext,
+        id: ID,
+        relations?: RelationPaths<ProductRequest>,
+    ): Promise<Translated<ProductRequest> | null> {
+        return this.connection
+            .getRepository(ctx, ProductRequest)
+            .findOne({
+                where: { id },
+                relations,
+            })
+// highlight-next-line
+            .then(entity => entity && this.translator.translate(entity, ctx));
+    }
+}
+```

+ 2 - 0
docs/sidebars.js

@@ -92,6 +92,7 @@ const sidebars = {
                     className: 'sidebar-section-header',
                 },
                 'guides/developer-guide/channel-aware/index',
+                'guides/developer-guide/translateable/index',
                 'guides/developer-guide/cache/index',
                 'guides/developer-guide/dataloaders/index',
                 'guides/developer-guide/db-subscribers/index',
@@ -102,6 +103,7 @@ const sidebars = {
                     label: 'Migrating from v1',
                     items: [{ type: 'autogenerated', dirName: 'guides/developer-guide/migrating-from-v1' }],
                 },
+                'guides/developer-guide/scheduled-tasks/index',
                 'guides/developer-guide/stand-alone-scripts/index',
                 'guides/developer-guide/translations/index',
                 'guides/developer-guide/uploading-files/index',

+ 3 - 0
packages/dashboard/src/app/routes/_authenticated/_collections/collections.graphql.ts

@@ -25,6 +25,9 @@ export const collectionListDocument = graphql(
                         name
                         slug
                     }
+                    children {
+                        id
+                    }
                     position
                     isPrivate
                     parentId

+ 112 - 2
packages/dashboard/src/app/routes/_authenticated/_collections/collections.tsx

@@ -3,9 +3,16 @@ import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Button } from '@/components/ui/button.js';
 import { PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
 import { ListPage } from '@/framework/page/list-page.js';
+import { api } from '@/graphql/api.js';
 import { Trans } from '@/lib/trans.js';
+import { useQueries } from '@tanstack/react-query';
 import { createFileRoute, Link } from '@tanstack/react-router';
-import { PlusIcon } from 'lucide-react';
+import { ExpandedState, getExpandedRowModel } from '@tanstack/react-table';
+import { TableOptions } from '@tanstack/table-core';
+import { ResultOf } from 'gql.tada';
+import { Folder, FolderOpen, PlusIcon } from 'lucide-react';
+import { useState } from 'react';
+
 import { collectionListDocument, deleteCollectionDocument } from './collections.graphql.js';
 import { CollectionContentsSheet } from './components/collection-contents-sheet.js';
 
@@ -14,17 +21,95 @@ export const Route = createFileRoute('/_authenticated/_collections/collections')
     loader: () => ({ breadcrumb: () => <Trans>Collections</Trans> }),
 });
 
+type Collection = ResultOf<typeof collectionListDocument>['collections']['items'][number];
+
 function CollectionListPage() {
+    const [expanded, setExpanded] = useState<ExpandedState>({});
+    const childrenQueries = useQueries({
+        queries: Object.entries(expanded).map(([collectionId, isExpanded]) => {
+            return {
+                queryKey: ['childCollections', collectionId],
+                queryFn: () =>
+                    api.query(collectionListDocument, {
+                        options: {
+                            filter: {
+                                parentId: { eq: collectionId },
+                            },
+                        },
+                    }),
+                staleTime: 1000 * 60 * 5,
+            };
+        }),
+    });
+    const childCollectionsByParentId = childrenQueries.reduce(
+        (acc, query, index) => {
+            const collectionId = Object.keys(expanded)[index];
+            if (query.data) {
+                acc[collectionId] = query.data.collections.items;
+            }
+            return acc;
+        },
+        {} as Record<string, any[]>,
+    );
+
+    const addSubCollections = (data: Collection[]) => {
+        const allRows = [] as Collection[];
+        const addSubRows = (row: Collection) => {
+            const subRows = childCollectionsByParentId[row.id] || [];
+            if (subRows.length) {
+                for (const subRow of subRows) {
+                    allRows.push(subRow);
+                    addSubRows(subRow);
+                }
+            }
+        };
+        data.forEach(row => {
+            allRows.push(row);
+            addSubRows(row);
+        });
+        return allRows;
+    };
+
     return (
         <ListPage
             pageId="collection-list"
             title="Collections"
             listQuery={collectionListDocument}
+            transformVariables={input => {
+                const filterTerm = input.options?.filter?.name?.contains;
+                const isFiltering = !!filterTerm;
+                return {
+                    options: {
+                        ...input.options,
+                        topLevelOnly: !isFiltering,
+                    },
+                };
+            }}
             deleteMutation={deleteCollectionDocument}
             customizeColumns={{
                 name: {
                     header: 'Collection Name',
-                    cell: ({ row }) => <DetailPageButton id={row.original.id} label={row.original.name} />,
+                    cell: ({ row }) => {
+                        const isExpanded = row.getIsExpanded();
+                        const hasChildren = !!row.original.children?.length;
+                        return (
+                            <div
+                                style={{ marginLeft: (row.original.breadcrumbs.length - 2) * 20 + 'px' }}
+                                className="flex gap-2 items-center"
+                            >
+                                <Button
+                                    size="icon"
+                                    variant="secondary"
+                                    onClick={row.getToggleExpandedHandler()}
+                                    disabled={!hasChildren}
+                                    className={!hasChildren ? 'opacity-20' : ''}
+                                >
+                                    {isExpanded ? <FolderOpen /> : <Folder />}
+                                </Button>
+                                <DetailPageButton id={row.original.id} label={row.original.name} />
+                            </div>
+                        );
+                    },
                 },
                 breadcrumbs: {
                     cell: ({ cell }) => {
@@ -56,12 +141,37 @@ function CollectionListPage() {
                     },
                 },
             }}
+            defaultColumnOrder={[
+                'featuredAsset',
+                'children',
+                'name',
+                'slug',
+                'breadcrumbs',
+                'productVariants',
+            ]}
+            transformData={data => {
+                return addSubCollections(data);
+            }}
+            setTableOptions={(options: TableOptions<any>) => {
+                options.state = {
+                    ...options.state,
+                    expanded: expanded,
+                };
+                options.onExpandedChange = setExpanded;
+                options.getExpandedRowModel = getExpandedRowModel();
+                options.getRowCanExpand = () => true;
+                options.getRowId = row => {
+                    return row.id;
+                };
+                return options;
+            }}
             defaultVisibility={{
                 id: false,
                 createdAt: false,
                 updatedAt: false,
                 position: false,
                 parentId: false,
+                children: false,
             }}
             onSearchTermChange={searchTerm => {
                 return {

+ 15 - 3
packages/dashboard/src/lib/components/data-table/data-table.tsx

@@ -18,6 +18,7 @@ import {
     useReactTable,
     VisibilityState,
 } from '@tanstack/react-table';
+import { TableOptions } from '@tanstack/table-core';
 import { CircleX, Filter } from 'lucide-react';
 import React, { Suspense, useEffect } from 'react';
 import { DataTableFacetedFilter, DataTableFacetedFilterOption } from './data-table-faceted-filter.js';
@@ -44,6 +45,11 @@ interface DataTableProps<TData, TValue> {
     defaultColumnVisibility?: VisibilityState;
     facetedFilters?: { [key: string]: FacetedFilter | undefined };
     disableViewOptions?: boolean;
+    /**
+     * This property allows full control over _all_ features of TanStack Table
+     * when needed.
+     */
+    setTableOptions?: (table: TableOptions<TData>) => TableOptions<TData>;
 }
 
 export function DataTable<TData, TValue>({
@@ -61,6 +67,7 @@ export function DataTable<TData, TValue>({
     defaultColumnVisibility,
     facetedFilters,
     disableViewOptions,
+    setTableOptions,
 }: DataTableProps<TData, TValue>) {
     const [sorting, setSorting] = React.useState<SortingState>(sortingInitialState || []);
     const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(filtersInitialState || []);
@@ -72,7 +79,7 @@ export function DataTable<TData, TValue>({
         defaultColumnVisibility ?? {},
     );
 
-    const table = useReactTable({
+    let tableOptions: TableOptions<TData> = {
         data,
         columns,
         getCoreRowModel: getCoreRowModel(),
@@ -91,7 +98,13 @@ export function DataTable<TData, TValue>({
             columnVisibility,
             columnFilters,
         },
-    });
+    };
+
+    if (typeof setTableOptions === 'function') {
+        tableOptions = setTableOptions(tableOptions);
+    }
+
+    const table = useReactTable(tableOptions);
 
     useEffect(() => {
         onPageChange?.(table, pagination.pageIndex + 1, pagination.pageSize);
@@ -104,7 +117,6 @@ export function DataTable<TData, TValue>({
     useEffect(() => {
         onFilterChange?.(table, columnFilters);
     }, [columnFilters]);
-
     return (
         <>
             <div className="flex justify-between items-start">

+ 52 - 40
packages/dashboard/src/lib/components/shared/asset-gallery.tsx

@@ -24,35 +24,41 @@ import { useCallback, useState } from 'react';
 import { useDropzone } from 'react-dropzone';
 import { useDebounce } from '@uidotdev/usehooks';
 
-const getAssetListDocument = graphql(`
-    query GetAssetList($options: AssetListOptions) {
-        assets(options: $options) {
-            items {
-               ...Asset
+const getAssetListDocument = graphql(
+    `
+        query GetAssetList($options: AssetListOptions) {
+            assets(options: $options) {
+                items {
+                    ...Asset
+                }
+                totalItems
             }
-            totalItems
         }
-    }
-`, [assetFragment]);
-
-export const createAssetsDocument = graphql(`
-    mutation CreateAssets($input: [CreateAssetInput!]!) {
-        createAssets(input: $input) {
-            ...Asset
-            ... on Asset {
-                tags {
-                    id
-                    createdAt
-                    updatedAt
-                    value
+    `,
+    [assetFragment],
+);
+
+export const createAssetsDocument = graphql(
+    `
+        mutation CreateAssets($input: [CreateAssetInput!]!) {
+            createAssets(input: $input) {
+                ...Asset
+                ... on Asset {
+                    tags {
+                        id
+                        createdAt
+                        updatedAt
+                        value
+                    }
+                }
+                ... on ErrorResult {
+                    message
                 }
-            }
-            ... on ErrorResult {
-                message
             }
         }
-    }
-`, [assetFragment]);
+    `,
+    [assetFragment],
+);
 
 const AssetType = {
     ALL: 'ALL',
@@ -89,12 +95,11 @@ export function AssetGallery({
     // State
     const [page, setPage] = useState(1);
     const [search, setSearch] = useState('');
-    const [debouncedSearch] = useDebounce(search, 500);
+    const debouncedSearch = useDebounce(search, 500);
     const [assetType, setAssetType] = useState<string>(AssetType.ALL);
     const [selected, setSelected] = useState<Asset[]>(initialSelectedAssets || []);
     const queryClient = useQueryClient();
 
-
     const queryKey = ['AssetGallery', page, pageSize, debouncedSearch, assetType];
 
     // Query for assets
@@ -128,11 +133,14 @@ export function AssetGallery({
             queryClient.invalidateQueries({ queryKey });
         },
     });
-    
+
     // Setup dropzone
-    const onDrop = useCallback((acceptedFiles: File[]) => {
-        createAssets({  input: acceptedFiles.map(file => ({ file })) });
-    }, [createAssets]);
+    const onDrop = useCallback(
+        (acceptedFiles: File[]) => {
+            createAssets({ input: acceptedFiles.map(file => ({ file })) });
+        },
+        [createAssets],
+    );
 
     const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, noClick: true });
 
@@ -183,7 +191,7 @@ export function AssetGallery({
         const fileInput = document.createElement('input');
         fileInput.type = 'file';
         fileInput.multiple = true;
-        fileInput.addEventListener('change', (event) => {
+        fileInput.addEventListener('change', event => {
             const target = event.target as HTMLInputElement;
             if (target.files) {
                 const filesList = Array.from(target.files);
@@ -193,7 +201,6 @@ export function AssetGallery({
         fileInput.click();
     };
 
-
     return (
         <div className={`flex flex-col w-full ${fixedHeight ? 'h-[600px]' : ''} ${className}`}>
             {showHeader && (
@@ -206,11 +213,16 @@ export function AssetGallery({
                             onChange={e => setSearch(e.target.value)}
                             className="pl-8"
                         />
-                         {(search || assetType !== AssetType.ALL) && (
-                        <Button variant="ghost" size="sm" onClick={clearFilters} className="absolute right-0">
-                            <X className="h-4 w-4 mr-1" /> Clear filters
-                        </Button>
-                    )}
+                        {(search || assetType !== AssetType.ALL) && (
+                            <Button
+                                variant="ghost"
+                                size="sm"
+                                onClick={clearFilters}
+                                className="absolute right-0"
+                            >
+                                <X className="h-4 w-4 mr-1" /> Clear filters
+                            </Button>
+                        )}
                     </div>
                     <Select value={assetType} onValueChange={setAssetType}>
                         <SelectTrigger className="w-full md:w-[180px]">
@@ -229,8 +241,8 @@ export function AssetGallery({
                 </div>
             )}
 
-            <div 
-                {...getRootProps()} 
+            <div
+                {...getRootProps()}
                 className={`
                     ${fixedHeight ? 'flex-grow overflow-y-auto' : ''}
                     ${isDragActive ? 'ring-2 ring-primary bg-primary/5' : ''}
@@ -238,7 +250,7 @@ export function AssetGallery({
                 `}
             >
                 <input {...getInputProps()} />
-                
+
                 {isDragActive && (
                     <div className="absolute inset-0 bg-background/80 backdrop-blur-sm z-10 flex flex-col items-center justify-center rounded-md">
                         <Upload className="h-12 w-12 text-primary mb-2" />

+ 16 - 8
packages/dashboard/src/lib/components/shared/paginated-list-data-table.tsx

@@ -14,7 +14,7 @@ import {
     DropdownMenu,
     DropdownMenuContent,
     DropdownMenuItem,
-    DropdownMenuTrigger
+    DropdownMenuTrigger,
 } from '@/components/ui/dropdown-menu.js';
 import { DisplayComponent } from '@/framework/component-registry/dynamic-component.js';
 import { ResultOf } from '@/graphql/graphql.js';
@@ -28,7 +28,7 @@ import {
     SortingState,
     Table,
 } from '@tanstack/react-table';
-import { AccessorKeyColumnDef, ColumnDef, Row } from '@tanstack/table-core';
+import { AccessorKeyColumnDef, ColumnDef, Row, TableOptions } from '@tanstack/table-core';
 import { EllipsisIcon, TrashIcon } from 'lucide-react';
 import React, { useMemo } from 'react';
 import { toast } from 'sonner';
@@ -199,6 +199,8 @@ export interface PaginatedListDataTableProps<
     facetedFilters?: FacetedFilterConfig<T>;
     rowActions?: RowAction<PaginatedListItemFields<T>>[];
     disableViewOptions?: boolean;
+    transformData?: (data: PaginatedListItemFields<T>[]) => PaginatedListItemFields<T>[];
+    setTableOptions?: (table: TableOptions<any>) => TableOptions<any>;
 }
 
 export const PaginatedListDataTableKey = 'PaginatedListDataTable';
@@ -228,9 +230,11 @@ export function PaginatedListDataTable<
     facetedFilters,
     rowActions,
     disableViewOptions,
+    setTableOptions,
+    transformData,
 }: PaginatedListDataTableProps<T, U, V, AC>) {
     const [searchTerm, setSearchTerm] = React.useState<string>('');
-    const [debouncedSearchTerm] = useDebounce(searchTerm, 500);
+    const debouncedSearchTerm = useDebounce(searchTerm, 500);
     const queryClient = useQueryClient();
 
     const sort = sorting?.reduce((acc: any, sort: ColumnSort) => {
@@ -323,6 +327,7 @@ export function PaginatedListDataTable<
             const enableColumnFilter = fieldInfo.isScalar && !facetedFilters?.[fieldInfo.name];
 
             return columnHelper.accessor(fieldInfo.name as any, {
+                id: fieldInfo.name,
                 meta: { fieldInfo, isCustomField },
                 enableColumnFilter,
                 enableSorting: fieldInfo.isScalar,
@@ -376,13 +381,13 @@ export function PaginatedListDataTable<
             // ensure the columns with ids matching the items in defaultColumnOrder
             // appear as the first columns in sequence, and leave the remainder in the
             // existing order
-            const orderedColumns = finalColumns.filter(
-                column => column.id && defaultColumnOrder.includes(column.id),
-            );
+            const orderedColumns = finalColumns
+                .filter(column => column.id && defaultColumnOrder.includes(column.id))
+                .sort((a, b) => defaultColumnOrder.indexOf(a.id) - defaultColumnOrder.indexOf(b.id));
             const remainingColumns = finalColumns.filter(
                 column => !column.id || !defaultColumnOrder.includes(column.id),
             );
-            finalColumns = [...orderedColumns];
+            finalColumns = [...orderedColumns, ...remainingColumns];
         }
 
         if (rowActions || deleteMutation) {
@@ -396,11 +401,13 @@ export function PaginatedListDataTable<
     }, [fields, customizeColumns, rowActions]);
 
     const columnVisibility = getColumnVisibility(fields, defaultVisibility, customFieldColumnNames);
+    const transformedData =
+        typeof transformData === 'function' ? transformData(listData?.items ?? []) : (listData?.items ?? []);
     return (
         <PaginatedListContext.Provider value={{ refetchPaginatedList }}>
             <DataTable
                 columns={columns}
-                data={listData?.items ?? []}
+                data={transformedData}
                 page={page}
                 itemsPerPage={itemsPerPage}
                 sorting={sorting}
@@ -413,6 +420,7 @@ export function PaginatedListDataTable<
                 defaultColumnVisibility={columnVisibility}
                 facetedFilters={facetedFilters}
                 disableViewOptions={disableViewOptions}
+                setTableOptions={setTableOptions}
             />
         </PaginatedListContext.Provider>
     );

+ 8 - 1
packages/dashboard/src/lib/framework/page/list-page.tsx

@@ -10,7 +10,9 @@ import {
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { AnyRoute, AnyRouter, useNavigate } from '@tanstack/react-router';
 import { ColumnFiltersState, SortingState, Table } from '@tanstack/react-table';
+import { TableOptions } from '@tanstack/table-core';
 import { ResultOf } from 'gql.tada';
+import { addCustomFields } from '../document-introspection/add-custom-fields.js';
 import {
     FullWidthPageBlock,
     Page,
@@ -18,7 +20,6 @@ import {
     PageLayout,
     PageTitle,
 } from '../layout-engine/page-layout.js';
-import { addCustomFields } from '../document-introspection/add-custom-fields.js';
 
 type ListQueryFields<T extends TypedDocumentNode<any, any>> = {
     [Key in keyof ResultOf<T>]: ResultOf<T>[Key] extends { items: infer U }
@@ -49,6 +50,8 @@ export interface ListPageProps<
     children?: React.ReactNode;
     facetedFilters?: FacetedFilterConfig<T>;
     rowActions?: RowAction<ListQueryFields<T>>[];
+    transformData?: (data: any[]) => any[];
+    setTableOptions?: (table: TableOptions<any>) => TableOptions<any>;
 }
 
 export function ListPage<
@@ -72,6 +75,8 @@ export function ListPage<
     facetedFilters,
     children,
     rowActions,
+    transformData,
+    setTableOptions,
 }: ListPageProps<T, U, V, AC>) {
     const route = typeof routeOrFn === 'function' ? routeOrFn() : routeOrFn;
     const routeSearch = route.useSearch();
@@ -151,6 +156,8 @@ export function ListPage<
                         }}
                         facetedFilters={facetedFilters}
                         rowActions={rowActions}
+                        setTableOptions={setTableOptions}
+                        transformData={transformData}
                     />
                 </FullWidthPageBlock>
             </PageLayout>