Skip to content

Conversation

@asonnleitner
Copy link
Contributor

@asonnleitner asonnleitner commented Jul 30, 2025

Description

This PR adds channel awareness to ProductOption and ProductOptionGroup entities, solving a critical data duplication issue where 88.7% of product options and 99.9% of option groups are duplicates across channels (see #3318).

Motivation

In multi-channel Vendure installations, product options and option groups are currently global, leading to:

  • Massive data duplication when channels need their own options
  • Inability to have channel-specific product configurations
  • Poor multi-tenant isolation

Changes

🏗️ Core Implementation

  • ProductOption and ProductOptionGroup now implement ChannelAware interface
  • Added ManyToMany relations with Channel entity
  • Updated services to respect channel boundaries
  • Added migration to assign existing data to all channels (preserving current behavior)

📊 GraphQL API

Added new mutations:

assignProductOptionGroupsToChannel(input: AssignProductOptionGroupsToChannelInput!): [ProductOptionGroup!]!
removeProductOptionGroupsFromChannel(input: RemoveProductOptionGroupsFromChannelInput!): [RemoveProductOptionGroupFromChannelResult!]!

🔒 Channel Isolation

  • Option groups only visible in assigned channels
  • Cannot create options in groups from other channels
  • Proper error handling for cross-channel operations
  • Soft-deleted options properly excluded during channel assignment

✅ Testing

  • Comprehensive e2e test coverage
  • Tests for channel assignment/removal
  • Validation of channel isolation
  • Error handling verification

Future Work

  • Include migrations to automatically assigns all existing option groups and options to all current channels, ensuring backward compatibility.
  • Make productOptionGroups query return PaginatedList instead of array
  • Add dedicated Product Option Groups management page in Admin UI

References

Screenshots

N/A - Backend only changes

Checklist

  • Code follows Vendure patterns (similar to Facet/FacetValue implementation)
  • Tests pass
  • GraphQL schema updated

Summary by CodeRabbit

  • New Features

    • Added channel-level management for product option groups with assign/remove capabilities
    • Introduced bulk operations for creating, updating, and deleting product options
    • Added comprehensive admin dashboard interface for product option group management
  • Improvements

    • Enhanced product option listing with pagination, filtering, and sorting
    • Improved error messaging for product option group conflicts and usage scenarios
  • Internationalization

    • Expanded localization support across multiple languages for new product option management features

@vercel
Copy link

vercel bot commented Jul 30, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Updated (UTC)
docs ✅ Ready (Inspect) Visit Preview Aug 5, 2025 11:54am

@github-actions
Copy link
Contributor

github-actions bot commented Jul 30, 2025

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jul 30, 2025

Walkthrough

This PR implements channel-aware product options and option groups by adding many-to-many Channel relationships to both entities, extending the GraphQL schema with channel assignment/removal mutations and pagination support, updating services and resolvers with batch operations and channel-scoped filtering, adding comprehensive admin UI components and routes, and expanding internationalization across multiple languages.

Changes

Cohort / File(s) Summary
GraphQL Schema & API
packages/core/src/api/schema/admin-api/product-option-group.api.graphql
Updated productOptionGroups query from filterTerm-based to pagination-aware with ProductOptionGroupListOptions; added productOptions query; added mutations for batch creation/deletion/updates of options; added assignProductOptionGroupsToChannel and removeProductOptionGroupsFromChannel mutations; introduced ProductOptionGroupInUseError type and RemoveProductOptionGroupFromChannelResult union.
Entity Definitions
packages/core/src/entity/product-option-group/product-option-group.entity.ts, packages/core/src/entity/product-option/product-option.entity.ts, packages/core/src/entity/channel/channel.entity.ts, packages/core/src/entity/product/product.entity.ts
Made ProductOptionGroup and ProductOption implement ChannelAware interface; added many-to-many channels relationships; changed ProductOptionGroup.product from one-to-many to many-to-many products with join table; added productOptionGroups and productOptions relationships to Channel.
Resolver Layer
packages/core/src/api/resolvers/admin/product-option.resolver.ts
Updated productOptionGroups to return paginated list with channel-aware filtering; added productOptions query; introduced batch mutations createProductOptions, updateProductOptions, deleteProductOptions, deleteProductOptionGroups, deleteProductOptionGroup; added assignProductOptionGroupsToChannel and removeProductOptionGroupsFromChannel mutations.
Service Layer
packages/core/src/service/services/product-option-group.service.ts, packages/core/src/service/services/product-option.service.ts, packages/core/src/service/services/product.service.ts
Updated ProductOptionGroupService.findAll/findOne to use ListQueryBuilder with channel context and default relations; added assignProductOptionGroupsToChannel, removeProductOptionGroupsFromChannel, delete methods; implemented lifecycle cleanup for orphaned option groups when variants deleted; updated ProductOptionService with pagination and channel inheritance for newly created options.
Error Handling
packages/core/src/common/error/generated-graphql-admin-errors.ts
Added ProductOptionGroupInUseError class with fields optionGroupCode, productCount, variantCount; added resolver for RemoveProductOptionGroupFromChannelResult union type.
GraphQL Type Generation
packages/admin-ui/src/lib/core/src/common/generated-types.ts, packages/common/src/generated-types.ts, packages/core/e2e/graphql/generated-e2e-admin-types.ts, packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts, packages/common/src/generated-shop-types.ts
Added input types AssignProductOptionGroupsToChannelInput, RemoveProductOptionGroupsFromChannelInput; added list types ProductOptionGroupList, ProductOptionList with corresponding options/sort parameters; extended ProductOptionGroup with optionList field; added deprecation fields (deprecated, deprecationReason) to all CustomField configuration types.
Admin UI Components
packages/dashboard/src/app/routes/_authenticated/_product-option-groups/components/*
Added AddProductOptionDialog for creating options; EditProductOption for editing; ProductOptionGroupBulkActions with delete/assign/remove/duplicate actions; ProductOptionsSheet and ProductOptionsTable for viewing/managing options; ProductOptionActions for per-row operations; GroupSearch, OptionSearch, OptionGroupSearchInput for selection/creation flows.
Admin UI Routes
packages/dashboard/src/app/routes/_authenticated/_product-option-groups/product-option-groups.tsx, packages/dashboard/src/app/routes/_authenticated/_product-option-groups/product-option-groups_.$id.tsx
Added new route file for product option groups list page with bulk actions, search, and columns for name/options; added detail page route with form for creating/updating groups, custom fields, and nested product options management.
Product Creation UI
packages/dashboard/src/app/routes/_authenticated/_products/components/create-product-variants*.tsx, packages/dashboard/src/app/routes/_authenticated/_products/components/group-search.tsx, packages/dashboard/src/app/routes/_authenticated/_products/components/option-*.tsx, packages/dashboard/src/app/routes/_authenticated/_products/components/duplicate-bulk-action.tsx
Refactored variant creation dialog to support option group/option creation; added GroupSearch component for selecting/creating groups; added OptionSearch and OptionGroupSearchInput components for building variant options; extended bulk action to support ProductOptionGroup duplication.
GraphQL Documents
packages/core/e2e/src/data/definitions/product-definitions.ts, packages/dashboard/src/app/routes/_authenticated/_product-option-groups/product-option-groups.graphql.ts, packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts
Updated GET_PRODUCT_OPTION_GROUPS query to use ProductOptionGroupListOptions with pagination; added new fragments for ProductOption and ProductOptionGroup with options; added GraphQL mutations for group/option management; updated ProductDataService.getProductOptionGroups method signature.
Internationalization
packages/core/src/i18n/messages/{de,en,es,fr,pt_BR,pt_PT,ru,uk}.json, packages/admin-ui/src/lib/static/i18n-messages/en.json
Expanded error, errorResult, and message sections with new keys for product option operations (e.g., product-option-group-ids-cannot-be-empty, PRODUCT_OPTION_GROUP_IN_USE_ERROR, product-option-group-force-deleted), channel transitions, and zone management; added translation key "option-groups" to breadcrumb/catalog/nav sections.
E2E Tests
packages/core/e2e/product-option.e2e-spec.ts, packages/core/e2e/product.e2e-spec.ts
Added comprehensive channel-aware test coverage for option group assignment/removal; tests for removing groups in use with error information; tests for product sharing across channels with option groups; updated product test to allow sharing option groups across products.
Configuration & Utilities
packages/core/src/config/entity/entity-duplicators/product-duplicator.ts, packages/core/src/data-import/providers/importer/fast-importer.service.ts, packages/core/src/api/config/graphql-custom-fields.ts, packages/admin-ui/src/lib/catalog/src/catalog.routes.ts
Updated product duplicator to use query builder for loading option groups; added channel assignment logic to data importer beforeSave hooks for ProductOptionGroup/ProductOption; minor type correction in deprecation directive handler.

Sequence Diagram

sequenceDiagram
    participant Client as Admin Client
    participant API as GraphQL API
    participant Service as ProductOptionGroupService
    participant Channel as ChannelService
    participant DB as Database

    rect rgba(200, 220, 255, 0.3)
    Note over Client,DB: Assign Option Groups to Channel
    Client->>API: assignProductOptionGroupsToChannel(groupIds, channelId)
    API->>Service: assignProductOptionGroupsToChannel()
    Service->>Channel: Validate permissions (UpdateCatalog)
    Channel-->>Service: OK
    Service->>DB: Load groups with options & channels
    DB-->>Service: Groups[]
    Service->>DB: Update many-to-many: groups→channels, options→channels
    DB-->>Service: Success
    Service-->>API: ProductOptionGroup[]
    API-->>Client: Result
    end

    rect rgba(255, 220, 200, 0.3)
    Note over Client,DB: Remove Option Groups from Channel (with In-Use Check)
    Client->>API: removeProductOptionGroupsFromChannel(groupIds, channelId, force?)
    API->>Service: removeProductOptionGroupsFromChannel()
    Service->>Channel: Validate permissions (DeleteCatalog)
    Channel-->>Service: OK
    Service->>DB: Load groups with variants, check in-use
    DB-->>Service: Groups[], usage counts
    alt In use && not force
        Service-->>API: ProductOptionGroupInUseError[]
    else Not in use or force=true
        Service->>DB: Remove from channel relationships
        DB-->>Service: Success
        Service-->>API: ProductOptionGroup[]
    end
    API-->>Client: Result
    end

    rect rgba(200, 255, 200, 0.3)
    Note over Client,DB: Paginated List with Channel Scope
    Client->>API: productOptionGroups(options: {take: 20, skip: 0})
    API->>Service: findAll(ctx, options)
    Service->>DB: Query with channel filter, pagination, relations
    DB-->>Service: PaginatedList{items[], totalItems}
    Service-->>API: Translated items
    API-->>Client: ProductOptionGroupList
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Rationale: This PR encompasses substantial heterogeneous changes spanning entities, GraphQL schema, resolvers, services, admin UI components, E2E tests, and internationalization across 8 languages. While individual changes follow consistent patterns (channel relationships, pagination, batch operations), they require separate reasoning per component due to data model implications, error handling logic, and UI integration points. The many-to-many relationship refactoring, lifecycle cleanup mechanics, and bulk action coordination add logic density beyond simple refactoring.

Possibly related PRs

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Linked Issues Check ⚠️ Warning The PR addresses three of four primary requirements from linked issue #3318. The implementation successfully includes Many-to-Many relationships with Channel entity, comprehensive GraphQL schema updates with new mutations and types for channel operations, channel isolation logic in services, comprehensive Admin UI for option group management, and extensive test coverage. However, the migration path for existing data—explicitly requested in issue #3318 to "Provide migration path for existing data"—is acknowledged in the PR description under "Future Work" and is not implemented in this changeset. This leaves existing ProductOption and ProductOptionGroup records without channel assignments until a future migration is applied. The migration path requirement from issue #3318 is incomplete. Either implement a database migration to assign existing ProductOption and ProductOptionGroup records to all current channels (preserving backward compatibility), or update the issue requirements to reflect that this is being delivered separately and schedule it as a critical follow-up. The PR's current approach of assigning new entities to the current channel handles forward-compatibility but leaves existing data stranded.
Docstring Coverage ⚠️ Warning Docstring coverage is 17.65% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed The title "feat(core): Make ProductOption and ProductOptionGroup channel-aware" is clear, specific, and directly reflects the main objective of the changeset. It accurately describes the primary change while maintaining conciseness and following the semantic commit convention. The title appropriately highlights the core implementation work that dominates the PR without unnecessary noise or vague terminology.
Out of Scope Changes Check ✅ Passed The changeset remains tightly focused on the channel-awareness feature. Core changes include entity implementations, service updates, GraphQL schema additions, comprehensive Admin UI for product option group management, e2e tests, and i18n updates for new messages. All modifications directly support the feature objectives: making ProductOption and ProductOptionGroup channel-aware and providing management interfaces. The extensive i18n updates across multiple language files (en.json, es.json, fr.json, de.json, pt_BR.json, pt_PT.json, ru.json, uk.json) are appropriate for the new error messages and UI text introduced by this feature.
Description Check ✅ Passed The PR description is well-structured and covers most of the template requirements. It provides a clear summary of changes, motivation, and implementation details across Core, GraphQL API, Channel Isolation, and Testing sections. The description includes references to linked issues and acknowledges backend-only changes. However, the "Breaking changes" section is not explicitly filled out in the template, though the PR does introduce schema changes that could be considered breaking. The description also notes that migration for existing data is listed under "Future Work" rather than being completed as part of this PR.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 65

🔭 Outside diff range comments (3)
packages/core/src/api/schema/admin-api/product-option-group.api.graphql (1)

47-50: Incorrect translation input type used for option creation

CreateGroupOptionInput.translations references ProductOptionGroupTranslationInput, but it should use ProductOptionTranslationInput (group vs option). This slipped through earlier but will break localisation for options.

-input CreateGroupOptionInput {
-    code: String!
-    translations: [ProductOptionGroupTranslationInput!]!
-}
+input CreateGroupOptionInput {
+    code: String!
+    translations: [ProductOptionTranslationInput!]!
+}
packages/dashboard/package.json (1)

47-133: Update react-resizable-panels to latest patch version

All checked packages are on their latest stable releases and no known security advisories were found for react-resizable-panels. A minor patch is available for react-resizable-panels (v3.0.4).

• packages/dashboard/package.json

  • Bump react-resizable-panels from 3.0.3 to 3.0.4
-  "react-resizable-panels": "^3.0.3",
+  "react-resizable-panels": "^3.0.4",
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/shipping-eligibility-checker-selector.tsx (1)

22-25: Inconsistent props typing - missing Readonly wrapper

Unlike other similar components in this PR, this component doesn't use the Readonly wrapper for its props, which violates the coding guidelines.

export function ShippingEligibilityCheckerSelector({
    value,
    onChange,
-}: ShippingEligibilityCheckerSelectorProps) {
+}: Readonly<ShippingEligibilityCheckerSelectorProps>) {
🧹 Nitpick comments (26)
packages/core/src/api/schema/admin-api/product-option-group.api.graphql (3)

17-20: Document permissions & behaviour of the new channel mutations

The schema docs don’t specify required permissions (e.g. UpdateCatalog, DeleteCatalog) or the behaviour when productOptionGroupIds contains groups already assigned / un-assigned to the given channel. This lack of spec makes it easy for downstream consumers (codegen, UI, integration tests) to make wrong assumptions.
Add a short description of required permissions and edge-case behaviour to the field descriptions.


69-73: Expose default for force and clarify semantics

force is nullable, but its default behaviour (assumed false) is not documented. Explicitly state the default in the description or make the field non-null with a default value via SDL:

force: Boolean = false

to avoid surprises for API consumers.


83-83: Union name is inconsistent with mutation name

The mutation is removeProductOptionGroupsFromChannel (plural), but the union is RemoveProductOptionGroupFromChannelResult (singular). Aligning them avoids confusion in generated types:

-union RemoveProductOptionGroupFromChannelResult = ProductOptionGroup | ProductOptionGroupInUseError
+union RemoveProductOptionGroupsFromChannelResult = ProductOptionGroup | ProductOptionGroupInUseError
packages/core/e2e/product-option.e2e-spec.ts (1)

442-442: Consider using a more idiomatic assertion approach.

Instead of expect.fail(), consider using Vitest's expect().rejects.toThrow() pattern for cleaner async error assertions.

-                expect.fail('Should have thrown an error');
+                // This line should not be reached
+                fail('Expected an error to be thrown');

Alternatively, refactor the entire test to use:

await expect(
    adminClient.query(REMOVE_PRODUCT_OPTION_GROUPS_FROM_CHANNEL, { input: {...} })
).rejects.toThrow('Items cannot be removed from the default Channel');
docs/src/css/typography.css (1)

6-6: Large Google-Fonts request may impact CLS & FCP

Importing four variable-axis families via CSS blocks render until they download. Consider:

• Moving the import tag to HTML <head> with rel="preconnect" & display=swap, or
• Self-hosting subsetted fonts with font-display: swap.

This reduces layout shift and improves first paint.

docs/src/css/components.css (1)

45-52: Code-block max-height of 800 px may hide long examples without scroll cue

Line 51 sets max-height: 800px but no overflow rule. Add:

-.limited-height-code-block pre.prism-code {
-    max-height: 800px;
-}
+.limited-height-code-block pre.prism-code {
+    max-height: 800px;
+    overflow: auto;
+}
docs/src/pages/index.module.css (2)

188-191: Duplicate gap declaration

Lines 188-190 repeat gap: 24px;. Remove the duplicate to avoid accidental future divergence.

-    flex-direction: column;
-    gap: 24px;
-    gap: 24px;
+    flex-direction: column;
+    gap: 24px;

192-199: Mixed indentation inside media query

The .docContainer & grid rules are indented one extra level (Lines 192-199), inconsistent with surrounding blocks and other files. Align for readability.

-        .docContainer {
-        padding: 0 1rem;
-    }
+    .docContainer {
+        padding: 0 1rem;
+    }
CONTRIBUTING.md (3)

29-89: Clear getting started instructions with one consistency issue.

The forking and syncing instructions are comprehensive and well-structured. However, there's an inconsistency in spelling variants.

Fix the spelling consistency by using either American or British English throughout:

-There are **three main ways** to keep your fork synchronized:
+There are **three main ways** to keep your fork synchronised:

Or change line 55 to use American spelling consistently with line 59.


310-398: Clear contribution standards with minor punctuation fixes needed.

The guidelines effectively explain branching strategy, commit formats, and breaking changes. The use of Conventional Commits is well-documented with examples.

Fix American English punctuation for consistency:

-* **style** (formatting, missing semi colons, etc; no code change)
+* **style** (formatting, missing semi colons, etc.; no code change)
-* **chore** (updating build tasks etc; no production code change)
+* **chore** (updating build tasks etc.; no production code change)

399-452: Thorough technical documentation with one punctuation fix.

The sections on code generation, testing, and release process provide excellent technical depth for contributors.

Fix punctuation for consistency:

-e2e tests in those packages which feature e2e tests (core, elasticsearch-plugin, asset-server-plugin etc).
+e2e tests in those packages which feature e2e tests (core, elasticsearch-plugin, asset-server-plugin etc.).
packages/core/e2e/cache-service-redis.e2e-spec.ts (1)

99-121: Verify TTL test timing reliability.

The TTL tests use hardcoded timeouts (1000ms, 1500ms) which could be flaky in slow CI environments. Consider making these timeouts configurable or adding buffer time.

-        it('sets a key with ttl', async () => {
-            await cacheService.set('test-key1', 'test-value', { ttl: 1000 });
-            const result = await cacheService.get('test-key1');
-            expect(result).toBe('test-value');
-
-            await new Promise(resolve => setTimeout(resolve, 1500));
-
-            const result2 = await cacheService.get('test-key1');
-
-            expect(result2).toBeUndefined();
-        });
+        it('sets a key with ttl', async () => {
+            const ttl = 1000;
+            const waitTime = ttl + 500; // Add buffer for CI environments
+            await cacheService.set('test-key1', 'test-value', { ttl });
+            const result = await cacheService.get('test-key1');
+            expect(result).toBe('test-value');
+
+            await new Promise(resolve => setTimeout(resolve, waitTime));
+
+            const result2 = await cacheService.get('test-key1');
+
+            expect(result2).toBeUndefined();
+        });
packages/cli/e2e/cli-migrate.e2e-spec.ts (1)

102-109: Clarify expected behavior for no-arguments case.

The test expects exit code 1 when no arguments are provided, but the comment suggests it "shows help". Consider adding a test comment explaining why this is the expected behavior.

        it('should show help when no arguments provided', async () => {
            testProject = createTestProject('help-test');

+            // CLI shows help in stderr but exits with error code when no command is specified
            // CLI shows help but exits with code 1 when no arguments provided
            const result = await testProject.runCliCommand([], { expectError: true });
            expect(result.exitCode).toBe(1);
            expect(result.stderr).toContain('Usage:');
        });
packages/cli/src/commands/migrate/load-vendure-config-file.spec.ts (1)

96-134: Consider consolidating redundant test suites.

The second describe block tests similar functionality to the first but focuses on parsing. Consider consolidating or clearly differentiating the test purposes.

-describe('JSON parsing with comments', () => {
+describe('Integration: JSON parsing with comments', () => {
     it('should successfully parse tsconfig.json content with /* in paths', () => {
+        // Integration test: verifies the complete flow from comment stripping to parsing
packages/cli/e2e/cli-test-utils.ts (1)

32-43: Consider making dependency versions configurable.

The hardcoded version numbers ('3.3.7' for Vendure packages and '5.8.2' for TypeScript) may become outdated and cause test failures when new versions are released.

Consider making these configurable or reading from a shared configuration:

-        dependencies: {
-            '@vendure/core': '3.3.7',
-            '@vendure/common': '3.3.7',
-        },
-        devDependencies: {
-            typescript: '5.8.2',
-        },
+        dependencies: {
+            '@vendure/core': process.env.VENDURE_VERSION || '3.3.7',
+            '@vendure/common': process.env.VENDURE_VERSION || '3.3.7',
+        },
+        devDependencies: {
+            typescript: process.env.TYPESCRIPT_VERSION || '5.8.2',
+        },
packages/dashboard/src/app/routes/_authenticated/_collections/components/collection-contents-preview-table.tsx (1)

66-70: Consider localizing the alert content.

The alert title and description text are hardcoded in English. Consider using the Trans component for better internationalization support.

Apply this diff to add localization:

-                <AlertTitle>Preview</AlertTitle>
-                <AlertDescription>
-                    This is a preview of the collection contents based on the current filter settings. Once
-                    you save the collection, the contents will be updated to reflect the new filter settings.
-                </AlertDescription>
+                <AlertTitle><Trans>Preview</Trans></AlertTitle>
+                <AlertDescription>
+                    <Trans>This is a preview of the collection contents based on the current filter settings. Once
+                    you save the collection, the contents will be updated to reflect the new filter settings.</Trans>
+                </AlertDescription>
.github/workflows/scripts/setup-test-plugin.js (1)

14-22: Consider more robust plugin array parsing.

The regex-based approach for finding and modifying the plugins array could be fragile with complex configurations or different formatting styles.

For better reliability, consider using an AST parser like @babel/parser to parse the TypeScript file and modify the plugins array programmatically, though the current approach is sufficient for CI testing purposes.

packages/dashboard/src/lib/components/data-input/select-with-options.tsx (1)

47-59: Consider safer placeholder handling for MultiSelect.

The String(placeholder) conversion might not handle complex React nodes properly. Consider providing a fallback or type check.

-                placeholder={placeholder ? String(placeholder) : 'Select options'}
+                placeholder={typeof placeholder === 'string' ? placeholder : 'Select options'}
.github/workflows/build_test_dashboard.yml (1)

109-109: Fix trailing spaces.

Static analysis detected trailing spaces on this line. Please remove them for cleaner code.

-            - name: Copy files (Unix)  
+            - name: Copy files (Unix)
packages/dashboard/src/app/routes/_authenticated/_facets/components/add-facet-value-dialog.tsx (1)

82-86: Consider making language code configurable

The language code is hardcoded as 'en' which may limit internationalization capabilities. Consider making this configurable based on the current admin interface language or user preferences.

  translations: [
    {
-     languageCode: 'en',
+     languageCode: i18n.locale || 'en',
      name: values.name,
    },
  ],
packages/dashboard/src/lib/components/shared/configurable-operation-selector.tsx (1)

69-77: Props should be typed as Readonly

According to the coding guidelines, React component props objects should be typed as Readonly<...>. The props are already destructured with Readonly<> in the function signature, but the interface definition should also reflect this pattern for consistency.

-export interface ConfigurableOperationSelectorProps {
+export interface ConfigurableOperationSelectorProps extends Readonly<{
     /** Current selected configurable operation value */
     value: ConfigurableOperationInputType | undefined;
     /** Callback function called when the selection changes */
     onChange: (value: ConfigurableOperationInputType | undefined) => void;
     /** GraphQL document for querying available operations */
     queryDocument: any;
     /** Unique key for the query cache */
     queryKey: string;
     /** Dot-separated path to extract operations from query result (e.g., "paymentMethodHandlers") */
     dataPath: string;
     /** Text to display on the selection button */
     buttonText: string;
     /** Text to display when no operations are available (defaults to "No options found") */
     emptyText?: string;
-}
+}>
packages/dashboard/src/app/routes/_authenticated/_facets/components/facet-values-table.tsx (1)

30-31: Props interface should use Readonly wrapper

The component props should be wrapped with Readonly<> at the interface level for consistency.

-export interface FacetValuesTableProps {
+export interface FacetValuesTableProps extends Readonly<{
     facetId: string;
     registerRefresher?: (refresher: () => void) => void;
-}
+}>
packages/dashboard/src/lib/components/ui/navigation-menu.tsx (1)

76-81: Remove hardcoded space character

The space character {" "} between {children} and the ChevronDownIcon can cause inconsistent spacing. Consider using CSS for spacing instead.

-      {children}{" "}
+      {children}
       <ChevronDownIcon
-        className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
+        className="relative top-[1px] ml-1.5 size-3 transition duration-300 group-data-[state=open]:rotate-180"
         aria-hidden="true"
       />
packages/dashboard/src/lib/components/data-input/configurable-operation-list-input.tsx (1)

143-149: Improve date handling robustness

The DateTimeInput components receive values that might not be valid date strings, which could cause runtime errors.

Add date validation:

 case 'date-form-input':
+    const dateValue = itemValue ? new Date(itemValue) : new Date();
+    const isValidDate = !isNaN(dateValue.getTime());
     return (
         <DateTimeInput
-            value={itemValue ? new Date(itemValue) : new Date()}
+            value={isValidDate ? dateValue : new Date()}
             onChange={val => handleUpdateItem(index, val.toISOString())}
             disabled={readOnly}
         />
     );

Also applies to: 209-213

packages/dashboard/src/lib/components/shared/configurable-operation-arg-input.tsx (1)

171-174: Add localization for console warning message

While console warnings are typically not user-facing, it's good practice to have consistent messaging.

Consider using a translation function for the warning message:

-                        console.warn(
-                            `Failed to load UI component ${uiComponent}, falling back to type-based input`,
-                        );
+                        console.warn(
+                            i18n.t('Failed to load UI component {component}, falling back to type-based input', 
+                                  { component: uiComponent }),
+                        );

Note: This would require passing i18n to the component or using the useLingui hook.

packages/dashboard/src/lib/components/ui/context-menu.tsx (1)

9-13: Conform to dashboard typing guideline – props should be Readonly<…>

The coding guidelines for packages/dashboard/src/**/*.{ts,tsx} mandate Readonly<…> for component props. Wrap the inferred type accordingly:

-function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
+function ContextMenu({
+  ...props
+}: Readonly<React.ComponentProps<typeof ContextMenuPrimitive.Root>>) {

Apply consistently to every wrapper in this file.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Review continued from previous batch...

@mezzat11
Copy link

Hope this getting merged soon.

@asonnleitner
Copy link
Contributor Author

@michaelbromley any recommendations for a migration script?

@asonnleitner
Copy link
Contributor Author

I have read the CLA Document and I hereby sign the CLA

@asonnleitner asonnleitner force-pushed the feat/channel-aware-options branch from 3e9ad76 to bb005ae Compare August 4, 2025 17:09
@sonarqubecloud
Copy link

sonarqubecloud bot commented Aug 5, 2025

@asonnleitner asonnleitner marked this pull request as ready for review October 22, 2025 10:06
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 27

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (12)
packages/dashboard/src/app/routes/_authenticated/_products/components/create-product-variants-dialog.tsx (1)

286-295: Include createProductOptionMutation in disabled and loading states.

The button's disabled condition and loading state checks are missing createProductOptionMutation.isPending, even though this mutation is used in handleCreateVariants (line 202). This could allow button clicks during option creation and fail to show the loading state.

Apply this diff:

                             onClick={() => void handleCreateVariants()}
                             disabled={
                                 !variantData ||
                                 createOptionGroupMutation.isPending ||
+                                createProductOptionMutation.isPending ||
                                 addOptionGroupToProductMutation.isPending ||
                                 createProductVariantsMutation.isPending
                             }
                         >
                             {createOptionGroupMutation.isPending ||
+                            createProductOptionMutation.isPending ||
                             addOptionGroupToProductMutation.isPending ||
                             createProductVariantsMutation.isPending ? (
packages/core/src/service/services/product.service.ts (1)

399-411: Harden lookup against soft-deleted groups.

Guard against attaching a soft-deleted ProductOptionGroup. Add deletedAt: IsNull() to the lookup.

- const optionGroup = await this.connection.getRepository(ctx, ProductOptionGroup).findOne({
-     where: { id: optionGroupId },
-     relations: ['channels'],
- });
+ const optionGroup = await this.connection.getRepository(ctx, ProductOptionGroup).findOne({
+     where: { id: optionGroupId, deletedAt: IsNull() },
+     relations: ['channels'],
+ });
packages/dashboard/src/app/routes/_authenticated/_products/components/create-product-variants.tsx (1)

314-372: Fix variant ID generation to prevent collisions across groups.

The collision risk is valid. Since Option.id and Option.code are optional and not globally unique across groups, two different options with the same code in different groups will produce identical IDs. For example, if Group1 and Group2 both contain an option with code "red", the current logic produces id: "red-red" regardless of which actual options are selected—causing collisions and violating React's key stability requirement.

The proposed fix correctly addresses this by prefixing each option ID with its group dimension: ${currentGroup.id ?? currentIndex}:${option.id ?? option.code ?? option.name}. This ensures variant IDs encode group membership and remain stable and unique.

-            { name: currentGroup.name, value: option.name, id: option.id || option.code },
+            {
+              name: currentGroup.name,
+              value: option.name,
+              id: `${currentGroup.id ?? currentIndex}:${option.id ?? option.code ?? option.name}`,
+            },

Apply this fix at line 361 to stabilize variant IDs for both data integrity and React rendering at line 220.

packages/core/src/data-import/providers/importer/fast-importer.service.ts (1)

203-214: All five occurrences lack explicit falsy channel filtering; update all call sites to filter before unique().

Found 5 call sites using the pattern without falsy channel filtering:

  • Line 86: p.channels = unique(...)
  • Line 119: g.channels = unique(...)
  • Line 134: po.channels = unique(...)
  • Line 170: variant.channels = unique(...)
  • Line 203: const assignedChannelIds = unique(...).map(...)

Add .filter() to exclude null/undefined channels across all 5 locations before the unique() call (e.g., unique([this.defaultChannel, this.importCtx.channel].filter(Boolean), 'id')).

packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts (3)

878-883: Wrong translation input type on CreateProductOptionInput

The translations field in CreateProductOptionInput uses the wrong type. Since this input is for creating a ProductOption (as indicated by the productOptionGroupId field), it should accept ProductOptionTranslationInput to describe the translations for the option itself—not ProductOptionGroupTranslationInput which is for the parent group.

Apply:

 export type CreateProductOptionInput = {
   code: Scalars['String']['input'];
   customFields?: InputMaybe<Scalars['JSON']['input']>;
   productOptionGroupId: Scalars['ID']['input'];
-  translations: Array<ProductOptionGroupTranslationInput>;
+  translations: Array<ProductOptionTranslationInput>;
 };

848-852: Fix wrong translation input type on CreateGroupOptionInput across all generated types

CreateGroupOptionInput.translations incorrectly uses ProductOptionGroupTranslationInput but should use ProductOptionTranslationInput. This semantic mismatch breaks TypeScript type checking for clients creating product options.

Apply to all affected files (asset-server-plugin, elasticsearch-plugin, payments-plugin, core, common, admin-ui, dev-server, and test plugins):

 export type CreateGroupOptionInput = {
   code: Scalars['String']['input'];
-  translations: Array<ProductOptionGroupTranslationInput>;
+  translations: Array<ProductOptionTranslationInput>;
 };

Also fix CreateProductOptionInput (same issue—creates individual options, not groups):

 export type CreateProductOptionInput = {
   code: Scalars['String']['input'];
   productOptionGroupId: Scalars['ID']['input'];
-  translations: Array<ProductOptionGroupTranslationInput>;
+  translations: Array<ProductOptionTranslationInput>;
 };

Regenerate all generated types after fixing the GraphQL schema to ensure consistency.


6518-6523: Fix incorrect translation input type on UpdateProductOptionInput

UpdateProductOptionInput.translations is currently typed as ProductOptionGroupTranslationInput, but should be ProductOptionTranslationInput. CreateProductVariantOptionInput (line 905-908) uses ProductOptionTranslationInput for standalone options, and UpdateProductOptionInput lacks a productOptionGroupId field—indicating it updates standalone options, not group options (which are handled separately by UpdateProductOptionGroupInput).

 export type UpdateProductOptionInput = {
   code?: InputMaybe<Scalars['String']['input']>;
   customFields?: InputMaybe<Scalars['JSON']['input']>;
   id: Scalars['ID']['input'];
-  translations?: InputMaybe<Array<ProductOptionGroupTranslationInput>>;
+  translations?: InputMaybe<Array<ProductOptionTranslationInput>>;
 };
packages/core/src/service/services/product-option.service.ts (1)

169-199: Implement force behavior or remove unused force parameter in ProductOptionService.delete()

The admin resolver passes args.force to this method (packages/core/src/api/resolvers/admin/product-option.resolver.ts:227) but the implementation in packages/core/src/service/services/product-option.service.ts (lines 169–199) never uses it. Either implement the intended force semantics (e.g., skip in-use checks and hard-delete when true) or remove the parameter and update callers (resolver and other callers).

packages/core/src/api/schema/admin-api/product-option-group.api.graphql (1)

61-70: Wrong translation input type for ProductOption translations.

Option translations reference ProductOptionGroupTranslationInput. They should use ProductOptionTranslationInput to match the entity and avoid schema/DTO mismatches.

 input CreateGroupOptionInput {
   code: String!
-  translations: [ProductOptionGroupTranslationInput!]!
+  translations: [ProductOptionTranslationInput!]!
 }

 input CreateProductOptionInput {
   productOptionGroupId: ID!
   code: String!
-  translations: [ProductOptionGroupTranslationInput!]!
+  translations: [ProductOptionTranslationInput!]!
 }

 input UpdateProductOptionInput {
   id: ID!
   code: String
-  translations: [ProductOptionGroupTranslationInput!]
+  translations: [ProductOptionTranslationInput!]
 }

Also applies to: 72-76

packages/core/src/api/resolvers/admin/product-option.resolver.ts (1)

56-62: Pass @relations(...) to service.findOne() to honor requested fields.

Currently ignores the computed relations, which can cause extra queries or missing fields.

-    ): Promise<Translated<ProductOptionGroup> | undefined> {
-        return this.productOptionGroupService.findOne(ctx, args.id);
+    ): Promise<Translated<ProductOptionGroup> | undefined> {
+        return this.productOptionGroupService.findOne(ctx, args.id, relations);
packages/common/src/generated-types.ts (2)

6869-6874: Bug: UpdateProductOptionInput uses group translation type

UpdateProductOptionInput.translations is typed as Array. It should be Array. Fix schema and regenerate.

Example schema patch:

-input UpdateProductOptionInput {
-  id: ID!
-  code: String
-  customFields: JSON
-  translations: [ProductOptionGroupTranslationInput!]
-}
+input UpdateProductOptionInput {
+  id: ID!
+  code: String
+  customFields: JSON
+  translations: [ProductOptionTranslationInput!]
+}

889-894: Fix GraphQL schema: CreateProductOptionInput and UpdateProductOptionInput use incorrect translation type

The bug is confirmed. Both CreateProductOptionInput and UpdateProductOptionInput in packages/core/src/api/schema/admin-api/product-option-group.api.graphql use ProductOptionGroupTranslationInput, but should use ProductOptionTranslationInput to align with CreateProductVariantOptionInput and the ProductOption entity pattern. This error is reflected in three generated type files (packages/common/src, packages/admin-ui/src/lib/core/src/common, and packages/dev-server/test-plugins/reviews/ui). Update the GraphQL schema and regenerate types.

♻️ Duplicate comments (3)
packages/core/src/i18n/messages/de.json (1)

155-155: Fix entity/variables in PRODUCT_OPTION_IN_USE_ERROR

This key is for ProductOption, but the message references ProductOptionGroup and uses optionGroupCode. Use ProductOption and optionCode.

-        "PRODUCT_OPTION_IN_USE_ERROR": "Die ProductOptionGroup \"{ optionGroupCode }\" kann nicht entfernt werden, solange sie von {variantCount, plural, one {einer Produktvariante} other {# Produktvarianten}} genutzt wird",
+        "PRODUCT_OPTION_IN_USE_ERROR": "Die ProductOption \"{ optionCode }\" kann nicht entfernt werden, solange sie von {variantCount, plural, one {einer Produktvariante} other {# Produktvarianten}} genutzt wird",
packages/core/src/api/schema/admin-api/product-option-group.api.graphql (1)

79-81: Validate non-empty productOptionGroupIds.

GraphQL can’t enforce non-empty arrays; reject empty lists in the service/DTO to avoid no-op calls.

Add validation (e.g., class-validator @ArrayMinSize(1)) or explicit check in the resolver/service.

Also applies to: 84-87

packages/core/src/service/services/product-option-group.service.ts (1)

303-309: Return query uses ctx.channelId instead of input.channelId.

After assigning to input.channelId, querying with ctx.channelId can return empty results if operating from another channel. Query the target channel.

         return this.connection
             .findByIdsInChannel(
                 ctx,
                 ProductOptionGroup,
                 groupsToAssign.map(g => g.id),
-                ctx.channelId,
+                input.channelId,
                 { relations: ['options'] },
             )
🧹 Nitpick comments (53)
packages/core/src/api/config/graphql-custom-fields.ts (1)

759-763: Remove redundant type assertion.

The explicit cast (field.deprecated as string) on line 761 is unnecessary. TypeScript's control flow analysis already narrows field.deprecated to type string after the typeof check on line 759.

Apply this diff to simplify the code:

     if (typeof field.deprecated === 'string') {
         // Escape quotes in the deprecation reason
-        const escapedReason = (field.deprecated as string).replace(/"/g, '\\"');
+        const escapedReason = field.deprecated.replace(/"/g, '\\"');
         return `@deprecated(reason: "${escapedReason}")`;
     }
packages/dashboard/src/app/routes/_authenticated/_products/components/create-product-variants-dialog.tsx (3)

126-126: Replace any with a proper type.

The defaultLanguageCode parameter uses any, which bypasses type safety. Since this comes from activeChannel.defaultLanguageCode, use the proper type (likely string or a LanguageCode enum type).

Apply this diff:

     function mapVariantToCreateInput(
         variant: VariantConfiguration['variants'][0],
         allOptionGroups: Array<{ id: string; name: string; options: Array<{ id: string; name: string }> }>,
-        defaultLanguageCode: any
+        defaultLanguageCode: string
     ) {

193-193: Remove redundant check.

The condition if (!group.id) continue; is redundant because existingGroups was already filtered to only include groups with IDs (line 158).

Apply this diff:

             // 3. Handle existing groups - create new options if needed
             for (const group of existingGroups) {
-                if (!group.id) continue; // Skip groups without ID (should not happen due to filter)
                 const newOptions = group.options.filter(o => !o.id);

73-81: Wrap component props in Readonly<> to follow coding guidelines.

As per coding guidelines for React components in the dashboard, props should be typed as Readonly<...>.

Apply this diff:

 export function CreateProductVariantsDialog({
     productId,
     productName,
     onSuccess,
-}: {
+}: Readonly<{
     productId: string;
     productName: string;
     onSuccess?: () => void;
-}) {
+}>) {
packages/dashboard/src/app/routes/_authenticated/_product-option-groups/components/product-options-table.tsx (1)

14-14: Use consistent quote style for imports.

This import uses double quotes while all other imports in the file use single quotes. Maintain consistency.

Apply this diff:

-import { DeleteProductOptionsBulkAction } from "./product-option-group-bulk-actions.js";
+import { DeleteProductOptionsBulkAction } from './product-option-group-bulk-actions.js';
packages/core/src/service/services/product.service.ts (3)

499-548: Micro-optimizations and resiliency (optional).

  • Batch save: collect all orphan groups, update product.optionGroups once, then save once.
  • Log more context (channelId, variant ids) to aid debugging and audit.

77-87: Minor: avoid async on onModuleInit and alias rxjs operator.

  • onModuleInit doesn't await; remove async.
  • Alias filter to avoid confusion with similarly named helpers.
- import { filter } from 'rxjs/operators';
+ import { filter as rxFilter } from 'rxjs/operators';
...
-    async onModuleInit() {
+    onModuleInit() {
-        this.eventBus
-            .ofType(ProductVariantEvent)
-            .pipe(filter(event => event.type === 'deleted'))
+        this.eventBus
+            .ofType(ProductVariantEvent)
+            .pipe(rxFilter(event => event.type === 'deleted'))

Also applies to: 14-14


526-537: Delegate removal to ProductOptionGroupService to avoid duplicating product mutation logic.

The method ProductOptionGroupService.deleteGroupAndOptionsFromProduct() already handles removing the option group from the product (line 223 in product-option-group.service.ts). Duplicating this logic here risks inconsistency if invariants or side-effects are added later.

Note: deleteGroupAndOptionsFromProduct() emits ProductOptionGroupEvent (not ProductOptionGroupChangeEvent), so the suggested event emission in the review diff would not cause double emission but would emit a different, necessary event on successful deletion.

packages/dashboard/src/app/routes/_authenticated/_products/components/create-product-variants.tsx (8)

104-106: Avoid JSON.stringify in deps; rely on reference or deep-compare.

JSON.stringify in deps is expensive and brittle. Depend on the array reference (OptionGroupSearchInput already returns new arrays) or use a deep-compare hook.

Apply this diff:

-  const variants = useMemo(
-        () => generateVariants(optionGroups),
-        [JSON.stringify(optionGroups)],
-    );
+  const variants = useMemo(() => generateVariants(optionGroups), [optionGroups]);

110-138: Minor: trim defensive checks.

variant has static shape; the typeof/object checks add noise. Safe to remove for clarity.

-      variants.forEach(variant => {
-        if (variant && typeof variant === 'object') {
-          const formVariant = formVariants[variant.id];
-          if (formVariant) {
-            activeVariants.push({
+      variants.forEach(variant => {
+        const formVariant = formVariants[variant.id];
+        if (formVariant) {
+          activeVariants.push({
               enabled: formVariant.enabled ?? true,
               sku: formVariant.sku ?? '',
               price: formVariant.price ?? '',
               stock: formVariant.stock ?? '',
               options: variant.options,
-            });
-          }
-        }
+          });
+        }
       });

141-158: Prune stale variant form entries when variant set shrinks.

The effect adds missing keys but never removes obsolete ones, leaving dead state around.

   useEffect(() => {
     // Initialize any new variants with default values
     const currentVariants = form.getValues().variants || {};
-    const updatedVariants = { ...currentVariants };
+    const updatedVariants = { ...currentVariants };
+    const validIds = new Set(variants.map(v => v.id));

     variants.forEach(variant => {
       if (!updatedVariants[variant.id]) {
         updatedVariants[variant.id] = {
           enabled: true,
           sku: '',
           price: '',
           stock: '',
         };
       }
     });

-    setValue('variants', updatedVariants);
+    // Drop removed variant keys
+    const pruned = Object.fromEntries(
+      Object.entries(updatedVariants).filter(([id]) => validIds.has(id))
+    );
+    setValue('variants', pruned, { shouldDirty: false });
   }, [variants, form, setValue]);

84-88: Add staleTime to useQuery (per guidelines).

Follow the app’s TanStack Query pattern to reduce refetch churn.

   const { data: stockLocationsResult } = useQuery({
     queryKey: ['stockLocations'],
-    queryFn: () => api.query(getStockLocationsDocument, { options: { take: 100 } }),
+    staleTime: 5 * 60 * 1000,
+    queryFn: () => api.query(getStockLocationsDocument, { options: { take: 100 } }),
   });

As per coding guidelines.


59-66: Localize validation messages and placeholders.

Zod messages and the “SKU” placeholder should be localized.

+import { t } from '@lingui/macro';
+import { useLingui } from '@lingui/react';
@@
-const variantSchema = z.object({
-    enabled: z.boolean().default(true),
-    sku: z.string().min(1, { message: 'SKU is required' }),
-    price: z.string().refine(val => !isNaN(Number(val)) && Number(val) >= 0, {
-        message: 'Price must be a positive number',
-    }),
-    stock: z.string().refine(val => !isNaN(Number(val)) && parseInt(val, 10) >= 0, {
-        message: 'Stock must be a non-negative integer',
-    }),
-});
+const variantSchema = z.object({
+  enabled: z.boolean().default(true),
+  sku: z.string().min(1, { message: t`SKU is required` }),
+  price: z.string().refine(val => !isNaN(Number(val)) && Number(val) >= 0, {
+    message: t`Price must be a positive number`,
+  }),
+  stock: z.string().refine(val => !isNaN(Number(val)) && parseInt(val, 10) >= 0, {
+    message: t`Stock must be a non-negative integer`,
+  }),
+});
@@
 export function CreateProductVariants({
   currencyCode = 'USD',
   onChange,
 }: Readonly<CreateProductVariantsProps>) {
+  const { i18n } = useLingui();
@@
-  <Input {...field} placeholder="SKU" />
+  <Input {...field} placeholder={i18n._(t`SKU`)} />
@@
-  placeholder="0.00"
+  placeholder="0.00"

As per coding guidelines.

Also applies to: 248-255, 270-274


245-256: Consider FormFieldWrapper for consistency.

If the project standardizes on FormFieldWrapper, refactor these fields to match the shared pattern for labels, help text, and errors.

As per coding guidelines.

Also applies to: 259-281, 283-301


314-372: Optional: deterministic ordering.

If options can be returned in arbitrary order, sort by display name or code before combination to keep row order stable between renders.

-const currentGroup = optionGroups[currentIndex];
+const currentGroup = optionGroups[currentIndex];
+// optionally enforce stable ordering
+// currentGroup.options = [...currentGroup.options].sort((a, b) => (a.name || '').localeCompare(b.name || ''));

29-44: Minor: remove unused type alias.

VariantForm appears unused.

-type VariantForm = z.infer<typeof variantSchema>;

Also applies to: 72-74

packages/dashboard/src/app/common/duplicate-bulk-action.tsx (1)

75-85: Localize all user-facing messages (toasts and fallback strings).

Several hardcoded English strings bypass i18n (Unknown error, error toast). Localize via i18n.t to meet dashboard guidelines.

As per coding guidelines.

-                            result.duplicateEntity.message ||
-                            result.duplicateEntity.duplicationError ||
-                            'Unknown error';
+                            result.duplicateEntity.message ||
+                            result.duplicateEntity.duplicationError ||
+                            i18n.t('Unknown error');
...
-                    results.errors.push(
-                        `${entityName} ${entity.name || entity.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
-                    );
+                    results.errors.push(
+                        `${entityName} ${entity.name || entity.id}: ${
+                            error instanceof Error ? error.message : i18n.t('Unknown error')
+                        }`,
+                    );
...
-                toast.success(
-                    i18n.t(`Successfully duplicated ${results.success} ${entityName.toLowerCase()}s`),
-                );
+                toast.success(
+                    i18n.t('Successfully duplicated {count} {entity}s', {
+                        count: results.success,
+                        entity: entityName.toLowerCase(),
+                    }),
+                );
...
-                toast.error(
-                    `Failed to duplicate ${results.failed} ${entityName.toLowerCase()}s: ${errorMessage}`,
-                );
+                toast.error(
+                    i18n.t('Failed to duplicate {count} {entity}s: {message}', {
+                        count: results.failed,
+                        entity: entityName.toLowerCase(),
+                        message: errorMessage,
+                    }),
+                );

Also applies to: 93-105

packages/dashboard/src/app/routes/_authenticated/_product-option-groups/components/edit-product-option.tsx (3)

48-51: Add staleTime and expose loading state.

Follow the standard useQuery pattern and capture isLoading for correct conditional rendering.

As per coding guidelines.

-    const { data: productOptions } = useQuery({
-        queryKey: ['productOptions', productOptionId],
-        queryFn: () => api.query(productOptionsDocument, { options: { filter: { id: { eq: productOptionId } } } }),
-    });
+    const { data: productOptions, isLoading } = useQuery({
+        queryKey: ['productOptions', productOptionId],
+        staleTime: 30_000,
+        queryFn: () =>
+            api.query(productOptionsDocument, {
+                options: { filter: { id: { eq: productOptionId } } },
+            }),
+    });

97-99: Localize headings.

Wrap headings/description in Trans to comply with localization rules.

As per coding guidelines.

-                <h4 className="font-medium leading-none">Edit Product Option</h4>
-                <p className="text-sm text-muted-foreground">Update the name and code of this product option.</p>
+                <h4 className="font-medium leading-none">
+                    <Trans>Edit Product Option</Trans>
+                </h4>
+                <p className="text-sm text-muted-foreground">
+                    <Trans>Update the name and code of this product option.</Trans>
+                </p>

100-119: Prefer FormFieldWrapper for consistency with dashboard forms.

Using FormFieldWrapper yields consistent labels, help text, and errors across the app.

I can provide a concrete refactor if desired. As per coding guidelines.

packages/core/src/api/schema/common/product-option-group.type.graphql (1)

8-9: Deprecate non-paginated options to steer clients to optionList

Avoid large payloads and N+1s by guiding clients.

Apply:

 type ProductOptionGroup implements Node {
   id: ID!
   createdAt: DateTime!
   updatedAt: DateTime!
   languageCode: LanguageCode!
   code: String!
   name: String!
-  options: [ProductOption!]!
+  options: [ProductOption!]! @deprecated(reason: "Use optionList for pagination, filtering and sorting")
   optionList(options: ProductOptionListOptions): ProductOptionList!
   translations: [ProductOptionGroupTranslation!]!
 }
packages/core/src/i18n/messages/de.json (1)

186-186: Typo: “Produktvariantem” → “Produktvarianten”

Minor German plural fix.

-        "facet-force-deleted": "Die Facette wurde gelöscht und ihre FacetValues wurden von {products, plural, =0 {} one {einem Produkt} other {# Produkten}}{both, select, both { und } single {} other {}}{variants, plural, =0 {} one {einer Produktvariante} other {# Produktvariantem}} entfernt",
+        "facet-force-deleted": "Die Facette wurde gelöscht und ihre FacetValues wurden von {products, plural, =0 {} one {einem Produkt} other {# Produkten}}{both, select, both { und } single {} other {}}{variants, plural, =0 {} one {einer Produktvariante} other {# Produktvarianten}} entfernt",
packages/dashboard/src/app/routes/_authenticated/_products/components/option-search.tsx (2)

24-31: Type props as Readonly for React components

Conform to project guideline: component props should be Readonly<...>.

-interface OptionSearchProps {
+type OptionSearchProps = Readonly<{
     groupId?: string;
     groupName: string;
     onSelect: (option: Option) => void;
     selectedOptions?: Option[];
     placeholder?: string;
     disabled?: boolean;
-}
+}>;

58-70: Add staleTime and search by code OR name

Follow TanStack Query pattern (include staleTime). Also search by code as well as name using OR operator to match UX filter.

-    const { data, isLoading } = useQuery({
-        queryKey: ['productOptions', groupId, debouncedSearch],
-        queryFn: () => api.query(searchProductOptionsDocument, {
-            options: {
-                filter: {
-                    groupId: { eq: groupId },
-                    name: { contains: debouncedSearch },
-                },
-                take: 30
-            }
-        }),
-        enabled: !!groupId
-    });
+    const { data, isLoading } = useQuery({
+        queryKey: ['productOptions', groupId, debouncedSearch],
+        staleTime: 30_000,
+        queryFn: () =>
+            api.query(searchProductOptionsDocument, {
+                options: {
+                    filterOperator: 'OR',
+                    filter: {
+                        groupId: { eq: groupId },
+                        name: debouncedSearch ? { contains: debouncedSearch } : undefined,
+                        code: debouncedSearch ? { contains: debouncedSearch } : undefined,
+                    },
+                    take: 30,
+                },
+            }),
+        enabled: !!groupId,
+    });
packages/dashboard/src/app/routes/_authenticated/_product-option-groups/product-option-groups_.$id.tsx (1)

52-65: Avoid shadowing “entity” from upper scope

Rename the parameter to prevent eslint no-shadow.

-        setValuesForUpdate: entity => {
+        setValuesForUpdate: group => {
             return {
-                id: entity.id,
-                code: entity.code,
-                translations: entity.translations.map(translation => ({
+                id: group.id,
+                code: group.code,
+                translations: group.translations.map(translation => ({
                     id: translation.id,
                     languageCode: translation.languageCode,
                     name: translation.name,
                     customFields: (translation as any).customFields,
                 })),
                 options: [],
-                customFields: entity.customFields,
+                customFields: group.customFields,
             };
         },
packages/dashboard/src/app/routes/_authenticated/_product-option-groups/components/product-option-group-bulk-actions.tsx (1)

60-97: Optional: consolidate success/error toasts

You already surface per-item errors; consider passing a localized successMessage/errorMessage to RemoveFromChannelBulkAction for consistent UX.

packages/core/src/i18n/messages/ru.json (1)

92-99: Fill missing strings for new channel-aware flows.

Keys like product-option-group-already-assigned and related channel messages are empty. These surface in the new mutations and should be localized to avoid English fallbacks.

Would you like me to propose RU translations for the empty keys in this block? (Based on learnings)

packages/dashboard/src/app/routes/_authenticated/_products/components/option-group-search-input.tsx (2)

116-124: Add accessible labels to icon-only buttons.

Icon-only buttons need aria-labels for screen readers.

-<Button
+<Button
   type="button"
   variant="ghost"
   size="sm"
   onClick={() => handleRemoveGroup(groupIndex)}
+  aria-label="Remove option group"
 >
   <X className="h-4 w-4" />
</Button>

-<Button
+<Button
   type="button"
   variant="ghost"
   size="sm"
   className="h-4 w-4 p-0 ml-1 hover:bg-secondary-foreground/20"
   onClick={() => handleRemoveOption(groupIndex, optionIndex)}
+  aria-label="Remove option"
 >
   <X className="h-3 w-3" />
</Button>

Also applies to: 144-153


209-213: Copy tweak: button label mismatched action.

When no groups exist, the button opens “Add Option Group”. Label should say “Add First Option Group”.

- <Trans>Add First Option</Trans>
+ <Trans>Add First Option Group</Trans>
packages/core/src/i18n/messages/pt_BR.json (1)

154-156: Consistency: provide message for PRODUCT_OPTION_IN_USE_ERROR or drop placeholder.

Currently empty; this error is surfaced by new channel-aware flows.

Proposed PT-BR:

-"PRODUCT_OPTION_IN_USE_ERROR": "",
+"PRODUCT_OPTION_IN_USE_ERROR": "Não é possível remover a opção, pois ela é usada por {productVariantCount, plural, one {1 Variante de Produto} other {# Variantes de Produto}}",
packages/core/src/api/schema/admin-api/product-option-group.api.graphql (1)

18-19: Also validate non-empty ids for bulk deletions.

deleteProductOptionGroups/deleteProductOptions accept [ID!]! but may receive empty arrays. Reject early for clarity and consistent error handling.

Also applies to: 30-31

packages/dashboard/src/app/routes/_authenticated/_product-option-groups/product-option-groups.graphql.ts (1)

115-126: Surface structured error fields from union.

removeProductOptionGroupsFromChannel returns ProductOptionGroup | ProductOptionGroupInUseError. Capture errorCode and the in-use details to render actionable toasts.

 export const removeProductOptionGroupsFromChannelDocument = graphql(`
   mutation RemoveProductOptionGroupsFromChannel($input: RemoveProductOptionGroupsFromChannelInput!) {
     removeProductOptionGroupsFromChannel(input: $input) {
       ... on ProductOptionGroup {
         id
       }
-      ... on ErrorResult {
-        message
-      }
+      ... on ErrorResult {
+        errorCode
+        message
+      }
+      ... on ProductOptionGroupInUseError {
+        optionGroupCode
+        productCount
+        variantCount
+      }
     }
   }
 `);
packages/core/e2e/graphql/generated-e2e-admin-types.ts (4)

2737-2738: Channel assignment/removal surfaces — union result and inputs look right; add __typename in union queries.

Schema types are fine. For client robustness, include __typename when selecting union results (esp. removal results) to simplify narrowing.

Also applies to: 2947-2948, 3114-3116, 3222-3224, 3542-3544, 5511-5517


4577-4649: Filter/sort/list types and paginated queries — LGTM; prefer optionList in queries to avoid large payloads.

Types look consistent. In tests/queries returning many options, consider using optionList with pagination over options to limit response size.

Also applies to: 4673-4699, 5067-5068, 5241-5246


12154-12206: Generated TS types for new queries/mutations — OK; consider selecting __typename on unions.

Types align with schema. Recommend adding __typename to union selections in the corresponding source .graphql and regenerating.


36890-37166: Add __typename to union selections in e2e documents for safer narrowing.

Selecting __typename on unions prevents fragile ad‑hoc checks in tests/clients.

Apply to the source GraphQL documents (then regenerate); shown here as diff against the generated DocumentNodes for illustration:

@@
 export const AssignProductOptionGroupsToChannelDocument = {
@@
-                            selections: [
+                            selections: [
+                                { kind: 'Field', name: { kind: 'Name', value: '__typename' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'id' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'name' } },
                             ],
@@
 export const RemoveProductOptionGroupsFromChannelDocument = {
@@
-                            selections: [
+                            selections: [
                                 {
                                     kind: 'InlineFragment',
                                     typeCondition: {
                                         kind: 'NamedType',
                                         name: { kind: 'Name', value: 'ProductOptionGroup' },
                                     },
                                     selectionSet: {
                                         kind: 'SelectionSet',
-                                        selections: [
+                                        selections: [
+                                            { kind: 'Field', name: { kind: 'Name', value: '__typename' } },
                                             { kind: 'Field', name: { kind: 'Name', value: 'id' } },
                                             { kind: 'Field', name: { kind: 'Name', value: 'name' } },
                                         ],
                                     },
                                 },
                                 {
                                     kind: 'InlineFragment',
                                     typeCondition: {
                                         kind: 'NamedType',
                                         name: { kind: 'Name', value: 'ProductOptionGroupInUseError' },
                                     },
                                     selectionSet: {
                                         kind: 'SelectionSet',
-                                        selections: [
+                                        selections: [
+                                            { kind: 'Field', name: { kind: 'Name', value: '__typename' } },
                                             { kind: 'Field', name: { kind: 'Name', value: 'errorCode' } },
                                             { kind: 'Field', name: { kind: 'Name', value: 'message' } },
                                             { kind: 'Field', name: { kind: 'Name', value: 'optionGroupCode' } },
                                             { kind: 'Field', name: { kind: 'Name', value: 'productCount' } },
                                             { kind: 'Field', name: { kind: 'Name', value: 'variantCount' } },
                                         ],
                                     },
                                 },
                             ],
@@
 export const GetProductWithOptionGroupsDocument = {
@@
-                            selections: [
+                            selections: [
+                                { kind: 'Field', name: { kind: 'Name', value: '__typename' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'id' } },
                                 { kind: 'Field', name: { kind: 'Name', value: 'name' } },
                                 {
                                     kind: 'Field',
                                     name: { kind: 'Name', value: 'optionGroups' },
                                     selectionSet: {
                                         kind: 'SelectionSet',
-                                        selections: [
+                                        selections: [
+                                            { kind: 'Field', name: { kind: 'Name', value: '__typename' } },
                                             { kind: 'Field', name: { kind: 'Name', value: 'id' } },
                                             { kind: 'Field', name: { kind: 'Name', value: 'code' } },
                                             { kind: 'Field', name: { kind: 'Name', value: 'name' } },
                                         ],
                                     },
                                 },
                             ],
packages/core/src/service/services/product-option-group.service.ts (1)

292-301: Minor: unnecessary generic in Promise.all.

Promise.all(...) adds no type value here. Remove angle type.

-        await Promise.all<any>([
+        await Promise.all([
             ...groupsToAssign.map(async group => {
packages/core/src/api/resolvers/admin/product-option.resolver.ts (2)

112-119: Unify create() call style for clarity.

You load the group, then call create() with the ID, whereas batch create uses the group object. Prefer one style.

-        const productOptionGroup = await this.productOptionGroupService.findOne(
+        const productOptionGroup = await this.productOptionGroupService.findOne(
             ctx,
             input.productOptionGroupId,
         );
         if (!productOptionGroup) {
             throw new EntityNotFoundError('ProductOptionGroup', input.productOptionGroupId);
         }
-        return this.productOptionService.create(ctx, input.productOptionGroupId, input);
+        return this.productOptionService.create(ctx, productOptionGroup, input);

158-164: Remove ts-ignore; typing should be valid.

Avoid suppressing type checks here.

-        // @ts-ignore - is ok
-        return Promise.all(input.map(option => this.productOptionService.update(ctx, option)));
+        return Promise.all(input.map(option => this.productOptionService.update(ctx, option)));
packages/core/e2e/graphql/generated-e2e-shop-types.ts (2)

7219-7231: Remove duplicate total field in GET_ACTIVE_ORDER_WITH_PRICE_DATA query

The total field is selected twice in the query definition. Remove the duplicate at packages/core/e2e/graphql/shop-definitions.ts:365 to clean up the generated types.


2663-2663: Resolver correctly handles channel scoping and soft-delete filtering; @deprecated suggestion not yet implemented

Verification confirms the resolver at packages/core/src/api/resolvers/entity/product-option-group-entity.resolver.ts enforces channel scoping (via ctx.channelId in the service query builder) and filters soft-deleted options with options.filter(o => !o.deletedAt). However, the options field in the schema (packages/core/src/api/schema/common/product-option-group.type.graphql) is not marked @deprecated. Consider adding @deprecated(reason: "Use optionList for pagination support") to the schema to guide clients toward the new paginated field.

packages/admin-ui/src/lib/core/src/common/generated-types.ts (11)

890-891: Allow creating options with group creation.

Good addition. Consider wiring the Admin UI “Create group” flow to optionally include initial options.


1354-1355: Expose deprecation on CustomField interface.

Suggest surfacing “Deprecated” badges/tooltips in UI where custom fields are displayed.


2851-2852: Bulk createProductOptions mutation added.

Prefer this in UI when creating many options to reduce round-trips.


3093-3098: Bulk updateProductOptions mutation added.

Adopt for mass edits in Admin UI forms.


4910-4920: ProductOptionFilterParameter added.

Great for server-side filtering. Hook this up in list/search UIs to reduce payloads.


4930-4939: ProductOptionGroup.optionList + args introduced.

Prefer optionList over the raw options array to avoid N-size payloads and enable pagination. Plan UI migration.


5012-5038: ProductOptionList + list options/sort added.

All good; adopt in group detail pages for scalable option rendering.


5631-5637: Args for productOptionGroups/productOptions queries.

OK; wire pagination/filtering in UI list views.


5926-5932: RemoveProductOptionGroupFromChannelResult union + input.

Union enables per-item success/error; surface productCount/variantCount in UI dialogs. Based on learnings.


8167-8171: GetProductOptionGroups query uses options array.

Recommend switching to optionList { items { … } totalItems } to avoid loading all options for large groups; regenerate types after updating the .graphql source.


9606-9606: GetProductOptionGroupsDocument added.

Consider updating the source query to use optionList + pagination as above, then re-run codegen.

packages/common/src/generated-types.ts (1)

4835-4845: Optional: add channel-aware filters

Consider adding channelId (and possibly channelCode) to ProductOptionFilterParameter and ProductOptionGroupFilterParameter so SuperAdmins can query across channels explicitly. Current queries are channel-scoped, but explicit filters improve admin tooling and bulk ops.

Also applies to: 4866-4875, 4943-4954, 4956-4963

Comment on lines +2788 to +2789
/** Create one or more ProductOptions */
createProductOptions: Array<ProductOption>;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Bulk create/update/delete additions — check test coverage for force and cross‑channel constraints.

Please verify e2e tests cover:

  • delete with/without force
  • update/create blocked across channels
  • soft‑deleted options excluded during assignment.

Also applies to: 2858-2863, 3019-3019, 3024-3024, 3375-3388, 3724-3726


🏁 Script executed:

#!/bin/bash
# Quick scan for force semantics and cross-channel tests.
rg -nC3 '\bforce\b|assignProductOptionGroupsToChannel|removeProductOptionGroupsFromChannel|createProductOptions|updateProductOptions|deleteProductOption(Group|s)\b' packages/core/e2e

Length of output: 49805


🏁 Script executed:

#!/bin/bash
# Check for specific test cases for create/update/delete ProductOptions and soft-delete handling
rg -nA10 'describe|it\(' packages/core/e2e/product-option.e2e-spec.ts | head -150

Length of output: 6698


🏁 Script executed:

#!/bin/bash
# Search for soft-delete related tests
rg -nC5 'soft.*delete|deleted|softDelete' packages/core/e2e/product-option.e2e-spec.ts

Length of output: 1122


🏁 Script executed:

#!/bin/bash
# Look for createProductOptions and updateProductOptions test cases
rg -nB2 -A8 'createProductOptions|updateProductOptions' packages/core/e2e/product-option.e2e-spec.ts | grep -A8 'it\('

Length of output: 23



🏁 Script executed:

#!/bin/bash
# Search for plural forms and test structure around bulk operations
rg -n 'createProductOptions|updateProductOptions|deleteProductOptions' packages/core/e2e/product-option.e2e-spec.ts

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Get the full test describe blocks to see structure
sed -n '34,700p' packages/core/e2e/product-option.e2e-spec.ts | grep -E "^\s*(describe|it)\(" | head -40

Length of output: 1557


🏁 Script executed:

#!/bin/bash
# Check what mutations are actually defined in the test queries
rg -n 'CREATE_PRODUCT_OPTION|UPDATE_PRODUCT_OPTION|DELETE_PRODUCT_OPTION' packages/core/e2e/product-option.e2e-spec.ts | head -20

Length of output: 833



🏁 Script executed:

#!/bin/bash
# Search entire e2e directory for plural forms of these mutations
rg -r 'createProductOptions|updateProductOptions|deleteProductOptions' packages/core/e2e/ --type ts

Length of output: 732


🏁 Script executed:

#!/bin/bash
# Check if these mutations are tested anywhere in the codebase
rg -r 'createProductOptions|updateProductOptions|deleteProductOptions' packages/core/ --type ts -l | grep -i test

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look at the generated types to confirm these mutations exist and their signatures
sed -n '2788,2790p; 3023,3025p; 2862,2864p' packages/core/e2e/graphql/generated-e2e-admin-types.ts

Length of output: 485


Bulk mutation test coverage is missing — add e2e tests for createProductOptions, updateProductOptions, and deleteProductOptions mutations before production use.

The bulk mutations are defined in the GraphQL schema but have no corresponding e2e test cases in packages/core/e2e/product-option.e2e-spec.ts. Currently tested:

  • removeProductOptionGroupsFromChannel with force: true/false (line 592, 610 of product-option.e2e-spec.ts)
  • Channel cross-constraint validation with error handling
  • Soft-deleted options excluded from query results (line 268)

Not tested:

  • createProductOptions bulk operation with cross-channel constraints
  • updateProductOptions bulk operation across channels
  • deleteProductOptions with force semantics
  • Soft-deleted options excluded during assignment/bulk operations

Recommend adding dedicated test cases for the plural mutations before merging.

Comment on lines +1279 to +1299
it('addOptionGroupToProduct allows sharing option groups between products', async () => {
// First verify the option group is already on product T_1
const { product: laptop } = await adminClient.query<
Codegen.GetProductWithVariantsQuery,
Codegen.GetProductWithVariantsQueryVariables
>(GET_PRODUCT_WITH_VARIANTS, { id: 'T_1' });

expect(laptop?.optionGroups.some(og => og.id === 'T_1')).toBe(true);

// Now add the same option group to another product
const result = await adminClient.query<
Codegen.AddOptionGroupToProductMutation,
Codegen.AddOptionGroupToProductMutationVariables
>(ADD_OPTION_GROUP_TO_PRODUCT, {
optionGroupId: 'T_1',
productId: 'T_2',
});

expect(result.addOptionGroupToProduct.optionGroups.length).toBe(1);
expect(result.addOptionGroupToProduct.optionGroups[0].id).toBe('T_1');
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix the test expectation: product T_2 likely has existing option groups.

The pipeline failure indicates this test expects 1 option group but receives 2. The test adds option group T_1 to product T_2, but product T_2 appears to already have one option group. The assertion at line 1297 should verify that:

  1. The optionGroups array includes the newly added group T_1
  2. The total count matches the expected number (which is 2, not 1)

Apply this diff to fix the test:

-            expect(result.addOptionGroupToProduct.optionGroups.length).toBe(1);
-            expect(result.addOptionGroupToProduct.optionGroups[0].id).toBe('T_1');
+            expect(result.addOptionGroupToProduct.optionGroups.length).toBe(2);
+            expect(result.addOptionGroupToProduct.optionGroups.some(og => og.id === 'T_1')).toBe(true);

Alternatively, verify what option groups product T_2 has initially and adjust the expectation accordingly.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('addOptionGroupToProduct allows sharing option groups between products', async () => {
// First verify the option group is already on product T_1
const { product: laptop } = await adminClient.query<
Codegen.GetProductWithVariantsQuery,
Codegen.GetProductWithVariantsQueryVariables
>(GET_PRODUCT_WITH_VARIANTS, { id: 'T_1' });
expect(laptop?.optionGroups.some(og => og.id === 'T_1')).toBe(true);
// Now add the same option group to another product
const result = await adminClient.query<
Codegen.AddOptionGroupToProductMutation,
Codegen.AddOptionGroupToProductMutationVariables
>(ADD_OPTION_GROUP_TO_PRODUCT, {
optionGroupId: 'T_1',
productId: 'T_2',
});
expect(result.addOptionGroupToProduct.optionGroups.length).toBe(1);
expect(result.addOptionGroupToProduct.optionGroups[0].id).toBe('T_1');
});
it('addOptionGroupToProduct allows sharing option groups between products', async () => {
// First verify the option group is already on product T_1
const { product: laptop } = await adminClient.query<
Codegen.GetProductWithVariantsQuery,
Codegen.GetProductWithVariantsQueryVariables
>(GET_PRODUCT_WITH_VARIANTS, { id: 'T_1' });
expect(laptop?.optionGroups.some(og => og.id === 'T_1')).toBe(true);
// Now add the same option group to another product
const result = await adminClient.query<
Codegen.AddOptionGroupToProductMutation,
Codegen.AddOptionGroupToProductMutationVariables
>(ADD_OPTION_GROUP_TO_PRODUCT, {
optionGroupId: 'T_1',
productId: 'T_2',
});
expect(result.addOptionGroupToProduct.optionGroups.length).toBe(2);
expect(result.addOptionGroupToProduct.optionGroups.some(og => og.id === 'T_1')).toBe(true);
});
🤖 Prompt for AI Agents
In packages/core/e2e/product.e2e-spec.ts around lines 1279 to 1299, the test
currently asserts that product T_2 has exactly 1 option group after adding T_1,
but T_2 already has an existing option group so the expected length should be 2
(or, more robustly, computed as initialCount + 1). Update the assertions to (a)
confirm that the optionGroups array includes an entry with id 'T_1' and (b)
assert the total count equals the prior count plus one (or explicitly 2 if you
know T_2 starts with one), e.g., fetch product T_2's optionGroups before the
mutation to determine initialCount and then assert
result.addOptionGroupToProduct.optionGroups.length === initialCount + 1.

Comment on lines +122 to +141
@Transaction()
@Mutation()
@Allow(Permission.CreateCatalog, Permission.CreateProduct)
async createProductOptions(
@Ctx() ctx: RequestContext,
@Args() args: MutationCreateProductOptionsArgs,
): Promise<Array<Translated<ProductOption>>> {
const { input } = args;
const productOptionGroupId = input[0]?.productOptionGroupId;
const productOptionGroup = await this.productOptionGroupService.findOne(ctx, productOptionGroupId);
if (!productOptionGroup) {
throw new EntityNotFoundError('ProductOptionGroup', productOptionGroupId);
}
const productOptions: Array<Translated<ProductOption>> = [];
for (const productOption of input) {
const res = await this.productOptionService.create(ctx, productOptionGroup, productOption);
productOptions.push(res);
}
return productOptions;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate batch input and enforce single target group.

This assumes all inputs share the same productOptionGroupId and doesn’t guard against an empty array.

     async createProductOptions(
         @Ctx() ctx: RequestContext,
         @Args() args: MutationCreateProductOptionsArgs,
     ): Promise<Array<Translated<ProductOption>>> {
-        const { input } = args;
-        const productOptionGroupId = input[0]?.productOptionGroupId;
+        const { input } = args;
+        if (!input.length) {
+            throw new EntityNotFoundError('ProductOptionGroup', ''); // or a UserInputError if preferred
+        }
+        const productOptionGroupId = input[0].productOptionGroupId;
+        if (!input.every(i => i.productOptionGroupId === productOptionGroupId)) {
+            // If mixed groups should be supported, group by ID and process per group instead.
+            throw new EntityNotFoundError('ProductOptionGroup', 'mismatched-group-ids');
+        }
         const productOptionGroup = await this.productOptionGroupService.findOne(ctx, productOptionGroupId);
         if (!productOptionGroup) {
             throw new EntityNotFoundError('ProductOptionGroup', productOptionGroupId);
         }
         const productOptions: Array<Translated<ProductOption>> = [];
         for (const productOption of input) {
             const res = await this.productOptionService.create(ctx, productOptionGroup, productOption);
             productOptions.push(res);
         }
         return productOptions;
     }

If you prefer proper input errors, replace EntityNotFoundError with UserInputError and import it.

🤖 Prompt for AI Agents
In packages/core/src/api/resolvers/admin/product-option.resolver.ts around lines
122 to 141, validate the batch input before calling services: first throw a
UserInputError if args.input is empty; then collect productOptionGroupId values
from all input items and ensure they are all identical (if not, throw a
UserInputError indicating mixed target groups); use the single
productOptionGroupId to fetch the group and only then proceed (if fetch returns
null, keep throwing EntityNotFoundError or switch to UserInputError per
preference); this enforces a single target group and prevents empty/mismatched
batches before creating options.

Comment on lines +52 to +60
@ResolveField()
async optionList(
@Ctx() ctx: RequestContext,
@Parent() optionGroup: ProductOptionGroup,
@Args() args: { options: ProductOptionListOptions },
@Relations({ entity: ProductOption }) relations: RelationPaths<ProductOption>,
): Promise<PaginatedList<ProductOption>> {
return this.productOptionService.findByGroupIdList(ctx, optionGroup.id, args.options, relations);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Authorize optionList and align return type

  • Missing @Allow makes access inconsistent with options field.
  • Return type should reflect Translated returned by the service.
-    @ResolveField()
-    async optionList(
+    @ResolveField()
+    @Allow(Permission.ReadCatalog, Permission.Public, Permission.ReadProduct)
+    async optionList(
         @Ctx() ctx: RequestContext,
         @Parent() optionGroup: ProductOptionGroup,
         @Args() args: { options: ProductOptionListOptions },
         @Relations({ entity: ProductOption }) relations: RelationPaths<ProductOption>,
-    ): Promise<PaginatedList<ProductOption>> {
+    ): Promise<PaginatedList<Translated<ProductOption>>> {
         return this.productOptionService.findByGroupIdList(ctx, optionGroup.id, args.options, relations);
     }
🤖 Prompt for AI Agents
In
packages/core/src/api/resolvers/entity/product-option-group-entity.resolver.ts
around lines 52–60, the optionList resolver is missing the @Allow decorator
(making access inconsistent with the options field) and its return type does not
reflect the Translated<ProductOption> returned by the service; add the same
@Allow(...) used on the options field (e.g. @Allow(Permission.ReadCatalog))
immediately above the method, change the signature return type to
Promise<PaginatedList<Translated<ProductOption>>>, and add any necessary imports
(Translated and Permission) so the code compiles.

Comment on lines +118 to 121
beforeSave: async g => {
g.channels = unique([this.defaultChannel, this.importCtx.channel], 'id');
},
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Possible runtime error: unique([...]) with undefined channel.

When initialize() is called without a channel, this.importCtx.channel is undefined. unique([this.defaultChannel, undefined], 'id') can throw or later .map(c => c.id) can NPE.

Apply a defensive filter:

-            beforeSave: async g => {
-                g.channels = unique([this.defaultChannel, this.importCtx.channel], 'id');
-            },
+            beforeSave: async g => {
+                g.channels = unique(
+                    [this.defaultChannel, this.importCtx.channel].filter(
+                        (c): c is Channel => !!c,
+                    ),
+                    'id',
+                );
+            },

Also apply the same guard anywhere else this pattern occurs in this file (e.g., product/variant channel assignment).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
beforeSave: async g => {
g.channels = unique([this.defaultChannel, this.importCtx.channel], 'id');
},
});
beforeSave: async g => {
g.channels = unique(
[this.defaultChannel, this.importCtx.channel].filter(
(c): c is Channel => !!c,
),
'id',
);
},

Comment on lines +35 to +36
return [{ path: '/product-option-groups', label: 'Option Groups' }, isNew ? <Trans>New Option Group</Trans> : entity?.name];
},
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Breadcrumb: add keys and localize “Option Groups”

Fix missing key in iterable and localize label.

-        breadcrumb(isNew, entity) {
-            return [{ path: '/product-option-groups', label: 'Option Groups' }, isNew ? <Trans>New Option Group</Trans> : entity?.name];
+        breadcrumb(isNew, entity) {
+            return [
+                { path: '/product-option-groups', label: <Trans>Option Groups</Trans> },
+                isNew ? <Trans key="new-crumb">New Option Group</Trans> : <span key="detail-crumb">{entity?.name}</span>,
+            ];
         },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return [{ path: '/product-option-groups', label: 'Option Groups' }, isNew ? <Trans>New Option Group</Trans> : entity?.name];
},
breadcrumb(isNew, entity) {
return [
{ path: '/product-option-groups', label: <Trans>Option Groups</Trans> },
isNew ? <Trans key="new-crumb">New Option Group</Trans> : <span key="detail-crumb">{entity?.name}</span>,
];
},
🧰 Tools
🪛 Biome (2.1.2)

[error] 35-35: Missing key property for this element in iterable.

The order of the items may change, and having a key can help React identify which item was moved.
Check the React documentation.

(lint/correctness/useJsxKeyInIterable)

🤖 Prompt for AI Agents
In
packages/dashboard/src/app/routes/_authenticated/_product-option-groups/product-option-groups_.$id.tsx
around lines 35-36, the breadcrumb array returns an unlocalized label and a JSX
element without a key; change the first breadcrumb item to use a localized label
(e.g. use <Trans>Option Groups</Trans> or your i18n helper) and ensure any JSX
element in the returned array has a stable key (add key="new" to the <Trans>New
Option Group</Trans> element), so the array contains localized text and all JSX
entries include keys.

loader: () => ({ breadcrumb: () => <Trans>Option Groups</Trans> }),
});

function FacetListPage() {
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Rename function to match its purpose.

The function is named FacetListPage but implements the product option groups list page. This is misleading and will cause confusion during maintenance.

Apply this diff:

-function FacetListPage() {
+function ProductOptionGroupListPage() {
     return (
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function FacetListPage() {
function ProductOptionGroupListPage() {
🤖 Prompt for AI Agents
In
packages/dashboard/src/app/routes/_authenticated/_product-option-groups/product-option-groups.tsx
around line 26, the function is incorrectly named FacetListPage; rename it to
ProductOptionGroupsPage (or another name matching the file/purpose), update its
declaration and any corresponding export, and update all local references/usages
(default export, route/component imports, tests, and any JSX/props that
reference the old name) to use the new name so the symbol and file purpose are
consistent.

Comment on lines 248 to 250
} catch (error) {
console.error('Error creating variants:', error);
// Handle error (show toast notification, etc.)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Implement proper error handling.

The catch block is empty, silently swallowing all errors. Users won't be notified of failures, and partial state changes (e.g., some groups created but variant creation failed) will remain without cleanup.

At minimum, log the error and show user feedback:

         } catch (error) {
-            // Handle error (show toast notification, etc.)
+            console.error('Failed to create variants:', error);
+            // TODO: Show toast notification with error message
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
console.error('Error creating variants:', error);
// Handle error (show toast notification, etc.)
}
} catch (error) {
console.error('Failed to create variants:', error);
// TODO: Show toast notification with error message
}
🤖 Prompt for AI Agents
In
packages/dashboard/src/app/routes/_authenticated/_products/components/create-product-variants-dialog.tsx
around lines 248-250, the catch block is empty and silently swallows errors;
update it to log the caught error (console.error or centralized logger), surface
a user-friendly error notification/toast (using the app's toast/notification
utility) so users know the operation failed, and undo or roll back any partial
state changes (revert optimistic updates, remove any groups created before the
failure or mark creation as failed) and re-enable UI controls; if appropriate
return or rethrow the error after handling so callers can react.

Comment on lines +19 to +26
interface OptionGroupSearchInputProps {
value: SelectedOptionGroup[];
onChange: (groups: SelectedOptionGroup[]) => void;
disabled?: boolean;
}

export function OptionGroupSearchInput({ value, onChange, disabled }: OptionGroupSearchInputProps) {
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Props should be Readonly per guidelines.

Type props as Readonly<...> to prevent accidental mutation and align with repo rules.

-interface OptionGroupSearchInputProps {
+interface OptionGroupSearchInputProps extends Readonly<{
     value: SelectedOptionGroup[];
     onChange: (groups: SelectedOptionGroup[]) => void;
     disabled?: boolean;
-}
+}> {}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
interface OptionGroupSearchInputProps {
value: SelectedOptionGroup[];
onChange: (groups: SelectedOptionGroup[]) => void;
disabled?: boolean;
}
export function OptionGroupSearchInput({ value, onChange, disabled }: OptionGroupSearchInputProps) {
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
interface OptionGroupSearchInputProps extends Readonly<{
value: SelectedOptionGroup[];
onChange: (groups: SelectedOptionGroup[]) => void;
disabled?: boolean;
}> {}
export function OptionGroupSearchInput({ value, onChange, disabled }: OptionGroupSearchInputProps) {
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
🤖 Prompt for AI Agents
In
packages/dashboard/src/app/routes/_authenticated/_products/components/option-group-search-input.tsx
around lines 19 to 26, the component props are currently mutable; update the
component to accept props typed as Readonly<OptionGroupSearchInputProps> (or
redefine the interface fields as readonly) so the props cannot be mutated.
Modify the function signature to take a Readonly<OptionGroupSearchInputProps>
and ensure any internal state updates use local copies rather than mutating
props directly.

Comment on lines +59 to +67
const handleRemoveGroup = (index: number) => {
const newGroups = [...value];
newGroups.splice(index, 1);
onChange(newGroups);
// Update expanded state
const newExpanded = new Set(expandedGroups);
newExpanded.delete(index);
setExpandedGroups(newExpanded);
};
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Expanded-group index drift after deletion.

Using indices as IDs causes expansion state to misalign when a group is removed (indices shift). Rebase the Set after splice.

   const handleRemoveGroup = (index: number) => {
     const newGroups = [...value];
     newGroups.splice(index, 1);
     onChange(newGroups);
-    // Update expanded state
-    const newExpanded = new Set(expandedGroups);
-    newExpanded.delete(index);
-    setExpandedGroups(newExpanded);
+    // Reindex expanded state to match shifted indices
+    const reindexed = new Set<number>();
+    expandedGroups.forEach(i => {
+      if (i < index) reindexed.add(i);
+      else if (i > index) reindexed.add(i - 1);
+      // i === index is removed
+    });
+    setExpandedGroups(reindexed);
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleRemoveGroup = (index: number) => {
const newGroups = [...value];
newGroups.splice(index, 1);
onChange(newGroups);
// Update expanded state
const newExpanded = new Set(expandedGroups);
newExpanded.delete(index);
setExpandedGroups(newExpanded);
};
const handleRemoveGroup = (index: number) => {
const newGroups = [...value];
newGroups.splice(index, 1);
onChange(newGroups);
// Reindex expanded state to match shifted indices
const reindexed = new Set<number>();
expandedGroups.forEach(i => {
if (i < index) reindexed.add(i);
else if (i > index) reindexed.add(i - 1);
// i === index is removed
});
setExpandedGroups(reindexed);
};

@dlhck
Copy link
Collaborator

dlhck commented Oct 22, 2025

What are the breaking changes?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants