ソースを参照

feat(dashboard): Implement option group & option editing (#3837)

Michael Bromley 3 ヶ月 前
コミット
226d14f26b
24 ファイル変更1501 行追加383 行削除
  1. 53 0
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 270 356
      packages/admin-ui/src/lib/core/src/common/introspection-result.ts
  3. 50 0
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  4. 53 0
      packages/common/src/generated-types.ts
  5. 174 0
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  6. 70 1
      packages/core/e2e/product-option.e2e-spec.ts
  7. 25 3
      packages/core/src/api/resolvers/admin/product-option.resolver.ts
  8. 9 0
      packages/core/src/api/schema/admin-api/product-option-group.api.graphql
  9. 32 11
      packages/core/src/service/services/product-option.service.ts
  10. 10 0
      packages/dashboard/src/app/routes/_authenticated/_product-variants/product-variants.graphql.ts
  11. 19 0
      packages/dashboard/src/app/routes/_authenticated/_products/components/product-option-group-badge.tsx
  12. 111 0
      packages/dashboard/src/app/routes/_authenticated/_products/components/product-options-table.tsx
  13. 103 0
      packages/dashboard/src/app/routes/_authenticated/_products/product-option-groups.graphql.ts
  14. 5 1
      packages/dashboard/src/app/routes/_authenticated/_products/products.graphql.ts
  15. 13 7
      packages/dashboard/src/app/routes/_authenticated/_products/products_.$id.tsx
  16. 181 0
      packages/dashboard/src/app/routes/_authenticated/_products/products_.$productId.option-groups.$id.tsx
  17. 208 0
      packages/dashboard/src/app/routes/_authenticated/_products/products_.$productId.option-groups.$productOptionGroupId.options_.$id.tsx
  18. 5 1
      packages/dashboard/src/lib/graphql/graphql-env.d.ts
  19. 3 0
      packages/dashboard/vite/utils/compiler.ts
  20. 2 2
      packages/dashboard/vite/vite-plugin-vendure-dashboard.ts
  21. 5 1
      packages/dev-server/graphql/graphql-env.d.ts
  22. 50 0
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  23. 50 0
      packages/payments-plugin/e2e/graphql/generated-admin-types.ts
  24. 0 0
      schema-admin.json

+ 53 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -4849,6 +4849,18 @@ export type ProductOption = Node & {
   updatedAt: Scalars['DateTime']['output'];
 };
 
+export type ProductOptionFilterParameter = {
+  _and?: InputMaybe<Array<ProductOptionFilterParameter>>;
+  _or?: InputMaybe<Array<ProductOptionFilterParameter>>;
+  code?: InputMaybe<StringOperators>;
+  createdAt?: InputMaybe<DateOperators>;
+  groupId?: InputMaybe<IdOperators>;
+  id?: InputMaybe<IdOperators>;
+  languageCode?: InputMaybe<StringOperators>;
+  name?: InputMaybe<StringOperators>;
+  updatedAt?: InputMaybe<DateOperators>;
+};
+
 export type ProductOptionGroup = Node & {
   __typename?: 'ProductOptionGroup';
   code: Scalars['String']['output'];
@@ -4886,6 +4898,34 @@ export type ProductOptionInUseError = ErrorResult & {
   productVariantCount: Scalars['Int']['output'];
 };
 
+export type ProductOptionList = PaginatedList & {
+  __typename?: 'ProductOptionList';
+  items: Array<ProductOption>;
+  totalItems: Scalars['Int']['output'];
+};
+
+export type ProductOptionListOptions = {
+  /** Allows the results to be filtered */
+  filter?: InputMaybe<ProductOptionFilterParameter>;
+  /** Specifies whether multiple top-level "filter" fields should be combined with a logical AND or OR operation. Defaults to AND. */
+  filterOperator?: InputMaybe<LogicalOperator>;
+  /** Skips the first n results, for use in pagination */
+  skip?: InputMaybe<Scalars['Int']['input']>;
+  /** Specifies which properties to sort the results by */
+  sort?: InputMaybe<ProductOptionSortParameter>;
+  /** Takes n results, for use in pagination */
+  take?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type ProductOptionSortParameter = {
+  code?: InputMaybe<SortOrder>;
+  createdAt?: InputMaybe<SortOrder>;
+  groupId?: InputMaybe<SortOrder>;
+  id?: InputMaybe<SortOrder>;
+  name?: InputMaybe<SortOrder>;
+  updatedAt?: InputMaybe<SortOrder>;
+};
+
 export type ProductOptionTranslation = {
   __typename?: 'ProductOptionTranslation';
   createdAt: Scalars['DateTime']['output'];
@@ -5266,8 +5306,10 @@ export type Query = {
   previewCollectionVariants: ProductVariantList;
   /** Get a Product either by id or slug. If neither id nor slug is specified, an error will result. */
   product?: Maybe<Product>;
+  productOption?: Maybe<ProductOption>;
   productOptionGroup?: Maybe<ProductOptionGroup>;
   productOptionGroups: Array<ProductOptionGroup>;
+  productOptions: ProductOptionList;
   /** Get a ProductVariant by id */
   productVariant?: Maybe<ProductVariant>;
   /** List ProductVariants either all or for the specific product. */
@@ -5472,6 +5514,11 @@ export type QueryProductArgs = {
 };
 
 
+export type QueryProductOptionArgs = {
+  id: Scalars['ID']['input'];
+};
+
+
 export type QueryProductOptionGroupArgs = {
   id: Scalars['ID']['input'];
 };
@@ -5482,6 +5529,12 @@ export type QueryProductOptionGroupsArgs = {
 };
 
 
+export type QueryProductOptionsArgs = {
+  groupId?: InputMaybe<Scalars['ID']['input']>;
+  options?: InputMaybe<ProductOptionListOptions>;
+};
+
+
 export type QueryProductVariantArgs = {
   id: Scalars['ID']['input'];
 };

+ 270 - 356
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -1,359 +1,273 @@
 /* eslint-disable */
 
-      export interface PossibleTypesResultData {
-        possibleTypes: {
-          [key: string]: string[]
-        }
-      }
-      const result: PossibleTypesResultData = {
-  "possibleTypes": {
-    "AddFulfillmentToOrderResult": [
-      "CreateFulfillmentError",
-      "EmptyOrderLineSelectionError",
-      "Fulfillment",
-      "FulfillmentStateTransitionError",
-      "InsufficientStockOnHandError",
-      "InvalidFulfillmentHandlerError",
-      "ItemsAlreadyFulfilledError"
-    ],
-    "AddManualPaymentToOrderResult": [
-      "ManualPaymentStateError",
-      "Order"
-    ],
-    "ApplyCouponCodeResult": [
-      "CouponCodeExpiredError",
-      "CouponCodeInvalidError",
-      "CouponCodeLimitError",
-      "Order"
-    ],
-    "AuthenticationResult": [
-      "CurrentUser",
-      "InvalidCredentialsError"
-    ],
-    "CancelOrderResult": [
-      "CancelActiveOrderError",
-      "EmptyOrderLineSelectionError",
-      "MultipleOrderError",
-      "Order",
-      "OrderStateTransitionError",
-      "QuantityTooGreatError"
-    ],
-    "CancelPaymentResult": [
-      "CancelPaymentError",
-      "Payment",
-      "PaymentStateTransitionError"
-    ],
-    "CreateAssetResult": [
-      "Asset",
-      "MimeTypeError"
-    ],
-    "CreateChannelResult": [
-      "Channel",
-      "LanguageNotAvailableError"
-    ],
-    "CreateCustomerResult": [
-      "Customer",
-      "EmailAddressConflictError"
-    ],
-    "CreatePromotionResult": [
-      "MissingConditionsError",
-      "Promotion"
-    ],
-    "CustomField": [
-      "BooleanCustomFieldConfig",
-      "DateTimeCustomFieldConfig",
-      "FloatCustomFieldConfig",
-      "IntCustomFieldConfig",
-      "LocaleStringCustomFieldConfig",
-      "LocaleTextCustomFieldConfig",
-      "RelationCustomFieldConfig",
-      "StringCustomFieldConfig",
-      "StructCustomFieldConfig",
-      "TextCustomFieldConfig"
-    ],
-    "CustomFieldConfig": [
-      "BooleanCustomFieldConfig",
-      "DateTimeCustomFieldConfig",
-      "FloatCustomFieldConfig",
-      "IntCustomFieldConfig",
-      "LocaleStringCustomFieldConfig",
-      "LocaleTextCustomFieldConfig",
-      "RelationCustomFieldConfig",
-      "StringCustomFieldConfig",
-      "StructCustomFieldConfig",
-      "TextCustomFieldConfig"
-    ],
-    "DuplicateEntityResult": [
-      "DuplicateEntityError",
-      "DuplicateEntitySuccess"
-    ],
-    "ErrorResult": [
-      "AlreadyRefundedError",
-      "CancelActiveOrderError",
-      "CancelPaymentError",
-      "ChannelDefaultLanguageError",
-      "CouponCodeExpiredError",
-      "CouponCodeInvalidError",
-      "CouponCodeLimitError",
-      "CreateFulfillmentError",
-      "DuplicateEntityError",
-      "EmailAddressConflictError",
-      "EmptyOrderLineSelectionError",
-      "FacetInUseError",
-      "FulfillmentStateTransitionError",
-      "GuestCheckoutError",
-      "IneligibleShippingMethodError",
-      "InsufficientStockError",
-      "InsufficientStockOnHandError",
-      "InvalidCredentialsError",
-      "InvalidFulfillmentHandlerError",
-      "ItemsAlreadyFulfilledError",
-      "LanguageNotAvailableError",
-      "ManualPaymentStateError",
-      "MimeTypeError",
-      "MissingConditionsError",
-      "MultipleOrderError",
-      "NativeAuthStrategyError",
-      "NegativeQuantityError",
-      "NoActiveOrderError",
-      "NoChangesSpecifiedError",
-      "NothingToRefundError",
-      "OrderInterceptorError",
-      "OrderLimitError",
-      "OrderModificationError",
-      "OrderModificationStateError",
-      "OrderStateTransitionError",
-      "PaymentMethodMissingError",
-      "PaymentOrderMismatchError",
-      "PaymentStateTransitionError",
-      "ProductOptionInUseError",
-      "QuantityTooGreatError",
-      "RefundAmountError",
-      "RefundOrderStateError",
-      "RefundPaymentIdMissingError",
-      "RefundStateTransitionError",
-      "SettlePaymentError"
-    ],
-    "ModifyOrderResult": [
-      "CouponCodeExpiredError",
-      "CouponCodeInvalidError",
-      "CouponCodeLimitError",
-      "IneligibleShippingMethodError",
-      "InsufficientStockError",
-      "NegativeQuantityError",
-      "NoChangesSpecifiedError",
-      "Order",
-      "OrderLimitError",
-      "OrderModificationStateError",
-      "PaymentMethodMissingError",
-      "RefundPaymentIdMissingError"
-    ],
-    "NativeAuthenticationResult": [
-      "CurrentUser",
-      "InvalidCredentialsError",
-      "NativeAuthStrategyError"
-    ],
-    "Node": [
-      "Address",
-      "Administrator",
-      "Allocation",
-      "Asset",
-      "AuthenticationMethod",
-      "Cancellation",
-      "Channel",
-      "Collection",
-      "Country",
-      "Customer",
-      "CustomerGroup",
-      "Facet",
-      "FacetValue",
-      "Fulfillment",
-      "HistoryEntry",
-      "Job",
-      "Order",
-      "OrderLine",
-      "OrderModification",
-      "Payment",
-      "PaymentMethod",
-      "Product",
-      "ProductOption",
-      "ProductOptionGroup",
-      "ProductVariant",
-      "Promotion",
-      "Province",
-      "Refund",
-      "Release",
-      "Return",
-      "Role",
-      "Sale",
-      "Seller",
-      "ShippingMethod",
-      "StockAdjustment",
-      "StockLevel",
-      "StockLocation",
-      "Surcharge",
-      "Tag",
-      "TaxCategory",
-      "TaxRate",
-      "User",
-      "Zone"
-    ],
-    "PaginatedList": [
-      "AdministratorList",
-      "AssetList",
-      "ChannelList",
-      "CollectionList",
-      "CountryList",
-      "CustomerGroupList",
-      "CustomerList",
-      "FacetList",
-      "FacetValueList",
-      "HistoryEntryList",
-      "JobList",
-      "OrderList",
-      "PaymentMethodList",
-      "ProductList",
-      "ProductVariantList",
-      "PromotionList",
-      "ProvinceList",
-      "RoleList",
-      "SellerList",
-      "ShippingMethodList",
-      "StockLocationList",
-      "TagList",
-      "TaxCategoryList",
-      "TaxRateList",
-      "ZoneList"
-    ],
-    "RefundOrderResult": [
-      "AlreadyRefundedError",
-      "MultipleOrderError",
-      "NothingToRefundError",
-      "OrderStateTransitionError",
-      "PaymentOrderMismatchError",
-      "QuantityTooGreatError",
-      "Refund",
-      "RefundAmountError",
-      "RefundOrderStateError",
-      "RefundStateTransitionError"
-    ],
-    "Region": [
-      "Country",
-      "Province"
-    ],
-    "RemoveFacetFromChannelResult": [
-      "Facet",
-      "FacetInUseError"
-    ],
-    "RemoveOptionGroupFromProductResult": [
-      "Product",
-      "ProductOptionInUseError"
-    ],
-    "RemoveOrderItemsResult": [
-      "Order",
-      "OrderInterceptorError",
-      "OrderModificationError"
-    ],
-    "SearchResultPrice": [
-      "PriceRange",
-      "SinglePrice"
-    ],
-    "SetCustomerForDraftOrderResult": [
-      "EmailAddressConflictError",
-      "Order"
-    ],
-    "SetOrderShippingMethodResult": [
-      "IneligibleShippingMethodError",
-      "NoActiveOrderError",
-      "Order",
-      "OrderModificationError"
-    ],
-    "SettlePaymentResult": [
-      "OrderStateTransitionError",
-      "Payment",
-      "PaymentStateTransitionError",
-      "SettlePaymentError"
-    ],
-    "SettleRefundResult": [
-      "Refund",
-      "RefundStateTransitionError"
-    ],
-    "StockMovement": [
-      "Allocation",
-      "Cancellation",
-      "Release",
-      "Return",
-      "Sale",
-      "StockAdjustment"
-    ],
-    "StockMovementItem": [
-      "Allocation",
-      "Cancellation",
-      "Release",
-      "Return",
-      "Sale",
-      "StockAdjustment"
-    ],
-    "StructField": [
-      "BooleanStructFieldConfig",
-      "DateTimeStructFieldConfig",
-      "FloatStructFieldConfig",
-      "IntStructFieldConfig",
-      "StringStructFieldConfig",
-      "TextStructFieldConfig"
-    ],
-    "StructFieldConfig": [
-      "BooleanStructFieldConfig",
-      "DateTimeStructFieldConfig",
-      "FloatStructFieldConfig",
-      "IntStructFieldConfig",
-      "StringStructFieldConfig",
-      "TextStructFieldConfig"
-    ],
-    "TransitionFulfillmentToStateResult": [
-      "Fulfillment",
-      "FulfillmentStateTransitionError"
-    ],
-    "TransitionOrderToStateResult": [
-      "Order",
-      "OrderStateTransitionError"
-    ],
-    "TransitionPaymentToStateResult": [
-      "Payment",
-      "PaymentStateTransitionError"
-    ],
-    "UpdateChannelResult": [
-      "Channel",
-      "LanguageNotAvailableError"
-    ],
-    "UpdateCustomerResult": [
-      "Customer",
-      "EmailAddressConflictError"
-    ],
-    "UpdateGlobalSettingsResult": [
-      "ChannelDefaultLanguageError",
-      "GlobalSettings"
-    ],
-    "UpdateOrderItemErrorResult": [
-      "InsufficientStockError",
-      "NegativeQuantityError",
-      "OrderInterceptorError",
-      "OrderLimitError",
-      "OrderModificationError"
-    ],
-    "UpdateOrderItemsResult": [
-      "InsufficientStockError",
-      "NegativeQuantityError",
-      "Order",
-      "OrderInterceptorError",
-      "OrderLimitError",
-      "OrderModificationError"
-    ],
-    "UpdatePromotionResult": [
-      "MissingConditionsError",
-      "Promotion"
-    ]
-  }
+export interface PossibleTypesResultData {
+    possibleTypes: {
+        [key: string]: string[];
+    };
+}
+const result: PossibleTypesResultData = {
+    possibleTypes: {
+        AddFulfillmentToOrderResult: [
+            'CreateFulfillmentError',
+            'EmptyOrderLineSelectionError',
+            'Fulfillment',
+            'FulfillmentStateTransitionError',
+            'InsufficientStockOnHandError',
+            'InvalidFulfillmentHandlerError',
+            'ItemsAlreadyFulfilledError',
+        ],
+        AddManualPaymentToOrderResult: ['ManualPaymentStateError', 'Order'],
+        ApplyCouponCodeResult: [
+            'CouponCodeExpiredError',
+            'CouponCodeInvalidError',
+            'CouponCodeLimitError',
+            'Order',
+        ],
+        AuthenticationResult: ['CurrentUser', 'InvalidCredentialsError'],
+        CancelOrderResult: [
+            'CancelActiveOrderError',
+            'EmptyOrderLineSelectionError',
+            'MultipleOrderError',
+            'Order',
+            'OrderStateTransitionError',
+            'QuantityTooGreatError',
+        ],
+        CancelPaymentResult: ['CancelPaymentError', 'Payment', 'PaymentStateTransitionError'],
+        CreateAssetResult: ['Asset', 'MimeTypeError'],
+        CreateChannelResult: ['Channel', 'LanguageNotAvailableError'],
+        CreateCustomerResult: ['Customer', 'EmailAddressConflictError'],
+        CreatePromotionResult: ['MissingConditionsError', 'Promotion'],
+        CustomField: [
+            'BooleanCustomFieldConfig',
+            'DateTimeCustomFieldConfig',
+            'FloatCustomFieldConfig',
+            'IntCustomFieldConfig',
+            'LocaleStringCustomFieldConfig',
+            'LocaleTextCustomFieldConfig',
+            'RelationCustomFieldConfig',
+            'StringCustomFieldConfig',
+            'StructCustomFieldConfig',
+            'TextCustomFieldConfig',
+        ],
+        CustomFieldConfig: [
+            'BooleanCustomFieldConfig',
+            'DateTimeCustomFieldConfig',
+            'FloatCustomFieldConfig',
+            'IntCustomFieldConfig',
+            'LocaleStringCustomFieldConfig',
+            'LocaleTextCustomFieldConfig',
+            'RelationCustomFieldConfig',
+            'StringCustomFieldConfig',
+            'StructCustomFieldConfig',
+            'TextCustomFieldConfig',
+        ],
+        DuplicateEntityResult: ['DuplicateEntityError', 'DuplicateEntitySuccess'],
+        ErrorResult: [
+            'AlreadyRefundedError',
+            'CancelActiveOrderError',
+            'CancelPaymentError',
+            'ChannelDefaultLanguageError',
+            'CouponCodeExpiredError',
+            'CouponCodeInvalidError',
+            'CouponCodeLimitError',
+            'CreateFulfillmentError',
+            'DuplicateEntityError',
+            'EmailAddressConflictError',
+            'EmptyOrderLineSelectionError',
+            'FacetInUseError',
+            'FulfillmentStateTransitionError',
+            'GuestCheckoutError',
+            'IneligibleShippingMethodError',
+            'InsufficientStockError',
+            'InsufficientStockOnHandError',
+            'InvalidCredentialsError',
+            'InvalidFulfillmentHandlerError',
+            'ItemsAlreadyFulfilledError',
+            'LanguageNotAvailableError',
+            'ManualPaymentStateError',
+            'MimeTypeError',
+            'MissingConditionsError',
+            'MultipleOrderError',
+            'NativeAuthStrategyError',
+            'NegativeQuantityError',
+            'NoActiveOrderError',
+            'NoChangesSpecifiedError',
+            'NothingToRefundError',
+            'OrderInterceptorError',
+            'OrderLimitError',
+            'OrderModificationError',
+            'OrderModificationStateError',
+            'OrderStateTransitionError',
+            'PaymentMethodMissingError',
+            'PaymentOrderMismatchError',
+            'PaymentStateTransitionError',
+            'ProductOptionInUseError',
+            'QuantityTooGreatError',
+            'RefundAmountError',
+            'RefundOrderStateError',
+            'RefundPaymentIdMissingError',
+            'RefundStateTransitionError',
+            'SettlePaymentError',
+        ],
+        ModifyOrderResult: [
+            'CouponCodeExpiredError',
+            'CouponCodeInvalidError',
+            'CouponCodeLimitError',
+            'IneligibleShippingMethodError',
+            'InsufficientStockError',
+            'NegativeQuantityError',
+            'NoChangesSpecifiedError',
+            'Order',
+            'OrderLimitError',
+            'OrderModificationStateError',
+            'PaymentMethodMissingError',
+            'RefundPaymentIdMissingError',
+        ],
+        NativeAuthenticationResult: ['CurrentUser', 'InvalidCredentialsError', 'NativeAuthStrategyError'],
+        Node: [
+            'Address',
+            'Administrator',
+            'Allocation',
+            'Asset',
+            'AuthenticationMethod',
+            'Cancellation',
+            'Channel',
+            'Collection',
+            'Country',
+            'Customer',
+            'CustomerGroup',
+            'Facet',
+            'FacetValue',
+            'Fulfillment',
+            'HistoryEntry',
+            'Job',
+            'Order',
+            'OrderLine',
+            'OrderModification',
+            'Payment',
+            'PaymentMethod',
+            'Product',
+            'ProductOption',
+            'ProductOptionGroup',
+            'ProductVariant',
+            'Promotion',
+            'Province',
+            'Refund',
+            'Release',
+            'Return',
+            'Role',
+            'Sale',
+            'Seller',
+            'ShippingMethod',
+            'StockAdjustment',
+            'StockLevel',
+            'StockLocation',
+            'Surcharge',
+            'Tag',
+            'TaxCategory',
+            'TaxRate',
+            'User',
+            'Zone',
+        ],
+        PaginatedList: [
+            'AdministratorList',
+            'AssetList',
+            'ChannelList',
+            'CollectionList',
+            'CountryList',
+            'CustomerGroupList',
+            'CustomerList',
+            'FacetList',
+            'FacetValueList',
+            'HistoryEntryList',
+            'JobList',
+            'OrderList',
+            'PaymentMethodList',
+            'ProductList',
+            'ProductOptionList',
+            'ProductVariantList',
+            'PromotionList',
+            'ProvinceList',
+            'RoleList',
+            'SellerList',
+            'ShippingMethodList',
+            'StockLocationList',
+            'TagList',
+            'TaxCategoryList',
+            'TaxRateList',
+            'ZoneList',
+        ],
+        RefundOrderResult: [
+            'AlreadyRefundedError',
+            'MultipleOrderError',
+            'NothingToRefundError',
+            'OrderStateTransitionError',
+            'PaymentOrderMismatchError',
+            'QuantityTooGreatError',
+            'Refund',
+            'RefundAmountError',
+            'RefundOrderStateError',
+            'RefundStateTransitionError',
+        ],
+        Region: ['Country', 'Province'],
+        RemoveFacetFromChannelResult: ['Facet', 'FacetInUseError'],
+        RemoveOptionGroupFromProductResult: ['Product', 'ProductOptionInUseError'],
+        RemoveOrderItemsResult: ['Order', 'OrderInterceptorError', 'OrderModificationError'],
+        SearchResultPrice: ['PriceRange', 'SinglePrice'],
+        SetCustomerForDraftOrderResult: ['EmailAddressConflictError', 'Order'],
+        SetOrderShippingMethodResult: [
+            'IneligibleShippingMethodError',
+            'NoActiveOrderError',
+            'Order',
+            'OrderModificationError',
+        ],
+        SettlePaymentResult: [
+            'OrderStateTransitionError',
+            'Payment',
+            'PaymentStateTransitionError',
+            'SettlePaymentError',
+        ],
+        SettleRefundResult: ['Refund', 'RefundStateTransitionError'],
+        StockMovement: ['Allocation', 'Cancellation', 'Release', 'Return', 'Sale', 'StockAdjustment'],
+        StockMovementItem: ['Allocation', 'Cancellation', 'Release', 'Return', 'Sale', 'StockAdjustment'],
+        StructField: [
+            'BooleanStructFieldConfig',
+            'DateTimeStructFieldConfig',
+            'FloatStructFieldConfig',
+            'IntStructFieldConfig',
+            'StringStructFieldConfig',
+            'TextStructFieldConfig',
+        ],
+        StructFieldConfig: [
+            'BooleanStructFieldConfig',
+            'DateTimeStructFieldConfig',
+            'FloatStructFieldConfig',
+            'IntStructFieldConfig',
+            'StringStructFieldConfig',
+            'TextStructFieldConfig',
+        ],
+        TransitionFulfillmentToStateResult: ['Fulfillment', 'FulfillmentStateTransitionError'],
+        TransitionOrderToStateResult: ['Order', 'OrderStateTransitionError'],
+        TransitionPaymentToStateResult: ['Payment', 'PaymentStateTransitionError'],
+        UpdateChannelResult: ['Channel', 'LanguageNotAvailableError'],
+        UpdateCustomerResult: ['Customer', 'EmailAddressConflictError'],
+        UpdateGlobalSettingsResult: ['ChannelDefaultLanguageError', 'GlobalSettings'],
+        UpdateOrderItemErrorResult: [
+            'InsufficientStockError',
+            'NegativeQuantityError',
+            'OrderInterceptorError',
+            'OrderLimitError',
+            'OrderModificationError',
+        ],
+        UpdateOrderItemsResult: [
+            'InsufficientStockError',
+            'NegativeQuantityError',
+            'Order',
+            'OrderInterceptorError',
+            'OrderLimitError',
+            'OrderModificationError',
+        ],
+        UpdatePromotionResult: ['MissingConditionsError', 'Promotion'],
+    },
 };
-      export default result;
-    
+export default result;

+ 50 - 0
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -4523,6 +4523,18 @@ export type ProductOption = Node & {
     updatedAt: Scalars['DateTime']['output'];
 };
 
+export type ProductOptionFilterParameter = {
+    _and?: InputMaybe<Array<ProductOptionFilterParameter>>;
+    _or?: InputMaybe<Array<ProductOptionFilterParameter>>;
+    code?: InputMaybe<StringOperators>;
+    createdAt?: InputMaybe<DateOperators>;
+    groupId?: InputMaybe<IdOperators>;
+    id?: InputMaybe<IdOperators>;
+    languageCode?: InputMaybe<StringOperators>;
+    name?: InputMaybe<StringOperators>;
+    updatedAt?: InputMaybe<DateOperators>;
+};
+
 export type ProductOptionGroup = Node & {
     code: Scalars['String']['output'];
     createdAt: Scalars['DateTime']['output'];
@@ -4557,6 +4569,33 @@ export type ProductOptionInUseError = ErrorResult & {
     productVariantCount: Scalars['Int']['output'];
 };
 
+export type ProductOptionList = PaginatedList & {
+    items: Array<ProductOption>;
+    totalItems: Scalars['Int']['output'];
+};
+
+export type ProductOptionListOptions = {
+    /** Allows the results to be filtered */
+    filter?: InputMaybe<ProductOptionFilterParameter>;
+    /** Specifies whether multiple top-level "filter" fields should be combined with a logical AND or OR operation. Defaults to AND. */
+    filterOperator?: InputMaybe<LogicalOperator>;
+    /** Skips the first n results, for use in pagination */
+    skip?: InputMaybe<Scalars['Int']['input']>;
+    /** Specifies which properties to sort the results by */
+    sort?: InputMaybe<ProductOptionSortParameter>;
+    /** Takes n results, for use in pagination */
+    take?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type ProductOptionSortParameter = {
+    code?: InputMaybe<SortOrder>;
+    createdAt?: InputMaybe<SortOrder>;
+    groupId?: InputMaybe<SortOrder>;
+    id?: InputMaybe<SortOrder>;
+    name?: InputMaybe<SortOrder>;
+    updatedAt?: InputMaybe<SortOrder>;
+};
+
 export type ProductOptionTranslation = {
     createdAt: Scalars['DateTime']['output'];
     id: Scalars['ID']['output'];
@@ -4923,8 +4962,10 @@ export type Query = {
     previewCollectionVariants: ProductVariantList;
     /** Get a Product either by id or slug. If neither id nor slug is specified, an error will result. */
     product?: Maybe<Product>;
+    productOption?: Maybe<ProductOption>;
     productOptionGroup?: Maybe<ProductOptionGroup>;
     productOptionGroups: Array<ProductOptionGroup>;
+    productOptions: ProductOptionList;
     /** Get a ProductVariant by id */
     productVariant?: Maybe<ProductVariant>;
     /** List ProductVariants either all or for the specific product. */
@@ -5094,6 +5135,10 @@ export type QueryProductArgs = {
     slug?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type QueryProductOptionArgs = {
+    id: Scalars['ID']['input'];
+};
+
 export type QueryProductOptionGroupArgs = {
     id: Scalars['ID']['input'];
 };
@@ -5102,6 +5147,11 @@ export type QueryProductOptionGroupsArgs = {
     filterTerm?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type QueryProductOptionsArgs = {
+    groupId?: InputMaybe<Scalars['ID']['input']>;
+    options?: InputMaybe<ProductOptionListOptions>;
+};
+
 export type QueryProductVariantArgs = {
     id: Scalars['ID']['input'];
 };

+ 53 - 0
packages/common/src/generated-types.ts

@@ -4774,6 +4774,18 @@ export type ProductOption = Node & {
   updatedAt: Scalars['DateTime']['output'];
 };
 
+export type ProductOptionFilterParameter = {
+  _and?: InputMaybe<Array<ProductOptionFilterParameter>>;
+  _or?: InputMaybe<Array<ProductOptionFilterParameter>>;
+  code?: InputMaybe<StringOperators>;
+  createdAt?: InputMaybe<DateOperators>;
+  groupId?: InputMaybe<IdOperators>;
+  id?: InputMaybe<IdOperators>;
+  languageCode?: InputMaybe<StringOperators>;
+  name?: InputMaybe<StringOperators>;
+  updatedAt?: InputMaybe<DateOperators>;
+};
+
 export type ProductOptionGroup = Node & {
   __typename?: 'ProductOptionGroup';
   code: Scalars['String']['output'];
@@ -4811,6 +4823,34 @@ export type ProductOptionInUseError = ErrorResult & {
   productVariantCount: Scalars['Int']['output'];
 };
 
+export type ProductOptionList = PaginatedList & {
+  __typename?: 'ProductOptionList';
+  items: Array<ProductOption>;
+  totalItems: Scalars['Int']['output'];
+};
+
+export type ProductOptionListOptions = {
+  /** Allows the results to be filtered */
+  filter?: InputMaybe<ProductOptionFilterParameter>;
+  /** Specifies whether multiple top-level "filter" fields should be combined with a logical AND or OR operation. Defaults to AND. */
+  filterOperator?: InputMaybe<LogicalOperator>;
+  /** Skips the first n results, for use in pagination */
+  skip?: InputMaybe<Scalars['Int']['input']>;
+  /** Specifies which properties to sort the results by */
+  sort?: InputMaybe<ProductOptionSortParameter>;
+  /** Takes n results, for use in pagination */
+  take?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type ProductOptionSortParameter = {
+  code?: InputMaybe<SortOrder>;
+  createdAt?: InputMaybe<SortOrder>;
+  groupId?: InputMaybe<SortOrder>;
+  id?: InputMaybe<SortOrder>;
+  name?: InputMaybe<SortOrder>;
+  updatedAt?: InputMaybe<SortOrder>;
+};
+
 export type ProductOptionTranslation = {
   __typename?: 'ProductOptionTranslation';
   createdAt: Scalars['DateTime']['output'];
@@ -5190,8 +5230,10 @@ export type Query = {
   previewCollectionVariants: ProductVariantList;
   /** Get a Product either by id or slug. If neither id nor slug is specified, an error will result. */
   product?: Maybe<Product>;
+  productOption?: Maybe<ProductOption>;
   productOptionGroup?: Maybe<ProductOptionGroup>;
   productOptionGroups: Array<ProductOptionGroup>;
+  productOptions: ProductOptionList;
   /** Get a ProductVariant by id */
   productVariant?: Maybe<ProductVariant>;
   /** List ProductVariants either all or for the specific product. */
@@ -5394,6 +5436,11 @@ export type QueryProductArgs = {
 };
 
 
+export type QueryProductOptionArgs = {
+  id: Scalars['ID']['input'];
+};
+
+
 export type QueryProductOptionGroupArgs = {
   id: Scalars['ID']['input'];
 };
@@ -5404,6 +5451,12 @@ export type QueryProductOptionGroupsArgs = {
 };
 
 
+export type QueryProductOptionsArgs = {
+  groupId?: InputMaybe<Scalars['ID']['input']>;
+  options?: InputMaybe<ProductOptionListOptions>;
+};
+
+
 export type QueryProductVariantArgs = {
   id: Scalars['ID']['input'];
 };

+ 174 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -4523,6 +4523,18 @@ export type ProductOption = Node & {
     updatedAt: Scalars['DateTime']['output'];
 };
 
+export type ProductOptionFilterParameter = {
+    _and?: InputMaybe<Array<ProductOptionFilterParameter>>;
+    _or?: InputMaybe<Array<ProductOptionFilterParameter>>;
+    code?: InputMaybe<StringOperators>;
+    createdAt?: InputMaybe<DateOperators>;
+    groupId?: InputMaybe<IdOperators>;
+    id?: InputMaybe<IdOperators>;
+    languageCode?: InputMaybe<StringOperators>;
+    name?: InputMaybe<StringOperators>;
+    updatedAt?: InputMaybe<DateOperators>;
+};
+
 export type ProductOptionGroup = Node & {
     code: Scalars['String']['output'];
     createdAt: Scalars['DateTime']['output'];
@@ -4557,6 +4569,33 @@ export type ProductOptionInUseError = ErrorResult & {
     productVariantCount: Scalars['Int']['output'];
 };
 
+export type ProductOptionList = PaginatedList & {
+    items: Array<ProductOption>;
+    totalItems: Scalars['Int']['output'];
+};
+
+export type ProductOptionListOptions = {
+    /** Allows the results to be filtered */
+    filter?: InputMaybe<ProductOptionFilterParameter>;
+    /** Specifies whether multiple top-level "filter" fields should be combined with a logical AND or OR operation. Defaults to AND. */
+    filterOperator?: InputMaybe<LogicalOperator>;
+    /** Skips the first n results, for use in pagination */
+    skip?: InputMaybe<Scalars['Int']['input']>;
+    /** Specifies which properties to sort the results by */
+    sort?: InputMaybe<ProductOptionSortParameter>;
+    /** Takes n results, for use in pagination */
+    take?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type ProductOptionSortParameter = {
+    code?: InputMaybe<SortOrder>;
+    createdAt?: InputMaybe<SortOrder>;
+    groupId?: InputMaybe<SortOrder>;
+    id?: InputMaybe<SortOrder>;
+    name?: InputMaybe<SortOrder>;
+    updatedAt?: InputMaybe<SortOrder>;
+};
+
 export type ProductOptionTranslation = {
     createdAt: Scalars['DateTime']['output'];
     id: Scalars['ID']['output'];
@@ -4923,8 +4962,10 @@ export type Query = {
     previewCollectionVariants: ProductVariantList;
     /** Get a Product either by id or slug. If neither id nor slug is specified, an error will result. */
     product?: Maybe<Product>;
+    productOption?: Maybe<ProductOption>;
     productOptionGroup?: Maybe<ProductOptionGroup>;
     productOptionGroups: Array<ProductOptionGroup>;
+    productOptions: ProductOptionList;
     /** Get a ProductVariant by id */
     productVariant?: Maybe<ProductVariant>;
     /** List ProductVariants either all or for the specific product. */
@@ -5094,6 +5135,10 @@ export type QueryProductArgs = {
     slug?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type QueryProductOptionArgs = {
+    id: Scalars['ID']['input'];
+};
+
 export type QueryProductOptionGroupArgs = {
     id: Scalars['ID']['input'];
 };
@@ -5102,6 +5147,11 @@ export type QueryProductOptionGroupsArgs = {
     filterTerm?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type QueryProductOptionsArgs = {
+    groupId?: InputMaybe<Scalars['ID']['input']>;
+    options?: InputMaybe<ProductOptionListOptions>;
+};
+
 export type QueryProductVariantArgs = {
     id: Scalars['ID']['input'];
 };
@@ -12019,6 +12069,12 @@ export type CreateProductOptionMutation = {
     };
 };
 
+export type GetProductOptionQueryVariables = Exact<{
+    id: Scalars['ID']['input'];
+}>;
+
+export type GetProductOptionQuery = { productOption?: { id: string; name: string; code: string } | null };
+
 export type UpdateProductOptionMutationVariables = Exact<{
     input: UpdateProductOptionInput;
 }>;
@@ -12035,6 +12091,18 @@ export type DeleteProductOptionMutation = {
     deleteProductOption: { result: DeletionResult; message?: string | null };
 };
 
+export type GetProductOptionsQueryVariables = Exact<{
+    groupId?: InputMaybe<Scalars['ID']['input']>;
+    options?: InputMaybe<ProductOptionListOptions>;
+}>;
+
+export type GetProductOptionsQuery = {
+    productOptions: {
+        totalItems: number;
+        items: Array<{ id: string; code: string; name: string; groupId: string }>;
+    };
+};
+
 export type RemoveOptionGroupFromProductMutationVariables = Exact<{
     productId: Scalars['ID']['input'];
     optionGroupId: Scalars['ID']['input'];
@@ -36768,6 +36836,50 @@ export const CreateProductOptionDocument = {
         },
     ],
 } as unknown as DocumentNode<CreateProductOptionMutation, CreateProductOptionMutationVariables>;
+export const GetProductOptionDocument = {
+    kind: 'Document',
+    definitions: [
+        {
+            kind: 'OperationDefinition',
+            operation: 'query',
+            name: { kind: 'Name', value: 'GetProductOption' },
+            variableDefinitions: [
+                {
+                    kind: 'VariableDefinition',
+                    variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } },
+                    type: {
+                        kind: 'NonNullType',
+                        type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
+                    },
+                },
+            ],
+            selectionSet: {
+                kind: 'SelectionSet',
+                selections: [
+                    {
+                        kind: 'Field',
+                        name: { kind: 'Name', value: 'productOption' },
+                        arguments: [
+                            {
+                                kind: 'Argument',
+                                name: { kind: 'Name', value: 'id' },
+                                value: { kind: 'Variable', name: { kind: 'Name', value: 'id' } },
+                            },
+                        ],
+                        selectionSet: {
+                            kind: 'SelectionSet',
+                            selections: [
+                                { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+                                { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+                                { kind: 'Field', name: { kind: 'Name', value: 'code' } },
+                            ],
+                        },
+                    },
+                ],
+            },
+        },
+    ],
+} as unknown as DocumentNode<GetProductOptionQuery, GetProductOptionQueryVariables>;
 export const UpdateProductOptionDocument = {
     kind: 'Document',
     definitions: [
@@ -36859,6 +36971,68 @@ export const DeleteProductOptionDocument = {
         },
     ],
 } as unknown as DocumentNode<DeleteProductOptionMutation, DeleteProductOptionMutationVariables>;
+export const GetProductOptionsDocument = {
+    kind: 'Document',
+    definitions: [
+        {
+            kind: 'OperationDefinition',
+            operation: 'query',
+            name: { kind: 'Name', value: 'GetProductOptions' },
+            variableDefinitions: [
+                {
+                    kind: 'VariableDefinition',
+                    variable: { kind: 'Variable', name: { kind: 'Name', value: 'groupId' } },
+                    type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
+                },
+                {
+                    kind: 'VariableDefinition',
+                    variable: { kind: 'Variable', name: { kind: 'Name', value: 'options' } },
+                    type: { kind: 'NamedType', name: { kind: 'Name', value: 'ProductOptionListOptions' } },
+                },
+            ],
+            selectionSet: {
+                kind: 'SelectionSet',
+                selections: [
+                    {
+                        kind: 'Field',
+                        name: { kind: 'Name', value: 'productOptions' },
+                        arguments: [
+                            {
+                                kind: 'Argument',
+                                name: { kind: 'Name', value: 'groupId' },
+                                value: { kind: 'Variable', name: { kind: 'Name', value: 'groupId' } },
+                            },
+                            {
+                                kind: 'Argument',
+                                name: { kind: 'Name', value: 'options' },
+                                value: { kind: 'Variable', name: { kind: 'Name', value: 'options' } },
+                            },
+                        ],
+                        selectionSet: {
+                            kind: 'SelectionSet',
+                            selections: [
+                                {
+                                    kind: 'Field',
+                                    name: { kind: 'Name', value: 'items' },
+                                    selectionSet: {
+                                        kind: 'SelectionSet',
+                                        selections: [
+                                            { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+                                            { kind: 'Field', name: { kind: 'Name', value: 'code' } },
+                                            { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+                                            { kind: 'Field', name: { kind: 'Name', value: 'groupId' } },
+                                        ],
+                                    },
+                                },
+                                { kind: 'Field', name: { kind: 'Name', value: 'totalItems' } },
+                            ],
+                        },
+                    },
+                ],
+            },
+        },
+    ],
+} as unknown as DocumentNode<GetProductOptionsQuery, GetProductOptionsQueryVariables>;
 export const RemoveOptionGroupFromProductDocument = {
     kind: 'Document',
     definitions: [

+ 70 - 1
packages/core/e2e/product-option.e2e-spec.ts

@@ -4,7 +4,7 @@ import path from 'path';
 import { afterAll, beforeAll, describe, expect, it } from 'vitest';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 import { omit } from '../../common/lib/omit';
 
 import { PRODUCT_OPTION_GROUP_FRAGMENT } from './graphql/fragments';
@@ -136,6 +136,51 @@ describe('ProductOption resolver', () => {
         mediumOption = createProductOption;
     });
 
+    it('getProductOption', async () => {
+        const { productOption } = await adminClient.query<
+            Codegen.GetProductOptionQuery,
+            Codegen.GetProductOptionQueryVariables
+        >(GET_PRODUCT_OPTION, {
+            id: 'T_7',
+        });
+
+        expect(productOption?.name).toBe('Medium');
+    });
+
+    it('productOptions query without groupId', async () => {
+        const { productOptions } = await adminClient.query<
+            Codegen.GetProductOptionsQuery,
+            Codegen.GetProductOptionsQueryVariables
+        >(GET_PRODUCT_OPTIONS, {});
+
+        expect(productOptions.items).toBeDefined();
+        expect(productOptions.totalItems).toBe(7);
+        // Should return all product options
+        const foundMediumOption = productOptions.items.find((o: any) => o.code === 'medium');
+        expect(foundMediumOption).toBeDefined();
+        expect(foundMediumOption?.name).toBe('Medium');
+        expect(foundMediumOption?.groupId).toBe(sizeGroup.id);
+    });
+
+    it('productOptions query with groupId', async () => {
+        const { productOptions } = await adminClient.query<
+            Codegen.GetProductOptionsQuery,
+            Codegen.GetProductOptionsQueryVariables
+        >(GET_PRODUCT_OPTIONS, {
+            groupId: sizeGroup.id,
+        });
+
+        expect(productOptions.items).toBeDefined();
+        expect(productOptions.totalItems).toBe(3);
+        // Should only return options from the specified group
+        productOptions.items.forEach((option: any) => {
+            expect(option.groupId).toBe(sizeGroup.id);
+        });
+        const foundMediumOption = productOptions.items.find((o: any) => o.code === 'medium');
+        expect(foundMediumOption).toBeDefined();
+        expect(foundMediumOption?.name).toBe('Medium');
+    });
+
     it('updateProductOption', async () => {
         const { updateProductOption } = await adminClient.query<
             Codegen.UpdateProductOptionMutation,
@@ -309,6 +354,16 @@ const CREATE_PRODUCT_OPTION = gql`
     }
 `;
 
+const GET_PRODUCT_OPTION = gql`
+    query GetProductOption($id: ID!) {
+        productOption(id: $id) {
+            id
+            name
+            code
+        }
+    }
+`;
+
 const UPDATE_PRODUCT_OPTION = gql`
     mutation UpdateProductOption($input: UpdateProductOptionInput!) {
         updateProductOption(input: $input) {
@@ -328,3 +383,17 @@ const DELETE_PRODUCT_OPTION = gql`
         }
     }
 `;
+
+const GET_PRODUCT_OPTIONS = gql`
+    query GetProductOptions($groupId: ID, $options: ProductOptionListOptions) {
+        productOptions(groupId: $groupId, options: $options) {
+            items {
+                id
+                code
+                name
+                groupId
+            }
+            totalItems
+        }
+    }
+`;

+ 25 - 3
packages/core/src/api/resolvers/admin/product-option.resolver.ts

@@ -1,20 +1,22 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
     DeletionResponse,
-    DeletionResult,
     MutationCreateProductOptionArgs,
     MutationCreateProductOptionGroupArgs,
     MutationDeleteProductOptionArgs,
     MutationUpdateProductOptionArgs,
     MutationUpdateProductOptionGroupArgs,
     Permission,
+    QueryProductOptionArgs,
     QueryProductOptionGroupArgs,
     QueryProductOptionGroupsArgs,
+    QueryProductOptionsArgs,
 } from '@vendure/common/lib/generated-types';
+import { PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { Translated } from '../../../common/types/locale-types';
-import { ProductOption } from '../../../entity/product-option/product-option.entity';
 import { ProductOptionGroup } from '../../../entity/product-option-group/product-option-group.entity';
+import { ProductOption } from '../../../entity/product-option/product-option.entity';
 import { ProductOptionGroupService } from '../../../service/services/product-option-group.service';
 import { ProductOptionService } from '../../../service/services/product-option.service';
 import { RequestContext } from '../../common/request-context';
@@ -47,7 +49,7 @@ export class ProductOptionResolver {
         @Args() args: QueryProductOptionGroupArgs,
         @Relations(ProductOptionGroup) relations: RelationPaths<ProductOptionGroup>,
     ): Promise<Translated<ProductOptionGroup> | undefined> {
-        return this.productOptionGroupService.findOne(ctx, args.id);
+        return this.productOptionGroupService.findOne(ctx, args.id, relations);
     }
 
     @Transaction()
@@ -80,6 +82,26 @@ export class ProductOptionResolver {
         return this.productOptionGroupService.update(ctx, input);
     }
 
+    @Query()
+    @Allow(Permission.ReadCatalog, Permission.ReadProduct)
+    productOption(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QueryProductOptionArgs,
+        @Relations(ProductOption) relations: RelationPaths<ProductOption>,
+    ): Promise<Translated<ProductOption> | undefined> {
+        return this.productOptionService.findOne(ctx, args.id, relations);
+    }
+
+    @Query()
+    @Allow(Permission.ReadCatalog, Permission.ReadProduct)
+    productOptions(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QueryProductOptionsArgs,
+        @Relations(ProductOption) relations: RelationPaths<ProductOption>,
+    ): Promise<PaginatedList<Translated<ProductOption>>> {
+        return this.productOptionService.findAll(ctx, args.options, args.groupId, relations);
+    }
+
     @Transaction()
     @Mutation()
     @Allow(Permission.CreateCatalog, Permission.CreateProduct)

+ 9 - 0
packages/core/src/api/schema/admin-api/product-option-group.api.graphql

@@ -1,6 +1,8 @@
 type Query {
     productOptionGroups(filterTerm: String): [ProductOptionGroup!]!
     productOptionGroup(id: ID!): ProductOptionGroup
+    productOptions(options: ProductOptionListOptions, groupId: ID): ProductOptionList!
+    productOption(id: ID!): ProductOption
 }
 
 type Mutation {
@@ -16,6 +18,13 @@ type Mutation {
     deleteProductOption(id: ID!): DeletionResponse!
 }
 
+input ProductOptionListOptions
+
+type ProductOptionList implements PaginatedList {
+    totalItems: Int!
+    items: [ProductOption!]!
+}
+
 input ProductOptionGroupTranslationInput {
     id: ID
     languageCode: LanguageCode!

+ 32 - 11
packages/core/src/service/services/product-option.service.ts

@@ -6,10 +6,12 @@ import {
     DeletionResult,
     UpdateProductOptionInput,
 } from '@vendure/common/lib/generated-types';
-import { ID } from '@vendure/common/lib/shared-types';
+import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { IsNull } from 'typeorm';
 
+import { RelationPaths } from '../../api';
 import { RequestContext } from '../../api/common/request-context';
+import { ListQueryOptions } from '../../common';
 import { Instrument } from '../../common/instrument-decorator';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound } from '../../common/utils';
@@ -22,6 +24,7 @@ import { ProductVariant } from '../../entity/product-variant/product-variant.ent
 import { EventBus } from '../../event-bus';
 import { ProductOptionEvent } from '../../event-bus/events/product-option-event';
 import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
+import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
 import { TranslatorService } from '../helpers/translator/translator.service';
 
@@ -40,24 +43,42 @@ export class ProductOptionService {
         private customFieldRelationService: CustomFieldRelationService,
         private eventBus: EventBus,
         private translator: TranslatorService,
+        private listQueryBuilder: ListQueryBuilder,
     ) {}
 
-    findAll(ctx: RequestContext): Promise<Array<Translated<ProductOption>>> {
-        return this.connection
-            .getRepository(ctx, ProductOption)
-            .find({
-                relations: ['group'],
-                where: { deletedAt: IsNull() },
-            })
-            .then(options => options.map(option => this.translator.translate(option, ctx)));
+    findAll(
+        ctx: RequestContext,
+        options?: ListQueryOptions<ProductOption>,
+        groupId?: ID,
+        relations?: RelationPaths<ProductOption>,
+    ): Promise<PaginatedList<Translated<ProductOption>>> {
+        const qb = this.listQueryBuilder.build(ProductOption, options, {
+            entityAlias: 'option',
+            ctx,
+            where: {
+                deletedAt: IsNull(),
+            },
+            relations,
+        });
+        if (groupId) {
+            qb.andWhere('option.groupId = :groupId', { groupId });
+        }
+        return qb.getManyAndCount().then(([items, totalItems]) => ({
+            items: items.map(option => this.translator.translate(option, ctx)),
+            totalItems,
+        }));
     }
 
-    findOne(ctx: RequestContext, id: ID): Promise<Translated<ProductOption> | undefined> {
+    findOne(
+        ctx: RequestContext,
+        id: ID,
+        relations?: RelationPaths<ProductOption>,
+    ): Promise<Translated<ProductOption> | undefined> {
         return this.connection
             .getRepository(ctx, ProductOption)
             .findOne({
                 where: { id, deletedAt: IsNull() },
-                relations: ['group'],
+                relations: relations ?? ['group'],
             })
             .then(option => (option && this.translator.translate(option, ctx)) ?? undefined);
     }

+ 10 - 0
packages/dashboard/src/app/routes/_authenticated/_product-variants/product-variants.graphql.ts

@@ -60,6 +60,16 @@ export const productVariantDetailDocument = graphql(
                         name
                     }
                 }
+                options {
+                    id
+                    name
+                    code
+                    group {
+                        id
+                        name
+                        code
+                    }
+                }
                 translations {
                     id
                     languageCode

+ 19 - 0
packages/dashboard/src/app/routes/_authenticated/_products/components/product-option-group-badge.tsx

@@ -0,0 +1,19 @@
+import { Badge } from '@/vdb/components/ui/badge.js';
+import { Link } from '@tanstack/react-router';
+import { Edit2 } from 'lucide-react';
+
+interface ProductOptionGroupBadgeProps {
+    id: string;
+    name: string;
+}
+
+export function ProductOptionGroupBadge({ id, name }: ProductOptionGroupBadgeProps) {
+    return (
+        <Badge variant="secondary" className="text-xs">
+            <span>{name}</span>
+            <Link to={`option-groups/${id}`} className="ml-1.5 inline-flex">
+                <Edit2 className="h-3 w-3" />
+            </Link>
+        </Badge>
+    );
+}

+ 111 - 0
packages/dashboard/src/app/routes/_authenticated/_products/components/product-options-table.tsx

@@ -0,0 +1,111 @@
+import { DetailPageButton } from '@/vdb/components/shared/detail-page-button.js';
+import { PaginatedListDataTable } from '@/vdb/components/shared/paginated-list-data-table.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { addCustomFields } from '@/vdb/framework/document-introspection/add-custom-fields.js';
+import { graphql } from '@/vdb/graphql/graphql.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { Link } from '@tanstack/react-router';
+import { ColumnFiltersState, SortingState } from '@tanstack/react-table';
+import { PlusIcon } from 'lucide-react';
+import { useRef, useState } from 'react';
+import { deleteProductOptionDocument } from '../product-option-groups.graphql.js';
+
+export const productOptionListDocument = graphql(`
+    query ProductOptionList($options: ProductOptionListOptions, $groupId: ID) {
+        productOptions(options: $options, groupId: $groupId) {
+            items {
+                id
+                createdAt
+                updatedAt
+                name
+                code
+                customFields
+            }
+            totalItems
+        }
+    }
+`);
+
+export interface ProductOptionsTableProps {
+    productOptionGroupId: string;
+    registerRefresher?: (refresher: () => void) => void;
+}
+
+export function ProductOptionsTable({
+    productOptionGroupId,
+    registerRefresher,
+}: Readonly<ProductOptionsTableProps>) {
+    const [sorting, setSorting] = useState<SortingState>([]);
+    const [page, setPage] = useState(1);
+    const [pageSize, setPageSize] = useState(10);
+    const [filters, setFilters] = useState<ColumnFiltersState>([]);
+    const refreshRef = useRef<() => void>(() => {});
+
+    return (
+        <>
+            <PaginatedListDataTable
+                listQuery={addCustomFields(productOptionListDocument)}
+                deleteMutation={deleteProductOptionDocument}
+                page={page}
+                itemsPerPage={pageSize}
+                sorting={sorting}
+                columnFilters={filters}
+                onPageChange={(_, page, perPage) => {
+                    setPage(page);
+                    setPageSize(perPage);
+                }}
+                onSortChange={(_, sorting) => {
+                    setSorting(sorting);
+                }}
+                onFilterChange={(_, filters) => {
+                    setFilters(filters);
+                }}
+                registerRefresher={refresher => {
+                    refreshRef.current = refresher;
+                    registerRefresher?.(refresher);
+                }}
+                transformVariables={variables => {
+                    const filter = variables.options?.filter ?? {};
+                    return {
+                        options: {
+                            filter: {
+                                ...filter,
+                                groupId: { eq: productOptionGroupId },
+                            },
+                            sort: variables.options?.sort,
+                            take: pageSize,
+                            skip: (page - 1) * pageSize,
+                        },
+                    };
+                }}
+                onSearchTermChange={searchTerm => {
+                    return {
+                        name: {
+                            contains: searchTerm,
+                        },
+                    };
+                }}
+                customizeColumns={{
+                    name: {
+                        header: 'Name',
+                        cell: ({ row }) => (
+                            <DetailPageButton
+                                id={row.original.id}
+                                label={row.original.name}
+                                href={`options/${row.original.id}`}
+                            />
+                        ),
+                    },
+                }}
+            />
+            <div className="mt-4">
+                <Button asChild variant="outline">
+                    <Link to="./options/new">
+                        <PlusIcon />
+                        <Trans>Add product option</Trans>
+                    </Link>
+                </Button>
+            </div>
+        </>
+    );
+}

+ 103 - 0
packages/dashboard/src/app/routes/_authenticated/_products/product-option-groups.graphql.ts

@@ -0,0 +1,103 @@
+import { graphql } from '@/vdb/graphql/graphql.js';
+
+export const productOptionGroupDetailDocument = graphql(`
+    query ProductOptionGroupDetail($id: ID!) {
+        productOptionGroup(id: $id) {
+            id
+            createdAt
+            updatedAt
+            name
+            code
+            languageCode
+            translations {
+                id
+                languageCode
+                name
+            }
+            customFields
+        }
+    }
+`);
+
+export const productIdNameDocument = graphql(`
+    query ProductIdName($id: ID!) {
+        product(id: $id) {
+            id
+            name
+        }
+    }
+`);
+
+export const productOptionGroupIdNameDocument = graphql(`
+    query ProductOptionGroupIdName($id: ID!) {
+        productOptionGroup(id: $id) {
+            id
+            name
+        }
+    }
+`);
+
+export const createProductOptionGroupDocument = graphql(`
+    mutation CreateProductOptionGroup($input: CreateProductOptionGroupInput!) {
+        createProductOptionGroup(input: $input) {
+            id
+        }
+    }
+`);
+
+export const updateProductOptionGroupDocument = graphql(`
+    mutation UpdateProductOptionGroup($input: UpdateProductOptionGroupInput!) {
+        updateProductOptionGroup(input: $input) {
+            id
+        }
+    }
+`);
+
+export const productOptionDetailDocument = graphql(`
+    query ProductOptionDetail($id: ID!) {
+        productOption(id: $id) {
+            id
+            createdAt
+            updatedAt
+            name
+            code
+            languageCode
+            translations {
+                id
+                languageCode
+                name
+            }
+            group {
+                id
+                name
+                code
+            }
+            customFields
+        }
+    }
+`);
+
+export const createProductOptionDocument = graphql(`
+    mutation CreateProductOption($input: CreateProductOptionInput!) {
+        createProductOption(input: $input) {
+            id
+        }
+    }
+`);
+
+export const updateProductOptionDocument = graphql(`
+    mutation UpdateProductOption($input: UpdateProductOptionInput!) {
+        updateProductOption(input: $input) {
+            id
+        }
+    }
+`);
+
+export const deleteProductOptionDocument = graphql(`
+    mutation DeleteProductOption($id: ID!) {
+        deleteProductOption(id: $id) {
+            result
+            message
+        }
+    }
+`);

+ 5 - 1
packages/dashboard/src/app/routes/_authenticated/_products/products.graphql.ts

@@ -44,7 +44,11 @@ export const productDetailFragment = graphql(
                 slug
                 description
             }
-
+            optionGroups {
+                id
+                code
+                name
+            }
             facetValues {
                 id
                 name

+ 13 - 7
packages/dashboard/src/app/routes/_authenticated/_products/products_.$id.tsx

@@ -7,7 +7,7 @@ import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js'
 import { PermissionGuard } from '@/vdb/components/shared/permission-guard.js';
 import { TranslatableFormFieldWrapper } from '@/vdb/components/shared/translatable-form-field.js';
 import { Button } from '@/vdb/components/ui/button.js';
-import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from '@/vdb/components/ui/form.js';
+import { FormControl, FormDescription, FormItem, FormMessage } from '@/vdb/components/ui/form.js';
 import { Input } from '@/vdb/components/ui/input.js';
 import { Switch } from '@/vdb/components/ui/switch.js';
 import { NEW_ENTITY_PATH } from '@/vdb/constants.js';
@@ -29,6 +29,7 @@ import { PlusIcon } from 'lucide-react';
 import { useRef } from 'react';
 import { toast } from 'sonner';
 import { CreateProductVariantsDialog } from './components/create-product-variants-dialog.js';
+import { ProductOptionGroupBadge } from './components/product-option-group-badge.js';
 import { ProductVariantsTable } from './components/product-variants-table.js';
 import { createProductDocument, productDetailDocument, updateProductDocument } from './products.graphql.js';
 
@@ -186,21 +187,26 @@ function ProductDetailPage() {
                         />
                     </PageBlock>
                 )}
-                <PageBlock column="side" blockId="facet-values">
+                {entity?.optionGroups.length ? (
+                    <PageBlock column="side" blockId="option-groups" title={<Trans>Product Options</Trans>}>
+                        <div className="flex flex-wrap gap-1.5">
+                            {entity.optionGroups.map(g => (
+                                <ProductOptionGroupBadge key={g.id} id={g.id} name={g.name} />
+                            ))}
+                        </div>
+                    </PageBlock>
+                ) : null}
+                <PageBlock column="side" blockId="facet-values" title={<Trans>Facet Values</Trans>}>
                     <FormFieldWrapper
                         control={form.control}
                         name="facetValueIds"
-                        label={<Trans>Facet values</Trans>}
                         render={({ field }) => (
                             <AssignedFacetValues facetValues={entity?.facetValues ?? []} {...field} />
                         )}
                     />
                 </PageBlock>
-                <PageBlock column="side" blockId="assets">
+                <PageBlock column="side" blockId="assets" title={<Trans>Assets</Trans>}>
                     <FormItem>
-                        <FormLabel>
-                            <Trans>Assets</Trans>
-                        </FormLabel>
                         <FormControl>
                             <EntityAssets
                                 assets={entity?.assets}

+ 181 - 0
packages/dashboard/src/app/routes/_authenticated/_products/products_.$productId.option-groups.$id.tsx

@@ -0,0 +1,181 @@
+import { SlugInput } from '@/vdb/components/data-input/index.js';
+import { ErrorPage } from '@/vdb/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
+import { PermissionGuard } from '@/vdb/components/shared/permission-guard.js';
+import { TranslatableFormFieldWrapper } from '@/vdb/components/shared/translatable-form-field.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { Input } from '@/vdb/components/ui/input.js';
+import { NEW_ENTITY_PATH } from '@/vdb/constants.js';
+import { extendDetailFormQuery } from '@/vdb/framework/document-extension/extend-detail-form-query.js';
+import { addCustomFields } from '@/vdb/framework/document-introspection/add-custom-fields.js';
+import {
+    CustomFieldsPageBlock,
+    DetailFormGrid,
+    Page,
+    PageActionBar,
+    PageActionBarRight,
+    PageBlock,
+    PageLayout,
+    PageTitle,
+} from '@/vdb/framework/layout-engine/page-layout.js';
+import { getDetailQueryOptions, useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
+import { api } from '@/vdb/graphql/api.js';
+import { Trans, useLingui } from '@/vdb/lib/trans.js';
+import { createFileRoute, ParsedLocation, useNavigate } from '@tanstack/react-router';
+import { toast } from 'sonner';
+import { ProductOptionsTable } from './components/product-options-table.js';
+import {
+    createProductOptionGroupDocument,
+    productIdNameDocument,
+    productOptionGroupDetailDocument,
+    updateProductOptionGroupDocument,
+} from './product-option-groups.graphql.js';
+
+const pageId = 'product-option-group-detail';
+
+export const Route = createFileRoute('/_authenticated/_products/products_/$productId/option-groups/$id')({
+    component: ProductOptionGroupDetailPage,
+    loader: async ({ context, params }: { context: any; params: any; location: ParsedLocation }) => {
+        if (!params.id) {
+            throw new Error('ID param is required');
+        }
+
+        const { extendedQuery: extendedQueryDocument } = extendDetailFormQuery(
+            addCustomFields(productOptionGroupDetailDocument),
+            pageId,
+        );
+        const result = await context.queryClient.ensureQueryData(
+            getDetailQueryOptions(extendedQueryDocument, { id: params.id }),
+        );
+        const productResult = await context.queryClient.fetchQuery({
+            queryKey: [pageId, 'productIdName', params.productId],
+            queryFn: () => api.query(productIdNameDocument, { id: params.productId }),
+        });
+        const entityName = 'ProductOptionGroup';
+
+        if (!result.productOptionGroup) {
+            throw new Error(`${entityName} with the ID ${params.id} was not found`);
+        }
+        return {
+            breadcrumb: [
+                { path: '/products', label: <Trans>Products</Trans> },
+                { path: `/products/${productResult.product.id}`, label: productResult.product.name },
+                { path: `/products/${productResult.product.id}`, label: <Trans>Option Groups</Trans> },
+                result.productOptionGroup?.name,
+            ],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+function ProductOptionGroupDetailPage() {
+    const params = Route.useParams();
+    const navigate = useNavigate();
+    const creatingNewEntity = params.id === NEW_ENTITY_PATH;
+    const { i18n } = useLingui();
+
+    const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
+        pageId,
+        queryDocument: productOptionGroupDetailDocument,
+        createDocument: createProductOptionGroupDocument,
+        updateDocument: updateProductOptionGroupDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                code: entity.code,
+                translations: entity.translations.map(translation => ({
+                    id: translation.id,
+                    languageCode: translation.languageCode,
+                    name: translation.name,
+                    customFields: (translation as any).customFields,
+                })),
+                options: [],
+                customFields: entity.customFields,
+            };
+        },
+        transformCreateInput: values => {
+            return {
+                ...values,
+                options: [],
+            };
+        },
+        params: { id: params.id },
+        onSuccess: async data => {
+            toast(
+                i18n.t(
+                    creatingNewEntity
+                        ? 'Successfully created product option group'
+                        : 'Successfully updated product option group',
+                ),
+            );
+            resetForm();
+            if (creatingNewEntity) {
+                await navigate({ to: `../$id`, params: { id: data.id } });
+            }
+        },
+        onError: err => {
+            toast(
+                i18n.t(
+                    creatingNewEntity
+                        ? 'Failed to create product option group'
+                        : 'Failed to update product option group',
+                ),
+                {
+                    description: err instanceof Error ? err.message : 'Unknown error',
+                },
+            );
+        },
+    });
+
+    return (
+        <Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
+            <PageTitle>
+                {creatingNewEntity ? <Trans>New product option group</Trans> : (entity?.name ?? '')}
+            </PageTitle>
+            <PageActionBar>
+                <PageActionBarRight>
+                    <PermissionGuard requires={['UpdateProduct', 'UpdateCatalog']}>
+                        <Button
+                            type="submit"
+                            disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
+                        >
+                            {creatingNewEntity ? <Trans>Create</Trans> : <Trans>Update</Trans>}
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
+            </PageActionBar>
+            <PageLayout>
+                <PageBlock column="main" blockId="main-form">
+                    <DetailFormGrid>
+                        <TranslatableFormFieldWrapper
+                            control={form.control}
+                            name="name"
+                            label={<Trans>Name</Trans>}
+                            render={({ field }) => <Input {...field} />}
+                        />
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="code"
+                            label={<Trans>Code</Trans>}
+                            render={({ field }) => (
+                                <SlugInput
+                                    fieldName="code"
+                                    watchFieldName="name"
+                                    entityName="ProductOptionGroup"
+                                    entityId={entity?.id}
+                                    {...field}
+                                />
+                            )}
+                        />
+                    </DetailFormGrid>
+                </PageBlock>
+                <CustomFieldsPageBlock column="main" entityType="ProductOptionGroup" control={form.control} />
+                {entity && (
+                    <PageBlock column="main" blockId="product-options" title={<Trans>Product Options</Trans>}>
+                        <ProductOptionsTable productOptionGroupId={entity?.id} />
+                    </PageBlock>
+                )}
+            </PageLayout>
+        </Page>
+    );
+}

+ 208 - 0
packages/dashboard/src/app/routes/_authenticated/_products/products_.$productId.option-groups.$productOptionGroupId.options_.$id.tsx

@@ -0,0 +1,208 @@
+import { SlugInput } from '@/vdb/components/data-input/index.js';
+import { ErrorPage } from '@/vdb/components/shared/error-page.js';
+import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
+import { PermissionGuard } from '@/vdb/components/shared/permission-guard.js';
+import { TranslatableFormFieldWrapper } from '@/vdb/components/shared/translatable-form-field.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { Input } from '@/vdb/components/ui/input.js';
+import { NEW_ENTITY_PATH } from '@/vdb/constants.js';
+import { extendDetailFormQuery } from '@/vdb/framework/document-extension/extend-detail-form-query.js';
+import { addCustomFields } from '@/vdb/framework/document-introspection/add-custom-fields.js';
+import {
+    CustomFieldsPageBlock,
+    DetailFormGrid,
+    Page,
+    PageActionBar,
+    PageActionBarRight,
+    PageBlock,
+    PageLayout,
+    PageTitle,
+} from '@/vdb/framework/layout-engine/page-layout.js';
+import { getDetailQueryOptions, useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
+import { api } from '@/vdb/graphql/api.js';
+import { Trans, useLingui } from '@/vdb/lib/trans.js';
+import { createFileRoute, ParsedLocation, useNavigate } from '@tanstack/react-router';
+import { toast } from 'sonner';
+import {
+    createProductOptionDocument,
+    productIdNameDocument,
+    productOptionDetailDocument,
+    productOptionGroupIdNameDocument,
+    updateProductOptionDocument,
+} from './product-option-groups.graphql.js';
+
+const pageId = 'product-option-detail';
+
+export const Route = createFileRoute(
+    '/_authenticated/_products/products_/$productId/option-groups/$productOptionGroupId/options_/$id',
+)({
+    component: ProductOptionDetailPage,
+    loader: async ({ context, params }: { context: any; params: any; location: ParsedLocation }) => {
+        if (!params.id) {
+            throw new Error('ID param is required');
+        }
+
+        const isNew = params.id === NEW_ENTITY_PATH;
+        let optionGroupName: string | undefined;
+        let optionGroupId = params.productOptionGroupId;
+        const { extendedQuery: extendedQueryDocument } = extendDetailFormQuery(
+            addCustomFields(productOptionDetailDocument),
+            pageId,
+        );
+
+        const result = isNew
+            ? null
+            : await context.queryClient.ensureQueryData(
+                  getDetailQueryOptions(extendedQueryDocument, { id: params.id }),
+              );
+        const productResult = await context.queryClient.fetchQuery({
+            queryKey: [pageId, 'productIdName', params.productId],
+            queryFn: () => api.query(productIdNameDocument, { id: params.productId }),
+        });
+        const entityName = 'ProductOption';
+
+        if (!result?.productOption && !isNew) {
+            throw new Error(`${entityName} with the ID ${params.id} was not found`);
+        }
+        if (isNew) {
+            const optionGroupResult = await context.queryClient.fetchQuery({
+                queryKey: [pageId, 'optionGroupIdName', optionGroupId],
+                queryFn: () => api.query(productOptionGroupIdNameDocument, { id: optionGroupId }),
+            });
+            optionGroupName = optionGroupResult.productOptionGroup?.name;
+        } else {
+            optionGroupName = result.productOption.group.name;
+        }
+        const productId = params.productId;
+        return {
+            breadcrumb: [
+                { path: '/products', label: <Trans>Products</Trans> },
+                { path: `/products/${productId}`, label: productResult.product.name },
+                { path: `/products/${productId}`, label: <Trans>Option Groups</Trans> },
+                {
+                    path: `/products/${productId}/option-groups/${optionGroupId}`,
+                    label: optionGroupName,
+                },
+                isNew ? <Trans>New option</Trans> : result.productOption?.name,
+            ],
+        };
+    },
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+function ProductOptionDetailPage() {
+    const params = Route.useParams();
+    const navigate = useNavigate();
+    const creatingNewEntity = params.id === NEW_ENTITY_PATH;
+    const { i18n } = useLingui();
+
+    const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
+        pageId,
+        queryDocument: productOptionDetailDocument,
+        createDocument: createProductOptionDocument,
+        updateDocument: updateProductOptionDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                code: entity.code,
+                name: entity.name,
+                translations: entity.translations.map(translation => ({
+                    id: translation.id,
+                    languageCode: translation.languageCode,
+                    name: translation.name,
+                    customFields: (translation as any).customFields,
+                })),
+                customFields: entity.customFields as any,
+            };
+        },
+        transformCreateInput: (value): any => {
+            return {
+                ...value,
+                productOptionGroupId: params.productOptionGroupId,
+            };
+        },
+        params: { id: params.id },
+        onSuccess: async data => {
+            toast(
+                i18n.t(
+                    creatingNewEntity
+                        ? 'Successfully created product option'
+                        : 'Successfully updated product option',
+                ),
+            );
+            resetForm();
+            const created = Array.isArray(data) ? data[0] : data;
+            if (creatingNewEntity && created) {
+                await navigate({ to: `../$id`, params: { id: (created as any).id } });
+            }
+        },
+        onError: err => {
+            toast(
+                i18n.t(
+                    creatingNewEntity ? 'Failed to create product option' : 'Failed to update product option',
+                ),
+                {
+                    description: err instanceof Error ? err.message : 'Unknown error',
+                },
+            );
+        },
+    });
+
+    return (
+        <Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
+            <PageTitle>
+                {creatingNewEntity ? <Trans>New product option</Trans> : ((entity as any)?.name ?? '')}
+            </PageTitle>
+            <PageActionBar>
+                <PageActionBarRight>
+                    <PermissionGuard requires={['UpdateProduct', 'UpdateCatalog']}>
+                        <Button
+                            type="submit"
+                            disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
+                        >
+                            {creatingNewEntity ? <Trans>Create</Trans> : <Trans>Update</Trans>}
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
+            </PageActionBar>
+            <PageLayout>
+                <PageBlock column="side" blockId="option-group-info">
+                    {entity?.group && (
+                        <div className="space-y-2">
+                            <div className="text-sm font-medium">
+                                <Trans>Product Option Group</Trans>
+                            </div>
+                            <div className="text-sm text-muted-foreground">{entity?.group.name}</div>
+                            <div className="text-xs text-muted-foreground">{entity?.group.code}</div>
+                        </div>
+                    )}
+                </PageBlock>
+                <PageBlock column="main" blockId="main-form">
+                    <DetailFormGrid>
+                        <TranslatableFormFieldWrapper
+                            control={form.control}
+                            name="name"
+                            label={<Trans>Name</Trans>}
+                            render={({ field }) => <Input {...field} />}
+                        />
+                        <FormFieldWrapper
+                            control={form.control}
+                            name="code"
+                            label={<Trans>Code</Trans>}
+                            render={({ field }) => (
+                                <SlugInput
+                                    fieldName="code"
+                                    watchFieldName="name"
+                                    entityName="ProductOption"
+                                    entityId={entity?.id}
+                                    {...field}
+                                />
+                            )}
+                        />
+                    </DetailFormGrid>
+                </PageBlock>
+                <CustomFieldsPageBlock column="main" entityType="ProductOption" control={form.control} />
+            </PageLayout>
+        </Page>
+    );
+}

ファイルの差分が大きいため隠しています
+ 5 - 1
packages/dashboard/src/lib/graphql/graphql-env.d.ts


+ 3 - 0
packages/dashboard/vite/utils/compiler.ts

@@ -47,6 +47,9 @@ export async function compile(options: CompilerOptions): Promise<CompileResult>
     const transformTsConfigPathMappings =
         pathAdapter?.transformTsConfigPathMappings ?? defaultPathAdapter.transformTsConfigPathMappings;
 
+    // 0. Clear the outputPath
+    fs.removeSync(outputPath);
+
     // 1. Compile TypeScript files
     const compileStart = Date.now();
     await compileTypeScript({

+ 2 - 2
packages/dashboard/vite/vite-plugin-vendure-dashboard.ts

@@ -1,5 +1,5 @@
 import tailwindcss from '@tailwindcss/vite';
-import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
+import { tanstackRouter } from '@tanstack/router-plugin/vite';
 import react from '@vitejs/plugin-react';
 import path from 'path';
 import { PluginOption } from 'vite';
@@ -109,7 +109,7 @@ export function vendureDashboardPlugin(options: VitePluginVendureDashboardOption
         ...(options.disableTansStackRouterPlugin
             ? []
             : [
-                  TanStackRouterVite({
+                  tanstackRouter({
                       autoCodeSplitting: true,
                       routeFileIgnorePattern: '.graphql.ts|components|hooks|utils',
                       routesDirectory: path.join(packageRoot, 'src/app/routes'),

ファイルの差分が大きいため隠しています
+ 5 - 1
packages/dev-server/graphql/graphql-env.d.ts


+ 50 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -4523,6 +4523,18 @@ export type ProductOption = Node & {
     updatedAt: Scalars['DateTime']['output'];
 };
 
+export type ProductOptionFilterParameter = {
+    _and?: InputMaybe<Array<ProductOptionFilterParameter>>;
+    _or?: InputMaybe<Array<ProductOptionFilterParameter>>;
+    code?: InputMaybe<StringOperators>;
+    createdAt?: InputMaybe<DateOperators>;
+    groupId?: InputMaybe<IdOperators>;
+    id?: InputMaybe<IdOperators>;
+    languageCode?: InputMaybe<StringOperators>;
+    name?: InputMaybe<StringOperators>;
+    updatedAt?: InputMaybe<DateOperators>;
+};
+
 export type ProductOptionGroup = Node & {
     code: Scalars['String']['output'];
     createdAt: Scalars['DateTime']['output'];
@@ -4557,6 +4569,33 @@ export type ProductOptionInUseError = ErrorResult & {
     productVariantCount: Scalars['Int']['output'];
 };
 
+export type ProductOptionList = PaginatedList & {
+    items: Array<ProductOption>;
+    totalItems: Scalars['Int']['output'];
+};
+
+export type ProductOptionListOptions = {
+    /** Allows the results to be filtered */
+    filter?: InputMaybe<ProductOptionFilterParameter>;
+    /** Specifies whether multiple top-level "filter" fields should be combined with a logical AND or OR operation. Defaults to AND. */
+    filterOperator?: InputMaybe<LogicalOperator>;
+    /** Skips the first n results, for use in pagination */
+    skip?: InputMaybe<Scalars['Int']['input']>;
+    /** Specifies which properties to sort the results by */
+    sort?: InputMaybe<ProductOptionSortParameter>;
+    /** Takes n results, for use in pagination */
+    take?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type ProductOptionSortParameter = {
+    code?: InputMaybe<SortOrder>;
+    createdAt?: InputMaybe<SortOrder>;
+    groupId?: InputMaybe<SortOrder>;
+    id?: InputMaybe<SortOrder>;
+    name?: InputMaybe<SortOrder>;
+    updatedAt?: InputMaybe<SortOrder>;
+};
+
 export type ProductOptionTranslation = {
     createdAt: Scalars['DateTime']['output'];
     id: Scalars['ID']['output'];
@@ -4923,8 +4962,10 @@ export type Query = {
     previewCollectionVariants: ProductVariantList;
     /** Get a Product either by id or slug. If neither id nor slug is specified, an error will result. */
     product?: Maybe<Product>;
+    productOption?: Maybe<ProductOption>;
     productOptionGroup?: Maybe<ProductOptionGroup>;
     productOptionGroups: Array<ProductOptionGroup>;
+    productOptions: ProductOptionList;
     /** Get a ProductVariant by id */
     productVariant?: Maybe<ProductVariant>;
     /** List ProductVariants either all or for the specific product. */
@@ -5094,6 +5135,10 @@ export type QueryProductArgs = {
     slug?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type QueryProductOptionArgs = {
+    id: Scalars['ID']['input'];
+};
+
 export type QueryProductOptionGroupArgs = {
     id: Scalars['ID']['input'];
 };
@@ -5102,6 +5147,11 @@ export type QueryProductOptionGroupsArgs = {
     filterTerm?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type QueryProductOptionsArgs = {
+    groupId?: InputMaybe<Scalars['ID']['input']>;
+    options?: InputMaybe<ProductOptionListOptions>;
+};
+
 export type QueryProductVariantArgs = {
     id: Scalars['ID']['input'];
 };

+ 50 - 0
packages/payments-plugin/e2e/graphql/generated-admin-types.ts

@@ -4605,6 +4605,18 @@ export type ProductOption = Node & {
     updatedAt: Scalars['DateTime']['output'];
 };
 
+export type ProductOptionFilterParameter = {
+    _and?: InputMaybe<Array<ProductOptionFilterParameter>>;
+    _or?: InputMaybe<Array<ProductOptionFilterParameter>>;
+    code?: InputMaybe<StringOperators>;
+    createdAt?: InputMaybe<DateOperators>;
+    groupId?: InputMaybe<IdOperators>;
+    id?: InputMaybe<IdOperators>;
+    languageCode?: InputMaybe<StringOperators>;
+    name?: InputMaybe<StringOperators>;
+    updatedAt?: InputMaybe<DateOperators>;
+};
+
 export type ProductOptionGroup = Node & {
     code: Scalars['String']['output'];
     createdAt: Scalars['DateTime']['output'];
@@ -4639,6 +4651,33 @@ export type ProductOptionInUseError = ErrorResult & {
     productVariantCount: Scalars['Int']['output'];
 };
 
+export type ProductOptionList = PaginatedList & {
+    items: Array<ProductOption>;
+    totalItems: Scalars['Int']['output'];
+};
+
+export type ProductOptionListOptions = {
+    /** Allows the results to be filtered */
+    filter?: InputMaybe<ProductOptionFilterParameter>;
+    /** Specifies whether multiple top-level "filter" fields should be combined with a logical AND or OR operation. Defaults to AND. */
+    filterOperator?: InputMaybe<LogicalOperator>;
+    /** Skips the first n results, for use in pagination */
+    skip?: InputMaybe<Scalars['Int']['input']>;
+    /** Specifies which properties to sort the results by */
+    sort?: InputMaybe<ProductOptionSortParameter>;
+    /** Takes n results, for use in pagination */
+    take?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type ProductOptionSortParameter = {
+    code?: InputMaybe<SortOrder>;
+    createdAt?: InputMaybe<SortOrder>;
+    groupId?: InputMaybe<SortOrder>;
+    id?: InputMaybe<SortOrder>;
+    name?: InputMaybe<SortOrder>;
+    updatedAt?: InputMaybe<SortOrder>;
+};
+
 export type ProductOptionTranslation = {
     createdAt: Scalars['DateTime']['output'];
     id: Scalars['ID']['output'];
@@ -5006,8 +5045,10 @@ export type Query = {
     previewCollectionVariants: ProductVariantList;
     /** Get a Product either by id or slug. If neither id nor slug is specified, an error will result. */
     product?: Maybe<Product>;
+    productOption?: Maybe<ProductOption>;
     productOptionGroup?: Maybe<ProductOptionGroup>;
     productOptionGroups: Array<ProductOptionGroup>;
+    productOptions: ProductOptionList;
     /** Get a ProductVariant by id */
     productVariant?: Maybe<ProductVariant>;
     /** List ProductVariants either all or for the specific product. */
@@ -5181,6 +5222,10 @@ export type QueryProductArgs = {
     slug?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type QueryProductOptionArgs = {
+    id: Scalars['ID']['input'];
+};
+
 export type QueryProductOptionGroupArgs = {
     id: Scalars['ID']['input'];
 };
@@ -5189,6 +5234,11 @@ export type QueryProductOptionGroupsArgs = {
     filterTerm?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type QueryProductOptionsArgs = {
+    groupId?: InputMaybe<Scalars['ID']['input']>;
+    options?: InputMaybe<ProductOptionListOptions>;
+};
+
 export type QueryProductVariantArgs = {
     id: Scalars['ID']['input'];
 };

ファイルの差分が大きいため隠しています
+ 0 - 0
schema-admin.json


この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません