فهرست منبع

fix(dashboard): Allow customization of custom field columns (#3597)

David Höck 7 ماه پیش
والد
کامیت
f42085bae7

+ 22 - 13
packages/dashboard/src/lib/components/shared/paginated-list-data-table.tsx

@@ -18,8 +18,8 @@ import {
 } from '@/components/ui/dropdown-menu.js';
 import { DisplayComponent } from '@/framework/component-registry/dynamic-component.js';
 import { ResultOf } from '@/graphql/graphql.js';
-import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { Trans, useLingui } from '@/lib/trans.js';
+import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { useQuery } from '@tanstack/react-query';
 import {
     ColumnFiltersState,
@@ -97,14 +97,21 @@ export type PaginatedListKeys<
     [K in keyof PaginatedListItemFields<T, Path>]: K;
 }[keyof PaginatedListItemFields<T, Path>];
 
+// Utility types to include keys inside `customFields` object for typing purposes
+export type CustomFieldKeysOfItem<Item> = Item extends { customFields?: infer CF }
+    ? Extract<keyof CF, string>
+    : never;
+
+export type AllItemFieldKeys<T extends TypedDocumentNode<any, any>> =
+    | keyof PaginatedListItemFields<T>
+    | CustomFieldKeysOfItem<PaginatedListItemFields<T>>;
+
 export type CustomizeColumnConfig<T extends TypedDocumentNode<any, any>> = {
-    [Key in keyof PaginatedListItemFields<T>]?: Partial<
-        ColumnDef<PaginatedListItemFields<T>, PaginatedListItemFields<T>[Key]>
-    >;
+    [Key in AllItemFieldKeys<T>]?: Partial<ColumnDef<PaginatedListItemFields<T>, any>>;
 };
 
 export type FacetedFilterConfig<T extends TypedDocumentNode<any, any>> = {
-    [Key in keyof PaginatedListItemFields<T>]?: FacetedFilter;
+    [Key in AllItemFieldKeys<T>]?: FacetedFilter;
 };
 
 export type ListQueryShape =
@@ -188,8 +195,8 @@ export interface PaginatedListDataTableProps<
     transformVariables?: (variables: V) => V;
     customizeColumns?: CustomizeColumnConfig<T>;
     additionalColumns?: AC;
-    defaultColumnOrder?: (keyof PaginatedListItemFields<T> | AC[number]['id'])[];
-    defaultVisibility?: Partial<Record<keyof PaginatedListItemFields<T>, boolean>>;
+    defaultColumnOrder?: (AllItemFieldKeys<T> | AC[number]['id'])[];
+    defaultVisibility?: Partial<Record<AllItemFieldKeys<T>, boolean>>;
     onSearchTermChange?: (searchTerm: string) => NonNullable<V['options']>['filter'];
     page: number;
     itemsPerPage: number;
@@ -334,7 +341,7 @@ export function PaginatedListDataTable<
         }
 
         const queryBasedColumns = columnConfigs.map(({ fieldInfo, isCustomField }) => {
-            const customConfig = customizeColumns?.[fieldInfo.name as keyof PaginatedListItemFields<T>] ?? {};
+            const customConfig = customizeColumns?.[fieldInfo.name as unknown as AllItemFieldKeys<T>] ?? {};
             const { header, ...customConfigRest } = customConfig;
             const enableColumnFilter = fieldInfo.isScalar && !facetedFilters?.[fieldInfo.name];
 
@@ -348,9 +355,11 @@ export function PaginatedListDataTable<
                 // prevents certain filters from working.
                 filterFn: 'equalsString',
                 cell: ({ cell, row }) => {
-                    const value = !isCustomField
-                        ? cell.getValue()
-                        : (row.original as any)?.customFields?.[fieldInfo.name];
+                    const cellValue = cell.getValue();
+                    const value =
+                        cellValue ??
+                        (isCustomField ? row.original?.customFields?.[fieldInfo.name] : undefined);
+
                     if (fieldInfo.list && Array.isArray(value)) {
                         return value.join(', ');
                     }
@@ -530,10 +539,10 @@ function getColumnVisibility(
         updatedAt: false,
         ...(allDefaultsTrue ? { ...Object.fromEntries(fields.map(f => [f.name, false])) } : {}),
         ...(allDefaultsFalse ? { ...Object.fromEntries(fields.map(f => [f.name, true])) } : {}),
-        ...defaultVisibility,
-        // Make custom fields hidden by default
+        // Make custom fields hidden by default unless overridden
         ...(customFieldColumnNames
             ? { ...Object.fromEntries(customFieldColumnNames.map(f => [f, false])) }
             : {}),
+        ...defaultVisibility,
     };
 }

+ 11 - 6
packages/dashboard/src/lib/framework/page/list-page.tsx

@@ -1,5 +1,6 @@
 import {
     AdditionalColumns,
+    CustomFieldKeysOfItem,
     CustomizeColumnConfig,
     FacetedFilterConfig,
     ListQueryOptionsShape,
@@ -7,11 +8,11 @@ import {
     PaginatedListDataTable,
     RowAction,
 } from '@/components/shared/paginated-list-data-table.js';
+import { useUserSettings } from '@/hooks/use-user-settings.js';
 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 { useUserSettings } from '@/hooks/use-user-settings.js';
 import { ResultOf } from 'gql.tada';
 
 import { addCustomFields } from '../document-introspection/add-custom-fields.js';
@@ -54,9 +55,11 @@ export interface ListPageProps<
     onSearchTermChange?: (searchTerm: string) => NonNullable<V['options']>['filter'];
     customizeColumns?: CustomizeColumnConfig<T>;
     additionalColumns?: AC;
-    defaultColumnOrder?: (keyof ListQueryFields<T> | keyof AC)[];
+    defaultColumnOrder?: (keyof ListQueryFields<T> | keyof AC | CustomFieldKeysOfItem<ListQueryFields<T>>)[];
     defaultSort?: SortingState;
-    defaultVisibility?: Partial<Record<keyof ListQueryFields<T> | keyof AC, boolean>>;
+    defaultVisibility?: Partial<
+        Record<keyof ListQueryFields<T> | keyof AC | CustomFieldKeysOfItem<ListQueryFields<T>>, boolean>
+    >;
     children?: React.ReactNode;
     facetedFilters?: FacetedFilterConfig<T>;
     rowActions?: RowAction<ListQueryFields<T>>[];
@@ -107,11 +110,13 @@ export function ListPage<
 
     const pagination = {
         page: routeSearch.page ? parseInt(routeSearch.page) : 1,
-        itemsPerPage: routeSearch.perPage ? parseInt(routeSearch.perPage) : tableSettings?.pageSize ?? 10,
+        itemsPerPage: routeSearch.perPage ? parseInt(routeSearch.perPage) : (tableSettings?.pageSize ?? 10),
     };
 
-    const columnVisibility = pageId ? tableSettings?.columnVisibility ?? defaultVisibility : defaultVisibility;
-    const columnOrder = pageId ? tableSettings?.columnOrder ?? defaultColumnOrder : defaultColumnOrder;
+    const columnVisibility = pageId
+        ? (tableSettings?.columnVisibility ?? defaultVisibility)
+        : defaultVisibility;
+    const columnOrder = pageId ? (tableSettings?.columnOrder ?? defaultColumnOrder) : defaultColumnOrder;
     const columnFilters = pageId ? tableSettings?.columnFilters : routeSearch.filters;
 
     const sorting: SortingState = (routeSearch.sort ?? '')

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 1
packages/dashboard/src/lib/graphql/graphql-env.d.ts


+ 2 - 0
packages/dev-server/dev-config.ts

@@ -18,6 +18,7 @@ import { TelemetryPlugin } from '@vendure/telemetry-plugin';
 import 'dotenv/config';
 import path from 'path';
 import { DataSourceOptions } from 'typeorm';
+import { ReviewsPlugin } from './test-plugins/reviews/reviews-plugin';
 
 const IS_INSTRUMENTED = process.env.IS_INSTRUMENTED === 'true';
 
@@ -98,6 +99,7 @@ export const devConfig: VendureConfig = {
         //     platformFeePercent: 10,
         //     platformFeeSKU: 'FEE',
         // }),
+        ReviewsPlugin,
         GraphiqlPlugin.init(),
         AssetServerPlugin.init({
             route: 'assets',

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 1
packages/dev-server/graphql/graphql-env.d.ts


+ 19 - 4
packages/dev-server/test-plugins/reviews/dashboard/review-list.tsx

@@ -27,6 +27,9 @@ const getReviewList = graphql(`
                 state
                 response
                 responseCreatedAt
+                customFields {
+                    reviewerName
+                }
             }
         }
     }
@@ -50,10 +53,16 @@ export const reviewList: DashboardRouteDefinition = {
             listQuery={getReviewList}
             route={route}
             defaultVisibility={{
-                product: true,
-                summary: true,
-                rating: true,
-                authorName: true,
+                productVariant: false,
+                product: false,
+                summary: false,
+                rating: false,
+                authorName: false,
+                reviewerName: false,
+                responseCreatedAt: false,
+                response: false,
+                upvotes: false,
+                downvotes: false,
             }}
             customizeColumns={{
                 product: {
@@ -62,6 +71,12 @@ export const reviewList: DashboardRouteDefinition = {
                         return <DetailPageButton id={row.original.id} label={row.original.product.name} />;
                     },
                 },
+                reviewerName: {
+                    header: 'Reviewer Name',
+                    cell: ({ row }) => {
+                        return <div className="text-red-500">{row.original.customFields?.reviewerName}</div>;
+                    },
+                },
             }}
         />
     ),

+ 5 - 5
packages/dev-server/test-plugins/reviews/entities/product-review-translation.entity.ts

@@ -2,13 +2,10 @@ import { DeepPartial } from '@vendure/common/lib/shared-types';
 import { LanguageCode, Translation, VendureEntity } from '@vendure/core';
 import { Column, Entity, Index, ManyToOne } from 'typeorm';
 
-import { ProductReview } from './product-review.entity';
+import { CustomReviewFields, ProductReview } from './product-review.entity';
 
 @Entity()
-export class ProductReviewTranslation
-    extends VendureEntity
-    implements Translation<ProductReview>
-{
+export class ProductReviewTranslation extends VendureEntity implements Translation<ProductReview> {
     constructor(input?: DeepPartial<Translation<ProductReviewTranslation>>) {
         super(input);
     }
@@ -22,4 +19,7 @@ export class ProductReviewTranslation
     @Index()
     @ManyToOne(() => ProductReview, base => base.translations, { onDelete: 'CASCADE' })
     base: ProductReview;
+
+    @Column(type => CustomReviewFields)
+    customFields: CustomReviewFields;
 }

+ 16 - 2
packages/dev-server/test-plugins/reviews/entities/product-review.entity.ts

@@ -1,10 +1,21 @@
-import { Customer, DeepPartial, LocaleString, Product, ProductVariant, Translatable, Translation, VendureEntity } from '@vendure/core';
+import {
+    Customer,
+    DeepPartial,
+    HasCustomFields,
+    LocaleString,
+    Product,
+    ProductVariant,
+    Translatable,
+    Translation,
+    VendureEntity,
+} from '@vendure/core';
 import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 import { ReviewState } from '../types';
 import { ProductReviewTranslation } from './product-review-translation.entity';
 
+export class CustomReviewFields {}
 @Entity()
-export class ProductReview extends VendureEntity implements Translatable {
+export class ProductReview extends VendureEntity implements Translatable, HasCustomFields {
     constructor(input?: DeepPartial<ProductReview>) {
         super(input);
     }
@@ -51,4 +62,7 @@ export class ProductReview extends VendureEntity implements Translatable {
 
     @OneToMany(() => ProductReviewTranslation, translation => translation.base, { eager: true })
     translations: Array<Translation<ProductReview>>;
+
+    @Column(type => CustomReviewFields)
+    customFields: CustomReviewFields;
 }

+ 115 - 3
packages/dev-server/test-plugins/reviews/reviews-plugin.ts

@@ -1,4 +1,13 @@
-import { LanguageCode, PluginCommonModule, VendurePlugin } from '@vendure/core';
+import { OnApplicationBootstrap } from '@nestjs/common';
+import {
+    LanguageCode,
+    PluginCommonModule,
+    Product,
+    ProductVariant,
+    RequestContextService,
+    TransactionalConnection,
+    VendurePlugin,
+} from '@vendure/core';
 import { AdminUiExtension } from '@vendure/ui-devkit/compiler';
 import path from 'path';
 
@@ -7,8 +16,9 @@ import { ProductEntityResolver } from './api/product-entity.resolver';
 import { ProductReviewAdminResolver } from './api/product-review-admin.resolver';
 import { ProductReviewEntityResolver } from './api/product-review-entity.resolver';
 import { ProductReviewShopResolver } from './api/product-review-shop.resolver';
-import { ProductReview } from './entities/product-review.entity';
 import { ProductReviewTranslation } from './entities/product-review-translation.entity';
+import { ProductReview } from './entities/product-review.entity';
+import { ReviewState } from './types';
 
 @VendurePlugin({
     imports: [PluginCommonModule],
@@ -47,14 +57,116 @@ import { ProductReviewTranslation } from './entities/product-review-translation.
             ui: { tab: 'Reviews', component: 'review-selector-form-input' },
             inverseSide: undefined,
         });
+        config.customFields.ProductReview = [
+            {
+                type: 'string',
+                name: 'reviewerName',
+                label: [{ languageCode: LanguageCode.en, value: 'Reviewer name' }],
+                public: true,
+                nullable: true,
+            },
+        ];
         return config;
     },
     dashboard: './dashboard/index.tsx',
 })
-export class ReviewsPlugin {
+export class ReviewsPlugin implements OnApplicationBootstrap {
+    constructor(
+        private readonly connection: TransactionalConnection,
+        private readonly requestContextService: RequestContextService,
+    ) {}
+
     static uiExtensions: AdminUiExtension = {
         extensionPath: path.join(__dirname, 'ui'),
         routes: [{ route: 'product-reviews', filePath: 'routes.ts' }],
         providers: ['providers.ts'],
     };
+
+    async onApplicationBootstrap() {
+        const ctx = await this.requestContextService.create({
+            apiType: 'admin',
+            languageCode: LanguageCode.en,
+        });
+        const reviewCount = await this.connection.getRepository(ctx, ProductReview).count();
+
+        if (reviewCount === 0) {
+            const products = await this.connection.getRepository(ctx, Product).find();
+            if (products.length === 0) {
+                return;
+            }
+
+            const demoReviews = [
+                {
+                    summary: 'Great product, highly recommend!',
+                    body: 'I was really impressed with the quality and performance. Would definitely buy again.',
+                    rating: 5,
+                    authorName: 'John Smith',
+                    authorLocation: 'New York, USA',
+                    state: 'approved',
+                    customFields: {
+                        reviewerName: 'JSmith123',
+                    },
+                },
+                {
+                    summary: 'Good value for money',
+                    body: 'Does exactly what it says. No complaints.',
+                    rating: 4,
+                    authorName: 'Sarah Wilson',
+                    authorLocation: 'London, UK',
+                    state: 'approved',
+                    customFields: {
+                        reviewerName: 'SarahW',
+                    },
+                },
+                {
+                    summary: 'Decent but could be better',
+                    body: 'The product is okay but there is room for improvement in terms of durability.',
+                    rating: 3,
+                    authorName: 'Mike Johnson',
+                    authorLocation: 'Toronto, Canada',
+                    state: 'approved',
+                    customFields: {
+                        reviewerName: 'MikeJ',
+                    },
+                },
+                {
+                    summary: 'Exceeded expectations',
+                    body: 'Really happy with this purchase. The quality is outstanding.',
+                    rating: 5,
+                    authorName: 'Emma Brown',
+                    authorLocation: 'Sydney, Australia',
+                    state: 'approved',
+                    customFields: {
+                        reviewerName: 'EmmaB',
+                    },
+                },
+                {
+                    summary: 'Good product, fast delivery',
+                    body: 'Product arrived quickly and works as described. Happy with the purchase.',
+                    rating: 4,
+                    authorName: 'David Lee',
+                    authorLocation: 'Singapore',
+                    state: 'approved',
+                    customFields: {
+                        reviewerName: 'DavidL',
+                    },
+                },
+            ];
+
+            for (const review of demoReviews) {
+                const randomProduct = products[Math.floor(Math.random() * products.length)];
+                const productVariants = await this.connection.getRepository(ctx, ProductVariant).find({
+                    where: { productId: randomProduct.id },
+                });
+                const randomVariant = productVariants[Math.floor(Math.random() * productVariants.length)];
+
+                await this.connection.getRepository(ctx, ProductReview).save({
+                    ...review,
+                    state: review.state as ReviewState,
+                    product: randomProduct,
+                    productVariant: randomVariant,
+                });
+            }
+        }
+    }
 }

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است