feat: add settings module (#13175)
* feat: add view_configurations feature flag - Add feature flag provider and hooks to admin dashboard - Add backend API endpoint for feature flags - Create view_configurations feature flag (disabled by default) - Update order list table to use legacy version when flag is disabled - Can be enabled with MEDUSA_FF_VIEW_CONFIGURATIONS=true env var * fix: naming * fix: feature flags unauthenticated * fix: add test * feat: add settings module * fix: deps * fix: cleanup * fix: add more tetsts * fix: rm changelog * fix: deps * fix: add settings module to default modules list * fix: pr comments * fix: deps,build * fix: alias * fix: tests * fix: update snapshots
This commit is contained in:
@@ -219,6 +219,17 @@ export const ModulesDefinition: {
|
||||
scope: MODULE_SCOPE.INTERNAL,
|
||||
},
|
||||
},
|
||||
[Modules.SETTINGS]: {
|
||||
key: Modules.SETTINGS,
|
||||
defaultPackage: false,
|
||||
label: upperCaseFirst(Modules.SETTINGS),
|
||||
isRequired: false,
|
||||
isQueryable: true,
|
||||
dependencies: [ContainerRegistrationKeys.LOGGER],
|
||||
defaultModuleDeclaration: {
|
||||
scope: MODULE_SCOPE.INTERNAL,
|
||||
},
|
||||
},
|
||||
[Modules.STORE]: {
|
||||
key: Modules.STORE,
|
||||
defaultPackage: false,
|
||||
|
||||
@@ -26,6 +26,7 @@ export * as PromotionTypes from "./promotion"
|
||||
export * as RegionTypes from "./region"
|
||||
export * as SalesChannelTypes from "./sales-channel"
|
||||
export * as SearchTypes from "./search"
|
||||
export * as SettingsTypes from "./settings"
|
||||
export * as StockLocationTypes from "./stock-location"
|
||||
export * as StoreTypes from "./store"
|
||||
export * as TaxTypes from "./tax"
|
||||
|
||||
@@ -34,6 +34,7 @@ export * from "./promotion"
|
||||
export * from "./region"
|
||||
export * from "./sales-channel"
|
||||
export * from "./search"
|
||||
export * from "./settings"
|
||||
export * from "./shared-context"
|
||||
export * from "./stock-location"
|
||||
export * from "./store"
|
||||
|
||||
161
packages/core/types/src/settings/common.ts
Normal file
161
packages/core/types/src/settings/common.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { BaseFilterable } from "../dal"
|
||||
|
||||
/**
|
||||
* The view configuration data model.
|
||||
*/
|
||||
export interface ViewConfigurationDTO {
|
||||
/**
|
||||
* The ID of the configuration.
|
||||
*/
|
||||
id: string
|
||||
|
||||
/**
|
||||
* The entity this configuration is for.
|
||||
*/
|
||||
entity: string
|
||||
|
||||
/**
|
||||
* The name of the configuration.
|
||||
*/
|
||||
name: string
|
||||
|
||||
/**
|
||||
* The user ID this configuration belongs to.
|
||||
*/
|
||||
user_id: string | null
|
||||
|
||||
/**
|
||||
* Whether this is a system default configuration.
|
||||
*/
|
||||
is_system_default: boolean
|
||||
|
||||
/**
|
||||
* The configuration data.
|
||||
*/
|
||||
configuration: {
|
||||
/**
|
||||
* The visible columns.
|
||||
*/
|
||||
visible_columns: string[]
|
||||
|
||||
/**
|
||||
* The column order.
|
||||
*/
|
||||
column_order: string[]
|
||||
|
||||
/**
|
||||
* The column widths.
|
||||
*/
|
||||
column_widths?: Record<string, number>
|
||||
|
||||
/**
|
||||
* The filters to apply.
|
||||
*/
|
||||
filters?: Record<string, any>
|
||||
|
||||
/**
|
||||
* The sorting configuration.
|
||||
*/
|
||||
sorting?: { id: string; desc: boolean } | null
|
||||
|
||||
/**
|
||||
* The search string.
|
||||
*/
|
||||
search?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The date the configuration was created.
|
||||
*/
|
||||
created_at: Date
|
||||
|
||||
/**
|
||||
* The date the configuration was last updated.
|
||||
*/
|
||||
updated_at: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* The user preference data model.
|
||||
*/
|
||||
export interface UserPreferenceDTO {
|
||||
/**
|
||||
* The ID of the preference.
|
||||
*/
|
||||
id: string
|
||||
|
||||
/**
|
||||
* The user ID.
|
||||
*/
|
||||
user_id: string
|
||||
|
||||
/**
|
||||
* The preference key.
|
||||
*/
|
||||
key: string
|
||||
|
||||
/**
|
||||
* The preference value.
|
||||
*/
|
||||
value: any
|
||||
|
||||
/**
|
||||
* The date the preference was created.
|
||||
*/
|
||||
created_at: Date
|
||||
|
||||
/**
|
||||
* The date the preference was last updated.
|
||||
*/
|
||||
updated_at: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* The filters to apply on the retrieved view configurations.
|
||||
*/
|
||||
export interface FilterableViewConfigurationProps extends BaseFilterable<ViewConfigurationDTO> {
|
||||
/**
|
||||
* The IDs to filter by.
|
||||
*/
|
||||
id?: string | string[]
|
||||
|
||||
/**
|
||||
* Filter by entity name.
|
||||
*/
|
||||
entity?: string | string[]
|
||||
|
||||
/**
|
||||
* Filter by user ID.
|
||||
*/
|
||||
user_id?: string | string[] | null
|
||||
|
||||
/**
|
||||
* Filter by system default flag.
|
||||
*/
|
||||
is_system_default?: boolean
|
||||
|
||||
/**
|
||||
* Filter by name.
|
||||
*/
|
||||
name?: string | string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The filters to apply on the retrieved user preferences.
|
||||
*/
|
||||
export interface FilterableUserPreferenceProps extends BaseFilterable<UserPreferenceDTO> {
|
||||
/**
|
||||
* The IDs to filter by.
|
||||
*/
|
||||
id?: string | string[]
|
||||
|
||||
/**
|
||||
* Filter by user ID.
|
||||
*/
|
||||
user_id?: string | string[]
|
||||
|
||||
/**
|
||||
* Filter by preference key.
|
||||
*/
|
||||
key?: string | string[]
|
||||
}
|
||||
3
packages/core/types/src/settings/index.ts
Normal file
3
packages/core/types/src/settings/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./common"
|
||||
export * from "./mutations"
|
||||
export * from "./service"
|
||||
134
packages/core/types/src/settings/mutations.ts
Normal file
134
packages/core/types/src/settings/mutations.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* The view configuration to be created.
|
||||
*/
|
||||
export interface CreateViewConfigurationDTO {
|
||||
/**
|
||||
* The entity this configuration is for.
|
||||
*/
|
||||
entity: string
|
||||
|
||||
/**
|
||||
* The name of the configuration.
|
||||
*/
|
||||
name: string
|
||||
|
||||
/**
|
||||
* The user ID this configuration belongs to.
|
||||
*/
|
||||
user_id?: string
|
||||
|
||||
/**
|
||||
* Whether this is a system default configuration.
|
||||
*/
|
||||
is_system_default?: boolean
|
||||
|
||||
/**
|
||||
* The configuration data.
|
||||
*/
|
||||
configuration: {
|
||||
/**
|
||||
* The visible columns.
|
||||
*/
|
||||
visible_columns: string[]
|
||||
|
||||
/**
|
||||
* The column order.
|
||||
*/
|
||||
column_order: string[]
|
||||
|
||||
/**
|
||||
* The column widths.
|
||||
*/
|
||||
column_widths?: Record<string, number>
|
||||
|
||||
/**
|
||||
* The filters to apply.
|
||||
*/
|
||||
filters?: Record<string, any>
|
||||
|
||||
/**
|
||||
* The sorting configuration.
|
||||
*/
|
||||
sorting?: { id: string; desc: boolean } | null
|
||||
|
||||
/**
|
||||
* The search string.
|
||||
*/
|
||||
search?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The attributes to update in the view configuration.
|
||||
*/
|
||||
export interface UpdateViewConfigurationDTO {
|
||||
/**
|
||||
* The name of the configuration.
|
||||
*/
|
||||
name?: string
|
||||
|
||||
/**
|
||||
* The configuration data.
|
||||
*/
|
||||
configuration?: {
|
||||
/**
|
||||
* The visible columns.
|
||||
*/
|
||||
visible_columns?: string[]
|
||||
|
||||
/**
|
||||
* The column order.
|
||||
*/
|
||||
column_order?: string[]
|
||||
|
||||
/**
|
||||
* The column widths.
|
||||
*/
|
||||
column_widths?: Record<string, number>
|
||||
|
||||
/**
|
||||
* The filters to apply.
|
||||
*/
|
||||
filters?: Record<string, any>
|
||||
|
||||
/**
|
||||
* The sorting configuration.
|
||||
*/
|
||||
sorting?: { id: string; desc: boolean } | null
|
||||
|
||||
/**
|
||||
* The search string.
|
||||
*/
|
||||
search?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The user preference to be created.
|
||||
*/
|
||||
export interface CreateUserPreferenceDTO {
|
||||
/**
|
||||
* The user ID.
|
||||
*/
|
||||
user_id: string
|
||||
|
||||
/**
|
||||
* The preference key.
|
||||
*/
|
||||
key: string
|
||||
|
||||
/**
|
||||
* The preference value.
|
||||
*/
|
||||
value: any
|
||||
}
|
||||
|
||||
/**
|
||||
* The attributes to update in the user preference.
|
||||
*/
|
||||
export interface UpdateUserPreferenceDTO {
|
||||
/**
|
||||
* The preference value.
|
||||
*/
|
||||
value: any
|
||||
}
|
||||
117
packages/core/types/src/settings/service.ts
Normal file
117
packages/core/types/src/settings/service.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Context } from "../shared-context"
|
||||
import { FindConfig } from "../common"
|
||||
import { IModuleService } from "../modules-sdk"
|
||||
import {
|
||||
ViewConfigurationDTO,
|
||||
UserPreferenceDTO,
|
||||
FilterableViewConfigurationProps,
|
||||
FilterableUserPreferenceProps,
|
||||
} from "./common"
|
||||
import {
|
||||
CreateViewConfigurationDTO,
|
||||
UpdateViewConfigurationDTO,
|
||||
} from "./mutations"
|
||||
|
||||
export interface ISettingsModuleService extends IModuleService {
|
||||
// View Configuration methods
|
||||
retrieveViewConfiguration(
|
||||
id: string,
|
||||
config?: FindConfig<ViewConfigurationDTO>,
|
||||
sharedContext?: Context
|
||||
): Promise<ViewConfigurationDTO>
|
||||
|
||||
listViewConfigurations(
|
||||
filters?: FilterableViewConfigurationProps,
|
||||
config?: FindConfig<ViewConfigurationDTO>,
|
||||
sharedContext?: Context
|
||||
): Promise<ViewConfigurationDTO[]>
|
||||
|
||||
listAndCountViewConfigurations(
|
||||
filters?: FilterableViewConfigurationProps,
|
||||
config?: FindConfig<ViewConfigurationDTO>,
|
||||
sharedContext?: Context
|
||||
): Promise<[ViewConfigurationDTO[], number]>
|
||||
|
||||
createViewConfigurations(
|
||||
data: CreateViewConfigurationDTO[],
|
||||
sharedContext?: Context
|
||||
): Promise<ViewConfigurationDTO[]>
|
||||
|
||||
createViewConfigurations(
|
||||
data: CreateViewConfigurationDTO,
|
||||
sharedContext?: Context
|
||||
): Promise<ViewConfigurationDTO>
|
||||
|
||||
updateViewConfigurations(
|
||||
idOrSelector: string,
|
||||
data: UpdateViewConfigurationDTO,
|
||||
sharedContext?: Context
|
||||
): Promise<ViewConfigurationDTO>
|
||||
|
||||
updateViewConfigurations(
|
||||
idOrSelector: FilterableViewConfigurationProps,
|
||||
data: UpdateViewConfigurationDTO,
|
||||
sharedContext?: Context
|
||||
): Promise<ViewConfigurationDTO[]>
|
||||
|
||||
deleteViewConfigurations(
|
||||
ids: string | string[],
|
||||
sharedContext?: Context
|
||||
): Promise<void>
|
||||
|
||||
// User Preference methods
|
||||
retrieveUserPreference(
|
||||
id: string,
|
||||
config?: FindConfig<UserPreferenceDTO>,
|
||||
sharedContext?: Context
|
||||
): Promise<UserPreferenceDTO>
|
||||
|
||||
listUserPreferences(
|
||||
filters?: FilterableUserPreferenceProps,
|
||||
config?: FindConfig<UserPreferenceDTO>,
|
||||
sharedContext?: Context
|
||||
): Promise<UserPreferenceDTO[]>
|
||||
|
||||
getUserPreference(
|
||||
userId: string,
|
||||
key: string,
|
||||
sharedContext?: Context
|
||||
): Promise<UserPreferenceDTO | null>
|
||||
|
||||
setUserPreference(
|
||||
userId: string,
|
||||
key: string,
|
||||
value: any,
|
||||
sharedContext?: Context
|
||||
): Promise<UserPreferenceDTO>
|
||||
|
||||
deleteUserPreferences(
|
||||
ids: string | string[],
|
||||
sharedContext?: Context
|
||||
): Promise<void>
|
||||
|
||||
// Helper methods
|
||||
getActiveViewConfiguration(
|
||||
entity: string,
|
||||
userId: string,
|
||||
sharedContext?: Context
|
||||
): Promise<ViewConfigurationDTO | null>
|
||||
|
||||
setActiveViewConfiguration(
|
||||
entity: string,
|
||||
userId: string,
|
||||
viewConfigurationId: string,
|
||||
sharedContext?: Context
|
||||
): Promise<void>
|
||||
|
||||
getSystemDefaultViewConfiguration(
|
||||
entity: string,
|
||||
sharedContext?: Context
|
||||
): Promise<ViewConfigurationDTO | null>
|
||||
|
||||
clearActiveViewConfiguration(
|
||||
entity: string,
|
||||
userId: string,
|
||||
sharedContext?: Context
|
||||
): Promise<void>
|
||||
}
|
||||
@@ -106,6 +106,9 @@ describe("defineConfig", function () {
|
||||
"sales_channel": {
|
||||
"resolve": "@medusajs/medusa/sales-channel",
|
||||
},
|
||||
"settings": {
|
||||
"resolve": "@medusajs/medusa/settings",
|
||||
},
|
||||
"stock_location": {
|
||||
"resolve": "@medusajs/medusa/stock-location",
|
||||
},
|
||||
@@ -270,6 +273,9 @@ describe("defineConfig", function () {
|
||||
"sales_channel": {
|
||||
"resolve": "@medusajs/medusa/sales-channel",
|
||||
},
|
||||
"settings": {
|
||||
"resolve": "@medusajs/medusa/settings",
|
||||
},
|
||||
"stock_location": {
|
||||
"resolve": "@medusajs/medusa/stock-location",
|
||||
},
|
||||
@@ -442,6 +448,9 @@ describe("defineConfig", function () {
|
||||
"sales_channel": {
|
||||
"resolve": "@medusajs/medusa/sales-channel",
|
||||
},
|
||||
"settings": {
|
||||
"resolve": "@medusajs/medusa/settings",
|
||||
},
|
||||
"stock_location": {
|
||||
"resolve": "@medusajs/medusa/stock-location",
|
||||
},
|
||||
@@ -615,6 +624,9 @@ describe("defineConfig", function () {
|
||||
"sales_channel": {
|
||||
"resolve": "@medusajs/medusa/sales-channel",
|
||||
},
|
||||
"settings": {
|
||||
"resolve": "@medusajs/medusa/settings",
|
||||
},
|
||||
"stock_location": {
|
||||
"resolve": "@medusajs/medusa/stock-location",
|
||||
},
|
||||
@@ -776,6 +788,9 @@ describe("defineConfig", function () {
|
||||
"sales_channel": {
|
||||
"resolve": "@medusajs/medusa/sales-channel",
|
||||
},
|
||||
"settings": {
|
||||
"resolve": "@medusajs/medusa/settings",
|
||||
},
|
||||
"stock_location": {
|
||||
"resolve": "@medusajs/medusa/stock-location",
|
||||
},
|
||||
@@ -940,6 +955,9 @@ describe("defineConfig", function () {
|
||||
"sales_channel": {
|
||||
"resolve": "@medusajs/medusa/sales-channel",
|
||||
},
|
||||
"settings": {
|
||||
"resolve": "@medusajs/medusa/settings",
|
||||
},
|
||||
"stock_location": {
|
||||
"resolve": "@medusajs/medusa/stock-location",
|
||||
},
|
||||
@@ -1132,6 +1150,9 @@ describe("defineConfig", function () {
|
||||
"sales_channel": {
|
||||
"resolve": "@medusajs/medusa/sales-channel",
|
||||
},
|
||||
"settings": {
|
||||
"resolve": "@medusajs/medusa/settings",
|
||||
},
|
||||
"stock_location": {
|
||||
"resolve": "@medusajs/medusa/stock-location",
|
||||
},
|
||||
@@ -1331,6 +1352,9 @@ describe("defineConfig", function () {
|
||||
"sales_channel": {
|
||||
"resolve": "@medusajs/medusa/sales-channel",
|
||||
},
|
||||
"settings": {
|
||||
"resolve": "@medusajs/medusa/settings",
|
||||
},
|
||||
"stock_location": {
|
||||
"resolve": "@medusajs/medusa/stock-location",
|
||||
},
|
||||
@@ -1546,6 +1570,9 @@ describe("defineConfig", function () {
|
||||
"sales_channel": {
|
||||
"resolve": "@medusajs/medusa/sales-channel",
|
||||
},
|
||||
"settings": {
|
||||
"resolve": "@medusajs/medusa/settings",
|
||||
},
|
||||
"stock_location": {
|
||||
"resolve": "@medusajs/medusa/stock-location",
|
||||
},
|
||||
@@ -1733,6 +1760,9 @@ describe("defineConfig", function () {
|
||||
"sales_channel": {
|
||||
"resolve": "@medusajs/medusa/sales-channel",
|
||||
},
|
||||
"settings": {
|
||||
"resolve": "@medusajs/medusa/settings",
|
||||
},
|
||||
"stock_location": {
|
||||
"resolve": "@medusajs/medusa/stock-location",
|
||||
},
|
||||
|
||||
@@ -152,6 +152,7 @@ function resolveModules(
|
||||
{ resolve: MODULE_PACKAGE_NAMES[Modules.CURRENCY] },
|
||||
{ resolve: MODULE_PACKAGE_NAMES[Modules.PAYMENT] },
|
||||
{ resolve: MODULE_PACKAGE_NAMES[Modules.ORDER] },
|
||||
{ resolve: MODULE_PACKAGE_NAMES[Modules.SETTINGS] },
|
||||
|
||||
{
|
||||
resolve: MODULE_PACKAGE_NAMES[Modules.AUTH],
|
||||
|
||||
@@ -26,6 +26,7 @@ export const Modules = {
|
||||
NOTIFICATION: "notification",
|
||||
INDEX: "index",
|
||||
LOCKING: "locking",
|
||||
SETTINGS: "settings",
|
||||
} as const
|
||||
|
||||
export const MODULE_PACKAGE_NAMES = {
|
||||
@@ -56,6 +57,7 @@ export const MODULE_PACKAGE_NAMES = {
|
||||
[Modules.NOTIFICATION]: "@medusajs/medusa/notification",
|
||||
[Modules.INDEX]: "@medusajs/medusa/index-module",
|
||||
[Modules.LOCKING]: "@medusajs/medusa/locking",
|
||||
[Modules.SETTINGS]: "@medusajs/medusa/settings",
|
||||
}
|
||||
|
||||
export const REVERSED_MODULE_PACKAGE_NAMES = Object.entries(
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"@medusajs/promotion": "2.9.0",
|
||||
"@medusajs/region": "2.9.0",
|
||||
"@medusajs/sales-channel": "2.9.0",
|
||||
"@medusajs/settings": "2.9.0",
|
||||
"@medusajs/stock-location": "2.9.0",
|
||||
"@medusajs/store": "2.9.0",
|
||||
"@medusajs/tax": "2.9.0",
|
||||
|
||||
6
packages/medusa/src/modules/settings.ts
Normal file
6
packages/medusa/src/modules/settings.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import SettingsModule from "@medusajs/settings"
|
||||
|
||||
export * from "@medusajs/settings"
|
||||
|
||||
export default SettingsModule
|
||||
export const discoveryPath = require.resolve("@medusajs/settings")
|
||||
17
packages/modules/settings/README.md
Normal file
17
packages/modules/settings/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Settings Module
|
||||
|
||||
The settings module provides functionality for managing user preferences and configurations in Medusa.
|
||||
|
||||
## Features
|
||||
|
||||
- **View Configurations**: Save and manage table view configurations (column visibility, order, widths)
|
||||
- **User Preferences**: Store arbitrary user preferences in a key-value format
|
||||
- **System Defaults**: Admin users can set default configurations for all users
|
||||
|
||||
## Module Options
|
||||
|
||||
```js
|
||||
const settingsModuleOptions = {
|
||||
// Module options can be added here if needed
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,423 @@
|
||||
import { Modules } from "@medusajs/utils"
|
||||
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
|
||||
import { SettingsTypes } from "@medusajs/types"
|
||||
|
||||
jest.setTimeout(30000)
|
||||
|
||||
moduleIntegrationTestRunner<SettingsTypes.ISettingsModuleService>({
|
||||
moduleName: Modules.SETTINGS,
|
||||
testSuite: ({ service }) => {
|
||||
describe("SettingsModuleService", function () {
|
||||
describe("ViewConfiguration", function () {
|
||||
it("should create a view configuration", async () => {
|
||||
const viewConfig = await service.createViewConfigurations({
|
||||
entity: "orders",
|
||||
name: "My Orders View",
|
||||
user_id: "user_123",
|
||||
configuration: {
|
||||
visible_columns: ["id", "status", "created_at"],
|
||||
column_order: ["id", "status", "created_at"],
|
||||
column_widths: { id: 100, status: 150 },
|
||||
filters: { status: ["pending", "completed"] },
|
||||
sorting: { id: "created_at", desc: true },
|
||||
search: "",
|
||||
},
|
||||
})
|
||||
|
||||
expect(viewConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
entity: "orders",
|
||||
name: "My Orders View",
|
||||
user_id: "user_123",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should update a view configuration and remove filters", async () => {
|
||||
// Create a view with filters
|
||||
const viewConfig = await service.createViewConfigurations({
|
||||
entity: "products",
|
||||
name: "Filtered Products View",
|
||||
user_id: "user_456",
|
||||
configuration: {
|
||||
visible_columns: ["id", "title", "status"],
|
||||
column_order: ["id", "title", "status"],
|
||||
filters: {
|
||||
status: ["draft", "published"],
|
||||
collection_id: ["col_123", "col_456"],
|
||||
},
|
||||
sorting: { id: "created_at", desc: true },
|
||||
},
|
||||
})
|
||||
|
||||
expect(viewConfig.configuration.filters).toEqual({
|
||||
status: ["draft", "published"],
|
||||
collection_id: ["col_123", "col_456"],
|
||||
})
|
||||
|
||||
// Update the view to remove filters
|
||||
const updatedConfig = await service.updateViewConfigurations(
|
||||
viewConfig.id,
|
||||
{
|
||||
configuration: {
|
||||
visible_columns: ["id", "title", "status"],
|
||||
column_order: ["id", "title", "status"],
|
||||
filters: {}, // Empty filters object
|
||||
sorting: { id: "created_at", desc: true },
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
expect(updatedConfig.configuration.filters).toEqual({})
|
||||
|
||||
// Retrieve the view again to ensure filters were persisted as empty
|
||||
const retrievedConfig = await service.retrieveViewConfiguration(
|
||||
viewConfig.id
|
||||
)
|
||||
|
||||
expect(retrievedConfig.configuration.filters).toEqual({})
|
||||
})
|
||||
|
||||
it("should update view configuration with partial configuration updates", async () => {
|
||||
// Create a view with full configuration
|
||||
const viewConfig = await service.createViewConfigurations({
|
||||
entity: "customers",
|
||||
name: "Customer View",
|
||||
user_id: "user_789",
|
||||
configuration: {
|
||||
visible_columns: ["id", "name", "email"],
|
||||
column_order: ["id", "name", "email"],
|
||||
filters: {
|
||||
has_account: true,
|
||||
groups: ["vip", "regular"],
|
||||
},
|
||||
sorting: { id: "created_at", desc: false },
|
||||
search: "test search",
|
||||
},
|
||||
})
|
||||
|
||||
// Update only filters (should preserve other configuration)
|
||||
const updatedConfig = await service.updateViewConfigurations(
|
||||
viewConfig.id,
|
||||
{
|
||||
configuration: {
|
||||
visible_columns: ["id", "name", "email"],
|
||||
column_order: ["id", "name", "email"],
|
||||
filters: { has_account: false }, // Changed filters
|
||||
sorting: { id: "created_at", desc: false },
|
||||
search: "test search",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
expect(updatedConfig.configuration).toEqual({
|
||||
visible_columns: ["id", "name", "email"],
|
||||
column_order: ["id", "name", "email"],
|
||||
filters: { has_account: false },
|
||||
sorting: { id: "created_at", desc: false },
|
||||
search: "test search",
|
||||
column_widths: {}, // Default value when not provided
|
||||
})
|
||||
})
|
||||
|
||||
it("should update only the name field without affecting configuration", async () => {
|
||||
// Create a view with full configuration
|
||||
const viewConfig = await service.createViewConfigurations({
|
||||
entity: "orders",
|
||||
name: "Original Name",
|
||||
user_id: "user_123",
|
||||
configuration: {
|
||||
visible_columns: ["id", "status", "total"],
|
||||
column_order: ["id", "status", "total"],
|
||||
column_widths: { id: 100, status: 150, total: 200 },
|
||||
filters: { status: ["pending", "completed"] },
|
||||
sorting: { id: "created_at", desc: true },
|
||||
search: "test search",
|
||||
},
|
||||
})
|
||||
|
||||
// Update only the name field
|
||||
const updatedConfig = await service.updateViewConfigurations(
|
||||
viewConfig.id,
|
||||
{ name: "Updated Name" }
|
||||
)
|
||||
|
||||
expect(updatedConfig.name).toBe("Updated Name")
|
||||
expect(updatedConfig.configuration).toEqual(viewConfig.configuration)
|
||||
})
|
||||
|
||||
it("should completely replace filters when updating configuration", async () => {
|
||||
// Create a view with complex filters
|
||||
const viewConfig = await service.createViewConfigurations({
|
||||
entity: "products",
|
||||
name: "Product View",
|
||||
user_id: "user_123",
|
||||
configuration: {
|
||||
visible_columns: ["id", "title"],
|
||||
column_order: ["id", "title"],
|
||||
filters: {
|
||||
status: ["draft", "published"],
|
||||
collection_id: ["col_123", "col_456"],
|
||||
price_range: { min: 10, max: 100 },
|
||||
},
|
||||
sorting: { id: "created_at", desc: true },
|
||||
},
|
||||
})
|
||||
|
||||
// Update with new filters
|
||||
const updatedConfig = await service.updateViewConfigurations(
|
||||
viewConfig.id,
|
||||
{
|
||||
configuration: {
|
||||
visible_columns: ["id", "title"],
|
||||
column_order: ["id", "title"],
|
||||
filters: { category: ["electronics"] }, // Completely different filters
|
||||
sorting: { id: "created_at", desc: true },
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
expect(updatedConfig.configuration.filters).toEqual({
|
||||
category: ["electronics"],
|
||||
})
|
||||
// Verify old filters are gone
|
||||
expect(updatedConfig.configuration.filters.status).toBeUndefined()
|
||||
expect(updatedConfig.configuration.filters.collection_id).toBeUndefined()
|
||||
expect(updatedConfig.configuration.filters.price_range).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should remove filters when explicitly set to empty object", async () => {
|
||||
// Create a view with filters
|
||||
const viewConfig = await service.createViewConfigurations({
|
||||
entity: "customers",
|
||||
name: "Customer View",
|
||||
user_id: "user_123",
|
||||
configuration: {
|
||||
visible_columns: ["id", "name", "email"],
|
||||
column_order: ["id", "name", "email"],
|
||||
filters: {
|
||||
has_account: true,
|
||||
groups: ["vip", "regular"],
|
||||
},
|
||||
sorting: { id: "created_at", desc: false },
|
||||
},
|
||||
})
|
||||
|
||||
// Update configuration with empty filters
|
||||
const updatedConfig = await service.updateViewConfigurations(
|
||||
viewConfig.id,
|
||||
{
|
||||
configuration: {
|
||||
visible_columns: ["id", "name", "email"],
|
||||
column_order: ["id", "name", "email"],
|
||||
filters: {}, // Empty filters
|
||||
sorting: { id: "created_at", desc: false },
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
expect(updatedConfig.configuration.filters).toEqual({})
|
||||
})
|
||||
|
||||
it("should preserve other configuration properties when updating specific ones", async () => {
|
||||
// Create a view with full configuration
|
||||
const viewConfig = await service.createViewConfigurations({
|
||||
entity: "inventory",
|
||||
name: "Inventory View",
|
||||
user_id: "user_123",
|
||||
configuration: {
|
||||
visible_columns: ["id", "sku", "quantity"],
|
||||
column_order: ["id", "sku", "quantity"],
|
||||
column_widths: { id: 80, sku: 120, quantity: 100 },
|
||||
filters: { location: ["warehouse_1"] },
|
||||
sorting: { id: "sku", desc: false },
|
||||
search: "original search",
|
||||
},
|
||||
})
|
||||
|
||||
// Update only filters and sorting
|
||||
const updatedConfig = await service.updateViewConfigurations(
|
||||
viewConfig.id,
|
||||
{
|
||||
configuration: {
|
||||
visible_columns: viewConfig.configuration.visible_columns,
|
||||
column_order: viewConfig.configuration.column_order,
|
||||
filters: { location: ["warehouse_2"] },
|
||||
sorting: { id: "quantity", desc: true },
|
||||
// Note: not providing column_widths and search
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
expect(updatedConfig.configuration.filters).toEqual({
|
||||
location: ["warehouse_2"],
|
||||
})
|
||||
expect(updatedConfig.configuration.sorting).toEqual({
|
||||
id: "quantity",
|
||||
desc: true,
|
||||
})
|
||||
// These should be preserved
|
||||
expect(updatedConfig.configuration.visible_columns).toEqual(
|
||||
viewConfig.configuration.visible_columns
|
||||
)
|
||||
expect(updatedConfig.configuration.column_order).toEqual(
|
||||
viewConfig.configuration.column_order
|
||||
)
|
||||
// These should have default values since not provided
|
||||
expect(updatedConfig.configuration.column_widths).toEqual({})
|
||||
expect(updatedConfig.configuration.search).toBe("")
|
||||
})
|
||||
|
||||
it("should handle missing configuration properties with defaults", async () => {
|
||||
// Create a view with full configuration
|
||||
const viewConfig = await service.createViewConfigurations({
|
||||
entity: "payments",
|
||||
name: "Payment View",
|
||||
user_id: "user_123",
|
||||
configuration: {
|
||||
visible_columns: ["id", "amount", "status"],
|
||||
column_order: ["id", "amount", "status"],
|
||||
column_widths: { id: 100, amount: 150, status: 120 },
|
||||
filters: { status: ["completed"] },
|
||||
sorting: { id: "created_at", desc: true },
|
||||
search: "payment search",
|
||||
},
|
||||
})
|
||||
|
||||
// Update with partial configuration (only visible_columns)
|
||||
const updatedConfig = await service.updateViewConfigurations(
|
||||
viewConfig.id,
|
||||
{
|
||||
configuration: {
|
||||
visible_columns: ["id", "amount"],
|
||||
column_order: ["id", "amount"],
|
||||
// Not providing filters, sorting, search, column_widths
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
expect(updatedConfig.configuration).toEqual({
|
||||
visible_columns: ["id", "amount"],
|
||||
column_order: ["id", "amount"],
|
||||
column_widths: {}, // Default
|
||||
filters: {}, // Default
|
||||
sorting: null, // Default
|
||||
search: "", // Default
|
||||
})
|
||||
})
|
||||
|
||||
it("should update multiple view configurations when using selector", async () => {
|
||||
// Create multiple views for the same entity
|
||||
const view1 = await service.createViewConfigurations({
|
||||
entity: "orders",
|
||||
name: "Orders View 1",
|
||||
user_id: "user_123",
|
||||
configuration: {
|
||||
visible_columns: ["id", "status"],
|
||||
column_order: ["id", "status"],
|
||||
filters: { status: ["pending"] },
|
||||
},
|
||||
})
|
||||
|
||||
const view2 = await service.createViewConfigurations({
|
||||
entity: "orders",
|
||||
name: "Orders View 2",
|
||||
user_id: "user_456",
|
||||
configuration: {
|
||||
visible_columns: ["id", "total"],
|
||||
column_order: ["id", "total"],
|
||||
filters: { status: ["completed"] },
|
||||
},
|
||||
})
|
||||
|
||||
const view3 = await service.createViewConfigurations({
|
||||
entity: "products", // Different entity
|
||||
name: "Products View",
|
||||
user_id: "user_123",
|
||||
configuration: {
|
||||
visible_columns: ["id", "title"],
|
||||
column_order: ["id", "title"],
|
||||
},
|
||||
})
|
||||
|
||||
// Update using selector for entity "orders"
|
||||
const updatedConfigs = await service.updateViewConfigurations(
|
||||
{ entity: "orders" },
|
||||
{
|
||||
configuration: {
|
||||
visible_columns: ["id", "status", "total"],
|
||||
column_order: ["id", "status", "total"],
|
||||
filters: {},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Should return an array
|
||||
expect(Array.isArray(updatedConfigs)).toBe(true)
|
||||
expect(updatedConfigs).toHaveLength(2)
|
||||
|
||||
// Both orders views should be updated
|
||||
const updatedIds = updatedConfigs.map((v) => v.id).sort()
|
||||
expect(updatedIds).toEqual([view1.id, view2.id].sort())
|
||||
|
||||
// All should have the new configuration
|
||||
updatedConfigs.forEach((config) => {
|
||||
expect(config.configuration.visible_columns).toEqual([
|
||||
"id",
|
||||
"status",
|
||||
"total",
|
||||
])
|
||||
expect(config.configuration.filters).toEqual({})
|
||||
})
|
||||
|
||||
// Products view should not be affected
|
||||
const productView = await service.retrieveViewConfiguration(view3.id)
|
||||
expect(productView.configuration.visible_columns).toEqual(["id", "title"])
|
||||
})
|
||||
|
||||
it("should return empty array when no views match selector", async () => {
|
||||
// Try to update with a selector that matches no views
|
||||
const result = await service.updateViewConfigurations(
|
||||
{ entity: "non_existent_entity" },
|
||||
{ name: "New Name" }
|
||||
)
|
||||
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should handle null values in configuration", async () => {
|
||||
// Create a view with sorting
|
||||
const viewConfig = await service.createViewConfigurations({
|
||||
entity: "shipping",
|
||||
name: "Shipping View",
|
||||
user_id: "user_123",
|
||||
configuration: {
|
||||
visible_columns: ["id", "carrier", "tracking"],
|
||||
column_order: ["id", "carrier", "tracking"],
|
||||
sorting: { id: "created_at", desc: true },
|
||||
search: "fedex",
|
||||
},
|
||||
})
|
||||
|
||||
// Update with sorting: null
|
||||
const updatedConfig = await service.updateViewConfigurations(
|
||||
viewConfig.id,
|
||||
{
|
||||
configuration: {
|
||||
visible_columns: viewConfig.configuration.visible_columns,
|
||||
column_order: viewConfig.configuration.column_order,
|
||||
sorting: null,
|
||||
search: "", // Also test empty string
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
expect(updatedConfig.configuration.sorting).toBeNull()
|
||||
expect(updatedConfig.configuration.search).toBe("")
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
10
packages/modules/settings/jest.config.js
Normal file
10
packages/modules/settings/jest.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const defineJestConfig = require("../../../define_jest_config")
|
||||
module.exports = defineJestConfig({
|
||||
moduleNameMapper: {
|
||||
"^@models": "<rootDir>/src/models",
|
||||
"^@services": "<rootDir>/src/services",
|
||||
"^@repositories": "<rootDir>/src/repositories",
|
||||
"^@types": "<rootDir>/src/types",
|
||||
"^@utils": "<rootDir>/src/utils",
|
||||
},
|
||||
})
|
||||
6
packages/modules/settings/mikro-orm.config.dev.ts
Normal file
6
packages/modules/settings/mikro-orm.config.dev.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import * as entities from "./src/models"
|
||||
import { defineMikroOrmCliConfig, Modules } from "@medusajs/framework/utils"
|
||||
|
||||
export default defineMikroOrmCliConfig(Modules.SETTINGS, {
|
||||
entities: Object.values(entities),
|
||||
})
|
||||
59
packages/modules/settings/package.json
Normal file
59
packages/modules/settings/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@medusajs/settings",
|
||||
"version": "2.9.0",
|
||||
"description": "Medusa Settings module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/__tests__",
|
||||
"!dist/**/__mocks__",
|
||||
"!dist/**/__fixtures__"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/modules/settings"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"author": "Medusa",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"watch": "tsc --build --watch",
|
||||
"watch:test": "tsc --build tsconfig.spec.json --watch",
|
||||
"resolve:aliases": "tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && tsc-alias -p tsconfig.resolved.json && rimraf tsconfig.resolved.json",
|
||||
"build": "rimraf dist && tsc --build && npm run resolve:aliases",
|
||||
"test": "jest --runInBand --passWithNoTests --bail --forceExit -- src",
|
||||
"test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.ts",
|
||||
"migration:initial": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create --initial",
|
||||
"migration:create": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create",
|
||||
"migration:up": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:up",
|
||||
"orm:cache:clear": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm cache:clear"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@medusajs/framework": "2.9.0",
|
||||
"@medusajs/test-utils": "2.9.0",
|
||||
"@mikro-orm/cli": "6.4.3",
|
||||
"@mikro-orm/core": "6.4.3",
|
||||
"@mikro-orm/migrations": "6.4.3",
|
||||
"@mikro-orm/postgresql": "6.4.3",
|
||||
"@swc/core": "^1.7.28",
|
||||
"@swc/jest": "^0.2.36",
|
||||
"jest": "^29.7.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"tsc-alias": "^1.8.6",
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@medusajs/framework": "2.9.0",
|
||||
"@mikro-orm/core": "6.4.3",
|
||||
"@mikro-orm/migrations": "6.4.3",
|
||||
"@mikro-orm/postgresql": "6.4.3",
|
||||
"awilix": "^8.0.1"
|
||||
}
|
||||
}
|
||||
7
packages/modules/settings/src/index.ts
Normal file
7
packages/modules/settings/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { SettingsModuleService } from "@/services"
|
||||
import { Module } from "@medusajs/framework/utils"
|
||||
import { Modules } from "@medusajs/utils"
|
||||
|
||||
export default Module(Modules.SETTINGS, {
|
||||
service: SettingsModuleService,
|
||||
})
|
||||
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"namespaces": [
|
||||
"public"
|
||||
],
|
||||
"name": "public",
|
||||
"tables": [
|
||||
{
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "jsonb",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "json"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"default": "now()",
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"default": "now()",
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"length": 6,
|
||||
"mappedType": "datetime"
|
||||
}
|
||||
},
|
||||
"name": "user_preference",
|
||||
"schema": "public",
|
||||
"indexes": [
|
||||
{
|
||||
"keyName": "IDX_user_preference_deleted_at",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_user_preference_deleted_at\" ON \"user_preference\" (deleted_at) WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_user_preference_user_id_key_unique",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_user_preference_user_id_key_unique\" ON \"user_preference\" (user_id, key) WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_user_preference_user_id",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_user_preference_user_id\" ON \"user_preference\" (user_id) WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "user_preference_pkey",
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"composite": false,
|
||||
"constraint": true,
|
||||
"primary": true,
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"checks": [],
|
||||
"foreignKeys": {},
|
||||
"nativeEnums": {}
|
||||
},
|
||||
{
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"entity": {
|
||||
"name": "entity",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"is_system_default": {
|
||||
"name": "is_system_default",
|
||||
"type": "boolean",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"default": "false",
|
||||
"mappedType": "boolean"
|
||||
},
|
||||
"configuration": {
|
||||
"name": "configuration",
|
||||
"type": "jsonb",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "json"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"default": "now()",
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"default": "now()",
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"length": 6,
|
||||
"mappedType": "datetime"
|
||||
}
|
||||
},
|
||||
"name": "view_configuration",
|
||||
"schema": "public",
|
||||
"indexes": [
|
||||
{
|
||||
"keyName": "IDX_view_configuration_deleted_at",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_view_configuration_deleted_at\" ON \"view_configuration\" (deleted_at) WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_view_configuration_entity_user_id",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_view_configuration_entity_user_id\" ON \"view_configuration\" (entity, user_id) WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_view_configuration_entity_is_system_default",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_view_configuration_entity_is_system_default\" ON \"view_configuration\" (entity, is_system_default) WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_view_configuration_user_id",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_view_configuration_user_id\" ON \"view_configuration\" (user_id) WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "view_configuration_pkey",
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"composite": false,
|
||||
"constraint": true,
|
||||
"primary": true,
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"checks": [],
|
||||
"foreignKeys": {},
|
||||
"nativeEnums": {}
|
||||
}
|
||||
],
|
||||
"nativeEnums": {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20250717162007 extends Migration {
|
||||
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(`alter table if exists "user_preference" drop constraint if exists "user_preference_user_id_key_unique";`);
|
||||
this.addSql(`create table if not exists "user_preference" ("id" text not null, "user_id" text not null, "key" text not null, "value" jsonb not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "user_preference_pkey" primary key ("id"));`);
|
||||
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_user_preference_deleted_at" ON "user_preference" (deleted_at) WHERE deleted_at IS NULL;`);
|
||||
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_user_preference_user_id_key_unique" ON "user_preference" (user_id, key) WHERE deleted_at IS NULL;`);
|
||||
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_user_preference_user_id" ON "user_preference" (user_id) WHERE deleted_at IS NULL;`);
|
||||
|
||||
this.addSql(`create table if not exists "view_configuration" ("id" text not null, "entity" text not null, "name" text null, "user_id" text null, "is_system_default" boolean not null default false, "configuration" jsonb not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "view_configuration_pkey" primary key ("id"));`);
|
||||
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_view_configuration_deleted_at" ON "view_configuration" (deleted_at) WHERE deleted_at IS NULL;`);
|
||||
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_view_configuration_entity_user_id" ON "view_configuration" (entity, user_id) WHERE deleted_at IS NULL;`);
|
||||
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_view_configuration_entity_is_system_default" ON "view_configuration" (entity, is_system_default) WHERE deleted_at IS NULL;`);
|
||||
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_view_configuration_user_id" ON "view_configuration" (user_id) WHERE deleted_at IS NULL;`);
|
||||
}
|
||||
|
||||
}
|
||||
2
packages/modules/settings/src/models/index.ts
Normal file
2
packages/modules/settings/src/models/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ViewConfiguration } from "./view-configuration"
|
||||
export { UserPreference } from "./user-preference"
|
||||
18
packages/modules/settings/src/models/user-preference.ts
Normal file
18
packages/modules/settings/src/models/user-preference.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
|
||||
export const UserPreference = model
|
||||
.define("user_preference", {
|
||||
id: model.id({ prefix: "usrpref" }).primaryKey(),
|
||||
user_id: model.text(),
|
||||
key: model.text().searchable(),
|
||||
value: model.json(),
|
||||
})
|
||||
.indexes([
|
||||
{
|
||||
on: ["user_id", "key"],
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
on: ["user_id"],
|
||||
},
|
||||
])
|
||||
22
packages/modules/settings/src/models/view-configuration.ts
Normal file
22
packages/modules/settings/src/models/view-configuration.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
|
||||
export const ViewConfiguration = model
|
||||
.define("view_configuration", {
|
||||
id: model.id({ prefix: "vconf" }).primaryKey(),
|
||||
entity: model.text().searchable(),
|
||||
name: model.text().searchable().nullable(),
|
||||
user_id: model.text().nullable(),
|
||||
is_system_default: model.boolean().default(false),
|
||||
configuration: model.json(),
|
||||
})
|
||||
.indexes([
|
||||
{
|
||||
on: ["entity", "user_id"],
|
||||
},
|
||||
{
|
||||
on: ["entity", "is_system_default"],
|
||||
},
|
||||
{
|
||||
on: ["user_id"],
|
||||
},
|
||||
])
|
||||
1
packages/modules/settings/src/services/index.ts
Normal file
1
packages/modules/settings/src/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as SettingsModuleService } from "./settings-module-service"
|
||||
@@ -0,0 +1,365 @@
|
||||
import {
|
||||
Context,
|
||||
DAL,
|
||||
InferEntityType,
|
||||
InternalModuleDeclaration,
|
||||
ModulesSdkTypes,
|
||||
SettingsTypes,
|
||||
} from "@medusajs/framework/types"
|
||||
import {
|
||||
InjectManager,
|
||||
InjectTransactionManager,
|
||||
MedusaContext,
|
||||
MedusaError,
|
||||
MedusaService,
|
||||
} from "@medusajs/framework/utils"
|
||||
import { ViewConfiguration, UserPreference } from "@/models"
|
||||
|
||||
type InjectedDependencies = {
|
||||
baseRepository: DAL.RepositoryService
|
||||
viewConfigurationService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||
userPreferenceService: ModulesSdkTypes.IMedusaInternalService<any>
|
||||
}
|
||||
|
||||
export default class SettingsModuleService
|
||||
extends MedusaService<{
|
||||
ViewConfiguration: { dto: SettingsTypes.ViewConfigurationDTO }
|
||||
UserPreference: { dto: SettingsTypes.UserPreferenceDTO }
|
||||
}>({ ViewConfiguration, UserPreference })
|
||||
implements SettingsTypes.ISettingsModuleService
|
||||
{
|
||||
protected baseRepository_: DAL.RepositoryService
|
||||
protected readonly viewConfigurationService_: ModulesSdkTypes.IMedusaInternalService<
|
||||
InferEntityType<typeof ViewConfiguration>
|
||||
>
|
||||
protected readonly userPreferenceService_: ModulesSdkTypes.IMedusaInternalService<
|
||||
InferEntityType<typeof UserPreference>
|
||||
>
|
||||
|
||||
constructor(
|
||||
{
|
||||
baseRepository,
|
||||
viewConfigurationService,
|
||||
userPreferenceService,
|
||||
}: InjectedDependencies,
|
||||
protected readonly moduleDeclaration: InternalModuleDeclaration
|
||||
) {
|
||||
super(...arguments)
|
||||
this.baseRepository_ = baseRepository
|
||||
this.viewConfigurationService_ = viewConfigurationService
|
||||
this.userPreferenceService_ = userPreferenceService
|
||||
}
|
||||
|
||||
@InjectTransactionManager()
|
||||
// @ts-expect-error
|
||||
async createViewConfigurations(
|
||||
data:
|
||||
| SettingsTypes.CreateViewConfigurationDTO
|
||||
| SettingsTypes.CreateViewConfigurationDTO[],
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<
|
||||
SettingsTypes.ViewConfigurationDTO | SettingsTypes.ViewConfigurationDTO[]
|
||||
> {
|
||||
// Convert to array for validation only
|
||||
const isArrayInput = Array.isArray(data)
|
||||
const dataArray = isArrayInput ? data : [data]
|
||||
|
||||
// Validate system defaults
|
||||
for (const config of dataArray) {
|
||||
if (config.is_system_default && config.user_id) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"System default view configurations cannot have a user_id"
|
||||
)
|
||||
}
|
||||
|
||||
if (config.is_system_default) {
|
||||
// Check if a system default already exists for this entity
|
||||
const existingDefault = await this.viewConfigurationService_.list(
|
||||
{
|
||||
entity: config.entity,
|
||||
is_system_default: true,
|
||||
},
|
||||
{ select: ["id"] },
|
||||
sharedContext
|
||||
)
|
||||
|
||||
if (existingDefault.length > 0) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.DUPLICATE_ERROR,
|
||||
`A system default view configuration already exists for entity: ${config.entity}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await super.createViewConfigurations(
|
||||
dataArray,
|
||||
sharedContext
|
||||
)
|
||||
return isArrayInput ? result : result[0]
|
||||
}
|
||||
|
||||
@InjectTransactionManager()
|
||||
// @ts-expect-error
|
||||
async updateViewConfigurations(
|
||||
idOrSelector: string | SettingsTypes.FilterableViewConfigurationProps,
|
||||
data: SettingsTypes.UpdateViewConfigurationDTO,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<
|
||||
SettingsTypes.ViewConfigurationDTO | SettingsTypes.ViewConfigurationDTO[]
|
||||
> {
|
||||
let selector: SettingsTypes.FilterableViewConfigurationProps = {}
|
||||
|
||||
if (typeof idOrSelector === "string") {
|
||||
selector = { id: idOrSelector }
|
||||
} else {
|
||||
selector = idOrSelector
|
||||
}
|
||||
|
||||
// Special handling for configuration updates to ensure replacement instead of merge
|
||||
if (data.configuration) {
|
||||
// First, get the entities to update
|
||||
const entities = await this.viewConfigurationService_.list(
|
||||
selector,
|
||||
{},
|
||||
sharedContext
|
||||
)
|
||||
|
||||
if (entities.length === 0) {
|
||||
return typeof idOrSelector === "string" ? [] : []
|
||||
}
|
||||
|
||||
// Use upsertWithReplace to update the configuration field without merging
|
||||
const updateDataArray = entities.map((entity) => ({
|
||||
id: entity.id,
|
||||
...data,
|
||||
configuration: {
|
||||
visible_columns: data.configuration?.visible_columns ?? [],
|
||||
column_order: data.configuration?.column_order ?? [],
|
||||
column_widths:
|
||||
data.configuration?.column_widths !== undefined
|
||||
? data.configuration.column_widths
|
||||
: {},
|
||||
filters:
|
||||
data.configuration?.filters !== undefined
|
||||
? data.configuration.filters
|
||||
: {},
|
||||
sorting:
|
||||
data.configuration?.sorting !== undefined
|
||||
? data.configuration.sorting
|
||||
: null,
|
||||
search:
|
||||
data.configuration?.search !== undefined
|
||||
? data.configuration.search
|
||||
: "",
|
||||
},
|
||||
}))
|
||||
|
||||
// Use upsertWithReplace which uses nativeUpdateMany internally and doesn't merge JSON fields
|
||||
const { entities: updatedEntities } =
|
||||
await this.viewConfigurationService_.upsertWithReplace(
|
||||
updateDataArray,
|
||||
{ relations: [] },
|
||||
sharedContext
|
||||
)
|
||||
|
||||
const serialized = await this.baseRepository_.serialize<
|
||||
SettingsTypes.ViewConfigurationDTO[]
|
||||
>(updatedEntities, { populate: true })
|
||||
|
||||
return typeof idOrSelector === "string" ? serialized[0] : serialized
|
||||
}
|
||||
|
||||
// For non-configuration updates, use the standard update method
|
||||
const updated = await this.viewConfigurationService_.update(
|
||||
{ selector, data },
|
||||
sharedContext
|
||||
)
|
||||
|
||||
const serialized = await this.baseRepository_.serialize<
|
||||
SettingsTypes.ViewConfigurationDTO[]
|
||||
>(updated, { populate: true })
|
||||
|
||||
return typeof idOrSelector === "string" ? serialized[0] : serialized
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
async getUserPreference(
|
||||
userId: string,
|
||||
key: string,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<SettingsTypes.UserPreferenceDTO | null> {
|
||||
const prefs = await this.userPreferenceService_.list(
|
||||
{ user_id: userId, key },
|
||||
{},
|
||||
sharedContext
|
||||
)
|
||||
|
||||
if (prefs.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await this.baseRepository_.serialize<SettingsTypes.UserPreferenceDTO>(
|
||||
prefs[0],
|
||||
{ populate: true }
|
||||
)
|
||||
}
|
||||
|
||||
@InjectTransactionManager()
|
||||
async setUserPreference(
|
||||
userId: string,
|
||||
key: string,
|
||||
value: any,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<SettingsTypes.UserPreferenceDTO> {
|
||||
const existing = await this.userPreferenceService_.list(
|
||||
{ user_id: userId, key },
|
||||
{ select: ["id"] },
|
||||
sharedContext
|
||||
)
|
||||
|
||||
let result: InferEntityType<typeof UserPreference>
|
||||
|
||||
if (existing.length > 0) {
|
||||
const updated = await this.userPreferenceService_.update(
|
||||
[{ id: existing[0].id, value }],
|
||||
sharedContext
|
||||
)
|
||||
result = updated[0]
|
||||
} else {
|
||||
const created = await this.userPreferenceService_.create(
|
||||
{ user_id: userId, key, value },
|
||||
sharedContext
|
||||
)
|
||||
result = created[0]
|
||||
}
|
||||
|
||||
return await this.baseRepository_.serialize<SettingsTypes.UserPreferenceDTO>(
|
||||
result,
|
||||
{ populate: true }
|
||||
)
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
async getActiveViewConfiguration(
|
||||
entity: string,
|
||||
userId: string,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<SettingsTypes.ViewConfigurationDTO | null> {
|
||||
// Check if user has an active view preference
|
||||
const activeViewPref = await this.getUserPreference(
|
||||
userId,
|
||||
`active_view.${entity}`,
|
||||
sharedContext
|
||||
)
|
||||
|
||||
// Check if we have a preference with a view configuration ID (not explicitly null)
|
||||
if (
|
||||
activeViewPref &&
|
||||
activeViewPref.value?.viewConfigurationId &&
|
||||
activeViewPref.value.viewConfigurationId !== null
|
||||
) {
|
||||
try {
|
||||
return await this.retrieveViewConfiguration(
|
||||
activeViewPref.value.viewConfigurationId,
|
||||
{},
|
||||
sharedContext
|
||||
)
|
||||
} catch (error) {
|
||||
// View configuration might have been deleted
|
||||
}
|
||||
}
|
||||
|
||||
// If we have an explicit null preference, or no preference, or a deleted view
|
||||
// We should check for defaults in this order:
|
||||
|
||||
// Check if user has any personal views (only if no explicit null preference)
|
||||
if (!activeViewPref || activeViewPref.value?.viewConfigurationId !== null) {
|
||||
const [personalView] = await this.listViewConfigurations(
|
||||
{ entity, user_id: userId },
|
||||
{ take: 1, order: { created_at: "ASC" } },
|
||||
sharedContext
|
||||
)
|
||||
|
||||
if (personalView) {
|
||||
return personalView
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to system default
|
||||
const systemDefaults = await this.listViewConfigurations(
|
||||
{ entity, is_system_default: true },
|
||||
{},
|
||||
sharedContext
|
||||
)
|
||||
|
||||
return systemDefaults.length > 0 ? systemDefaults[0] : null
|
||||
}
|
||||
|
||||
@InjectTransactionManager()
|
||||
async setActiveViewConfiguration(
|
||||
entity: string,
|
||||
userId: string,
|
||||
viewConfigurationId: string,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<void> {
|
||||
// Verify the view configuration exists and user has access
|
||||
const viewConfig = await this.retrieveViewConfiguration(
|
||||
viewConfigurationId,
|
||||
{},
|
||||
sharedContext
|
||||
)
|
||||
|
||||
if (viewConfig.entity !== entity) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`View configuration ${viewConfigurationId} is not for entity ${entity}`
|
||||
)
|
||||
}
|
||||
|
||||
if (viewConfig.user_id && viewConfig.user_id !== userId) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
`User ${userId} does not have access to view configuration ${viewConfigurationId}`
|
||||
)
|
||||
}
|
||||
|
||||
await this.setUserPreference(
|
||||
userId,
|
||||
`active_view.${entity}`,
|
||||
{ viewConfigurationId },
|
||||
sharedContext
|
||||
)
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
async getSystemDefaultViewConfiguration(
|
||||
entity: string,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<SettingsTypes.ViewConfigurationDTO | null> {
|
||||
const systemDefaults = await this.listViewConfigurations(
|
||||
{ entity, is_system_default: true },
|
||||
{},
|
||||
sharedContext
|
||||
)
|
||||
|
||||
return systemDefaults.length > 0 ? systemDefaults[0] : null
|
||||
}
|
||||
|
||||
@InjectTransactionManager()
|
||||
async clearActiveViewConfiguration(
|
||||
entity: string,
|
||||
userId: string,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<void> {
|
||||
// Instead of deleting, set the preference to null
|
||||
// This ensures we're using the same transaction pattern as setActiveViewConfiguration
|
||||
await this.setUserPreference(
|
||||
userId,
|
||||
`active_view.${entity}`,
|
||||
{ viewConfigurationId: null },
|
||||
sharedContext
|
||||
)
|
||||
}
|
||||
}
|
||||
5
packages/modules/settings/src/types/index.ts
Normal file
5
packages/modules/settings/src/types/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Logger } from "@medusajs/framework/types"
|
||||
|
||||
export type InitializeModuleInjectableDependencies = {
|
||||
logger?: Logger
|
||||
}
|
||||
19
packages/modules/settings/tsconfig.json
Normal file
19
packages/modules/settings/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../../_tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/models": [
|
||||
"./src/models"
|
||||
],
|
||||
"@/services": [
|
||||
"./src/services"
|
||||
],
|
||||
"@/repositories": [
|
||||
"./src/repositories"
|
||||
],
|
||||
"@/types": [
|
||||
"./src/types"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
26
yarn.lock
26
yarn.lock
@@ -6675,6 +6675,7 @@ __metadata:
|
||||
"@medusajs/promotion": 2.9.0
|
||||
"@medusajs/region": 2.9.0
|
||||
"@medusajs/sales-channel": 2.9.0
|
||||
"@medusajs/settings": 2.9.0
|
||||
"@medusajs/stock-location": 2.9.0
|
||||
"@medusajs/store": 2.9.0
|
||||
"@medusajs/tax": 2.9.0
|
||||
@@ -7066,6 +7067,31 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@medusajs/settings@2.9.0, @medusajs/settings@workspace:packages/modules/settings":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@medusajs/settings@workspace:packages/modules/settings"
|
||||
dependencies:
|
||||
"@medusajs/framework": 2.9.0
|
||||
"@medusajs/test-utils": 2.9.0
|
||||
"@mikro-orm/cli": 6.4.3
|
||||
"@mikro-orm/core": 6.4.3
|
||||
"@mikro-orm/migrations": 6.4.3
|
||||
"@mikro-orm/postgresql": 6.4.3
|
||||
"@swc/core": ^1.7.28
|
||||
"@swc/jest": ^0.2.36
|
||||
jest: ^29.7.0
|
||||
rimraf: ^3.0.2
|
||||
tsc-alias: ^1.8.6
|
||||
typescript: ^5.6.2
|
||||
peerDependencies:
|
||||
"@medusajs/framework": 2.9.0
|
||||
"@mikro-orm/core": 6.4.3
|
||||
"@mikro-orm/migrations": 6.4.3
|
||||
"@mikro-orm/postgresql": 6.4.3
|
||||
awilix: ^8.0.1
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@medusajs/stock-location@2.9.0, @medusajs/stock-location@workspace:^, @medusajs/stock-location@workspace:packages/modules/stock-location":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@medusajs/stock-location@workspace:packages/modules/stock-location"
|
||||
|
||||
Reference in New Issue
Block a user