From 7e04091b492c1680fdf5321f512e43b90ba09e12 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Fri, 6 Dec 2024 12:33:18 +0100 Subject: [PATCH 01/13] fix(dashboard): Prevent sending off empty string as handle for product category (#10473) --- .changeset/heavy-peaches-sniff.md | 5 +++++ .../create-category-form/create-category-form.tsx | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 .changeset/heavy-peaches-sniff.md diff --git a/.changeset/heavy-peaches-sniff.md b/.changeset/heavy-peaches-sniff.md new file mode 100644 index 0000000000000..d8488adf4029e --- /dev/null +++ b/.changeset/heavy-peaches-sniff.md @@ -0,0 +1,5 @@ +--- +"@medusajs/dashboard": patch +--- + +fix(dashboard): Prevent sending off empty string as handle for product category diff --git a/packages/admin/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx b/packages/admin/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx index 91531834aa051..b4a156fb63e5b 100644 --- a/packages/admin/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx +++ b/packages/admin/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx @@ -10,6 +10,7 @@ import { } from "../../../../../components/modals" import { KeyboundForm } from "../../../../../components/utilities/keybound-form" import { useCreateProductCategory } from "../../../../../hooks/api/categories" +import { transformNullableFormData } from "../../../../../lib/form-helpers" import { CreateCategoryDetails } from "./create-category-details" import { CreateCategoryNesting } from "./create-category-nesting" import { CreateCategoryDetailsSchema, CreateCategorySchema } from "./schema" @@ -79,13 +80,15 @@ export const CreateCategoryForm = ({ const { mutateAsync, isPending } = useCreateProductCategory() const handleSubmit = form.handleSubmit((data) => { - const { visibility, status, parent_category_id, rank, ...rest } = data + const { visibility, status, parent_category_id, rank, name, ...rest } = data + const parsedData = transformNullableFormData(rest, false) setShouldFreeze(true) mutateAsync( { - ...rest, + name: name, + ...parsedData, parent_category_id: parent_category_id ?? undefined, rank: rank ?? undefined, is_active: status === "active", From f3c91c908a1c8d38eb31c21dbd94d4ad6cea9688 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Fri, 6 Dec 2024 17:38:15 +0530 Subject: [PATCH 02/13] refactor: migrate store module to DML (#10467) --- .changeset/gold-birds-draw.md | 5 + .../migrations/.snapshot-medusa-store.json | 36 +++++-- .../src/migrations/Migration20241206083313.ts | 13 +++ packages/modules/store/src/models/currency.ts | 88 +++-------------- packages/modules/store/src/models/store.ts | 99 +++---------------- .../src/services/store-module-service.ts | 11 ++- 6 files changed, 78 insertions(+), 174 deletions(-) create mode 100644 .changeset/gold-birds-draw.md create mode 100644 packages/modules/store/src/migrations/Migration20241206083313.ts diff --git a/.changeset/gold-birds-draw.md b/.changeset/gold-birds-draw.md new file mode 100644 index 0000000000000..ea21e15d809c4 --- /dev/null +++ b/.changeset/gold-birds-draw.md @@ -0,0 +1,5 @@ +--- +"@medusajs/store": patch +--- + +refactor: migrate store module to DML diff --git a/packages/modules/store/src/migrations/.snapshot-medusa-store.json b/packages/modules/store/src/migrations/.snapshot-medusa-store.json index a1b4e149778f4..cd4b7579cf0a3 100644 --- a/packages/modules/store/src/migrations/.snapshot-medusa-store.json +++ b/packages/modules/store/src/migrations/.snapshot-medusa-store.json @@ -1,5 +1,7 @@ { - "namespaces": ["public"], + "namespaces": [ + "public" + ], "name": "public", "tables": [ { @@ -97,15 +99,17 @@ "indexes": [ { "keyName": "IDX_store_deleted_at", - "columnNames": ["deleted_at"], + "columnNames": [], "composite": false, "primary": false, "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_deleted_at\" ON \"store\" (deleted_at) WHERE deleted_at IS NOT NULL" + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_deleted_at\" ON \"store\" (deleted_at) WHERE deleted_at IS NULL" }, { "keyName": "store_pkey", - "columnNames": ["id"], + "columnNames": [ + "id" + ], "composite": false, "primary": true, "unique": true @@ -189,17 +193,27 @@ "name": "store_currency", "schema": "public", "indexes": [ + { + "keyName": "IDX_store_currency_store_id", + "columnNames": [], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_currency_store_id\" ON \"store_currency\" (store_id) WHERE deleted_at IS NULL" + }, { "keyName": "IDX_store_currency_deleted_at", - "columnNames": ["deleted_at"], + "columnNames": [], "composite": false, "primary": false, "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_currency_deleted_at\" ON \"store_currency\" (deleted_at) WHERE deleted_at IS NOT NULL" + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_currency_deleted_at\" ON \"store_currency\" (deleted_at) WHERE deleted_at IS NULL" }, { "keyName": "store_currency_pkey", - "columnNames": ["id"], + "columnNames": [ + "id" + ], "composite": false, "primary": true, "unique": true @@ -209,9 +223,13 @@ "foreignKeys": { "store_currency_store_id_foreign": { "constraintName": "store_currency_store_id_foreign", - "columnNames": ["store_id"], + "columnNames": [ + "store_id" + ], "localTableName": "public.store_currency", - "referencedColumnNames": ["id"], + "referencedColumnNames": [ + "id" + ], "referencedTableName": "public.store", "deleteRule": "cascade", "updateRule": "cascade" diff --git a/packages/modules/store/src/migrations/Migration20241206083313.ts b/packages/modules/store/src/migrations/Migration20241206083313.ts new file mode 100644 index 0000000000000..de8556efea7a4 --- /dev/null +++ b/packages/modules/store/src/migrations/Migration20241206083313.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20241206083313 extends Migration { + + async up(): Promise { + this.addSql('CREATE INDEX IF NOT EXISTS "IDX_store_currency_store_id" ON "store_currency" (store_id) WHERE deleted_at IS NULL;'); + } + + async down(): Promise { + this.addSql('drop index if exists "IDX_store_currency_store_id";'); + } + +} diff --git a/packages/modules/store/src/models/currency.ts b/packages/modules/store/src/models/currency.ts index e62286f141fdf..ae37ba6092aec 100644 --- a/packages/modules/store/src/models/currency.ts +++ b/packages/modules/store/src/models/currency.ts @@ -1,81 +1,15 @@ -import { - DALUtils, - Searchable, - createPsqlIndexStatementHelper, - generateEntityId, -} from "@medusajs/framework/utils" - -import { - BeforeCreate, - Entity, - OnInit, - PrimaryKey, - Property, - Filter, - ManyToOne, -} from "@mikro-orm/core" +import { model } from "@medusajs/framework/utils" import Store from "./store" -const StoreCurrencyDeletedAtIndex = createPsqlIndexStatementHelper({ - tableName: "store_currency", - columns: "deleted_at", - where: "deleted_at IS NOT NULL", +const StoreCurrency = model.define("StoreCurrency", { + id: model.id({ prefix: "stocur" }).primaryKey(), + currency_code: model.text().searchable(), + is_default: model.boolean().default(false), + store: model + .belongsTo(() => Store, { + mappedBy: "supported_currencies", + }) + .nullable(), }) -@Entity() -@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) -export default class StoreCurrency { - @PrimaryKey({ columnType: "text" }) - id: string - - @Searchable() - @Property({ columnType: "text" }) - currency_code: string - - @Property({ columnType: "boolean", default: false }) - is_default?: boolean - - @ManyToOne(() => Store, { - columnType: "text", - fieldName: "store_id", - mapToPk: true, - nullable: true, - onDelete: "cascade", - }) - store_id: string | null - - @ManyToOne(() => Store, { - persist: false, - nullable: true, - }) - store: Store | null - - @Property({ - onCreate: () => new Date(), - columnType: "timestamptz", - defaultRaw: "now()", - }) - created_at: Date - - @Property({ - onCreate: () => new Date(), - onUpdate: () => new Date(), - columnType: "timestamptz", - defaultRaw: "now()", - }) - updated_at: Date - - @StoreCurrencyDeletedAtIndex.MikroORMIndex() - @Property({ columnType: "timestamptz", nullable: true }) - deleted_at: Date | null = null - - @BeforeCreate() - onCreate() { - this.id = generateEntityId(this.id, "stocur") - } - - @OnInit() - onInit() { - this.id = generateEntityId(this.id, "stocur") - } -} +export default StoreCurrency diff --git a/packages/modules/store/src/models/store.ts b/packages/modules/store/src/models/store.ts index 86df01dbf38de..7a19e130aecab 100644 --- a/packages/modules/store/src/models/store.ts +++ b/packages/modules/store/src/models/store.ts @@ -1,89 +1,20 @@ -import { - DALUtils, - Searchable, - createPsqlIndexStatementHelper, - generateEntityId, -} from "@medusajs/framework/utils" - -import { DAL } from "@medusajs/framework/types" - -import { - BeforeCreate, - Entity, - OnInit, - PrimaryKey, - Property, - Filter, - OptionalProps, - OneToMany, - Collection, - Cascade, -} from "@mikro-orm/core" +import { model } from "@medusajs/framework/utils" import StoreCurrency from "./currency" -type StoreOptionalProps = DAL.SoftDeletableModelDateColumns - -const StoreDeletedAtIndex = createPsqlIndexStatementHelper({ - tableName: "store", - columns: "deleted_at", - where: "deleted_at IS NOT NULL", -}) - -@Entity() -@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) -export default class Store { - [OptionalProps]?: StoreOptionalProps - - @PrimaryKey({ columnType: "text" }) - id: string - - @Searchable() - @Property({ columnType: "text", default: "Medusa Store" }) - name: string - - @OneToMany(() => StoreCurrency, (o) => o.store, { - cascade: [Cascade.PERSIST, "soft-remove"] as any, - }) - supported_currencies = new Collection(this) - - @Property({ columnType: "text", nullable: true }) - default_sales_channel_id: string | null = null - - @Property({ columnType: "text", nullable: true }) - default_region_id: string | null = null - - @Property({ columnType: "text", nullable: true }) - default_location_id: string | null = null - - @Property({ columnType: "jsonb", nullable: true }) - metadata: Record | null = null - - @Property({ - onCreate: () => new Date(), - columnType: "timestamptz", - defaultRaw: "now()", +const Store = model + .define("Store", { + id: model.id({ prefix: "store" }).primaryKey(), + name: model.text().default("Medusa Store").searchable(), + default_sales_channel_id: model.text().nullable(), + default_region_id: model.text().nullable(), + default_location_id: model.text().nullable(), + metadata: model.json().nullable(), + supported_currencies: model.hasMany(() => StoreCurrency, { + mappedBy: "store", + }), }) - created_at: Date - - @Property({ - onCreate: () => new Date(), - onUpdate: () => new Date(), - columnType: "timestamptz", - defaultRaw: "now()", + .cascades({ + delete: ["supported_currencies"], }) - updated_at: Date - - @StoreDeletedAtIndex.MikroORMIndex() - @Property({ columnType: "timestamptz", nullable: true }) - deleted_at: Date | null = null - - @BeforeCreate() - onCreate() { - this.id = generateEntityId(this.id, "store") - } - @OnInit() - onInit() { - this.id = generateEntityId(this.id, "store") - } -} +export default Store diff --git a/packages/modules/store/src/services/store-module-service.ts b/packages/modules/store/src/services/store-module-service.ts index 5f8d422ce06d0..c4654465bf28b 100644 --- a/packages/modules/store/src/services/store-module-service.ts +++ b/packages/modules/store/src/services/store-module-service.ts @@ -1,6 +1,7 @@ import { Context, DAL, + InferEntityType, InternalModuleDeclaration, IStoreModuleService, ModulesSdkTypes, @@ -34,7 +35,9 @@ export default class StoreModuleService implements IStoreModuleService { protected baseRepository_: DAL.RepositoryService - protected readonly storeService_: ModulesSdkTypes.IMedusaInternalService + protected readonly storeService_: ModulesSdkTypes.IMedusaInternalService< + InferEntityType + > constructor( { baseRepository, storeService }: InjectedDependencies, @@ -73,7 +76,7 @@ export default class StoreModuleService async create_( data: StoreTypes.CreateStoreDTO[], @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise[]> { let normalizedInput = StoreModuleService.normalizeInput(data) StoreModuleService.validateCreateRequest(normalizedInput) @@ -107,7 +110,7 @@ export default class StoreModuleService (store): store is StoreTypes.CreateStoreDTO => !store.id ) - const operations: Promise[] = [] + const operations: Promise[]>[] = [] if (forCreate.length) { operations.push(this.create_(forCreate, sharedContext)) @@ -168,7 +171,7 @@ export default class StoreModuleService protected async update_( data: UpdateStoreInput[], @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise[]> { const normalizedInput = StoreModuleService.normalizeInput(data) StoreModuleService.validateUpdateRequest(normalizedInput) From f65a3cc06df9faca3615f30eb4d3bf889efa5765 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Fri, 6 Dec 2024 09:16:02 -0300 Subject: [PATCH 03/13] fix(utils): avoid optional properties on graphql generated file (#10476) FIXES: SUP-367 --- .changeset/green-pants-remain.md | 5 ++++ .../utils/src/graphql/graphql-to-ts-types.ts | 25 +++++++++++-------- 2 files changed, 19 insertions(+), 11 deletions(-) create mode 100644 .changeset/green-pants-remain.md diff --git a/.changeset/green-pants-remain.md b/.changeset/green-pants-remain.md new file mode 100644 index 0000000000000..6875fb1cc0eab --- /dev/null +++ b/.changeset/green-pants-remain.md @@ -0,0 +1,5 @@ +--- +"@medusajs/utils": patch +--- + +fix: avoid optional fields on graphql generated types diff --git a/packages/core/utils/src/graphql/graphql-to-ts-types.ts b/packages/core/utils/src/graphql/graphql-to-ts-types.ts index b449f6f252f80..7082ae0277e3e 100644 --- a/packages/core/utils/src/graphql/graphql-to-ts-types.ts +++ b/packages/core/utils/src/graphql/graphql-to-ts-types.ts @@ -1,7 +1,7 @@ import { codegen } from "@graphql-codegen/core" -import { type GraphQLSchema, parse, printSchema } from "graphql" import * as typescriptPlugin from "@graphql-codegen/typescript" import { ModuleJoinerConfig } from "@medusajs/types" +import { type GraphQLSchema, parse, printSchema } from "graphql" import { FileSystem } from "../common" function buildEntryPointsTypeMap({ @@ -13,15 +13,15 @@ function buildEntryPointsTypeMap({ }): { entryPoint: string; entityType: any }[] { // build map entry point to there type to be merged and used by the remote query - return joinerConfigs - .flatMap((config) => { - const aliases = Array.isArray(config.alias) - ? config.alias - : config.alias - ? [config.alias] - : [] + return joinerConfigs.flatMap((config) => { + const aliases = Array.isArray(config.alias) + ? config.alias + : config.alias + ? [config.alias] + : [] - return aliases.flatMap((alias) => { + return aliases + .flatMap((alias) => { const names = Array.isArray(alias.name) ? alias.name : [alias.name] const entity = alias?.["entity"] return names.map((aliasItem) => { @@ -35,8 +35,8 @@ function buildEntryPointsTypeMap({ } }) }) - }) - .filter(Boolean) + .filter(Boolean) + }) } async function generateTypes({ @@ -111,6 +111,9 @@ export async function gqlSchemaToTypes({ output: "Record", }, }, + avoidOptionals: { + field: true, // Avoid optional fields in types + }, }, filename: "", schema: parse(printSchema(schema as any)), From b0448a7c35135f5a0e41e0195a507b10ce4b2131 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Fri, 6 Dec 2024 10:14:14 -0300 Subject: [PATCH 04/13] chore: locking-postgres provider dml (#10478) --- .changeset/silly-waves-add.md | 5 + .../__fixtures__/remote-query-type.ts | 143 +++++++++--------- .../index-service-entry-points.ts | 22 +-- .../modules-sdk/__fixtures__/remote-query.ts | 120 +++++++-------- .../locking-postgres/src/models/locking.ts | 35 +---- 5 files changed, 153 insertions(+), 172 deletions(-) create mode 100644 .changeset/silly-waves-add.md diff --git a/.changeset/silly-waves-add.md b/.changeset/silly-waves-add.md new file mode 100644 index 0000000000000..7fad40b649f60 --- /dev/null +++ b/.changeset/silly-waves-add.md @@ -0,0 +1,5 @@ +--- +"@medusajs/locking-postgres": patch +--- + +chore: locking-postgres provider to DML diff --git a/packages/core/modules-sdk/src/remote-query/__fixtures__/remote-query-type.ts b/packages/core/modules-sdk/src/remote-query/__fixtures__/remote-query-type.ts index 43d66a6750de5..a4889a5e30ed5 100644 --- a/packages/core/modules-sdk/src/remote-query/__fixtures__/remote-query-type.ts +++ b/packages/core/modules-sdk/src/remote-query/__fixtures__/remote-query-type.ts @@ -18,7 +18,6 @@ export type Incremental = | { [P in keyof T]?: P extends " $fragmentName" | "__typename" ? T[P] : never } - /** All built-in and custom scalars, mapped to their actual values */ export type Scalars = { ID: { input: string; output: string } @@ -33,232 +32,232 @@ export type Scalars = { export type SimpleProduct = { id: Scalars["ID"]["output"] handle: string - title?: Scalars["String"]["output"] - variants?: Maybe>>> - sales_channels_link?: Array< + title: Scalars["String"]["output"] + variants: Maybe>>> + sales_channels_link: Array< Pick > - sales_channels?: Array> + sales_channels: Array> } export type Product = { - __typename?: "Product" + __typename: "Product" id: Scalars["ID"]["output"] handle: Scalars["String"]["output"] title: Scalars["String"]["output"] - description?: Scalars["String"]["output"] - variants?: Array - sales_channels_link?: Array - sales_channels?: Array - metadata?: Maybe - translation?: Maybe - categories?: Array + description: Scalars["String"]["output"] + variants: Array + sales_channels_link: Array + sales_channels: Array + metadata: Maybe + translation: Maybe + categories: Array } export type ProductTranslation = { - __typename?: "ProductTranslation" + __typename: "ProductTranslation" id: Scalars["ID"]["output"] title: Scalars["String"]["output"] description: Scalars["String"]["output"] - product?: Maybe + product: Maybe } export type ProductVariant = { - __typename?: "ProductVariant" + __typename: "ProductVariant" id: Scalars["ID"]["output"] handle: Scalars["String"]["output"] title: Scalars["String"]["output"] sku: Scalars["String"]["output"] - product?: Maybe - calculated_price?: Maybe - translation?: Maybe + product: Maybe + calculated_price: Maybe + translation: Maybe } export type ProductVariantTranslation = { - __typename?: "ProductVariantTranslation" + __typename: "ProductVariantTranslation" id: Scalars["ID"]["output"] title: Scalars["String"]["output"] description: Scalars["String"]["output"] - variant?: Maybe + variant: Maybe } export type ProductCategory = { - __typename?: "ProductCategory" + __typename: "ProductCategory" id: Scalars["ID"]["output"] handle: Scalars["String"]["output"] - title?: Maybe - translation?: Maybe + title: Maybe + translation: Maybe } export type ProductCategoryTranslation = { - __typename?: "ProductCategoryTranslation" + __typename: "ProductCategoryTranslation" id: Scalars["ID"]["output"] title: Scalars["String"]["output"] description: Scalars["String"]["output"] - category?: Maybe + category: Maybe } export type SalesChannel = { - __typename?: "SalesChannel" + __typename: "SalesChannel" id: Scalars["ID"]["output"] - name?: Maybe - description?: Maybe - created_at?: Maybe - updated_at?: Maybe - products_link?: Maybe>> - api_keys_link?: Maybe>> - locations_link?: Maybe>> + name: Maybe + description: Maybe + created_at: Maybe + updated_at: Maybe + products_link: Maybe>> + api_keys_link: Maybe>> + locations_link: Maybe>> } export type LinkCartPaymentCollection = { - __typename?: "LinkCartPaymentCollection" + __typename: "LinkCartPaymentCollection" cart_id: Scalars["String"]["output"] payment_collection_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkCartPromotion = { - __typename?: "LinkCartPromotion" + __typename: "LinkCartPromotion" cart_id: Scalars["String"]["output"] promotion_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkLocationFulfillmentProvider = { - __typename?: "LinkLocationFulfillmentProvider" + __typename: "LinkLocationFulfillmentProvider" stock_location_id: Scalars["String"]["output"] fulfillment_provider_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkLocationFulfillmentSet = { - __typename?: "LinkLocationFulfillmentSet" + __typename: "LinkLocationFulfillmentSet" stock_location_id: Scalars["String"]["output"] fulfillment_set_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkOrderCart = { - __typename?: "LinkOrderCart" + __typename: "LinkOrderCart" order_id: Scalars["String"]["output"] cart_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkOrderFulfillment = { - __typename?: "LinkOrderFulfillment" + __typename: "LinkOrderFulfillment" order_id: Scalars["String"]["output"] fulfillment_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkOrderPaymentCollection = { - __typename?: "LinkOrderPaymentCollection" + __typename: "LinkOrderPaymentCollection" order_id: Scalars["String"]["output"] payment_collection_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkOrderPromotion = { - __typename?: "LinkOrderPromotion" + __typename: "LinkOrderPromotion" order_id: Scalars["String"]["output"] promotion_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkReturnFulfillment = { - __typename?: "LinkReturnFulfillment" + __typename: "LinkReturnFulfillment" return_id: Scalars["String"]["output"] fulfillment_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkProductSalesChannel = { - __typename?: "LinkProductSalesChannel" + __typename: "LinkProductSalesChannel" product_id: Scalars["String"]["output"] sales_channel_id: Scalars["String"]["output"] - product?: Maybe - sales_channel?: Maybe + product: Maybe + sales_channel: Maybe createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkProductVariantInventoryItem = { - __typename?: "LinkProductVariantInventoryItem" + __typename: "LinkProductVariantInventoryItem" variant_id: Scalars["String"]["output"] inventory_item_id: Scalars["String"]["output"] required_quantity: Scalars["Int"]["output"] - variant?: Maybe + variant: Maybe createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkProductVariantPriceSet = { - __typename?: "LinkProductVariantPriceSet" + __typename: "LinkProductVariantPriceSet" variant_id: Scalars["String"]["output"] price_set_id: Scalars["String"]["output"] - variant?: Maybe + variant: Maybe createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkPublishableApiKeySalesChannel = { - __typename?: "LinkPublishableApiKeySalesChannel" + __typename: "LinkPublishableApiKeySalesChannel" publishable_key_id: Scalars["String"]["output"] sales_channel_id: Scalars["String"]["output"] - sales_channel?: Maybe + sales_channel: Maybe createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkRegionPaymentProvider = { - __typename?: "LinkRegionPaymentProvider" + __typename: "LinkRegionPaymentProvider" region_id: Scalars["String"]["output"] payment_provider_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkSalesChannelStockLocation = { - __typename?: "LinkSalesChannelStockLocation" + __typename: "LinkSalesChannelStockLocation" sales_channel_id: Scalars["String"]["output"] stock_location_id: Scalars["String"]["output"] - sales_channel?: Maybe + sales_channel: Maybe createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkShippingOptionPriceSet = { - __typename?: "LinkShippingOptionPriceSet" + __typename: "LinkShippingOptionPriceSet" shipping_option_id: Scalars["String"]["output"] price_set_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export interface FixtureEntryPoints { diff --git a/packages/core/types/src/index-data/__fixtures__/index-service-entry-points.ts b/packages/core/types/src/index-data/__fixtures__/index-service-entry-points.ts index 42aec29043a3c..d4e469b903ab2 100644 --- a/packages/core/types/src/index-data/__fixtures__/index-service-entry-points.ts +++ b/packages/core/types/src/index-data/__fixtures__/index-service-entry-points.ts @@ -28,23 +28,23 @@ export type Scalars = { } export type Product = { - __typename?: "Product" - id?: Maybe - title?: Maybe - variants?: Maybe>> + __typename: "Product" + id: Maybe + title: Maybe + variants: Maybe>> } export type ProductVariant = { - __typename?: "ProductVariant" - id?: Maybe - product_id?: Maybe - sku?: Maybe - prices?: Maybe>> + __typename: "ProductVariant" + id: Maybe + product_id: Maybe + sku: Maybe + prices: Maybe>> } export type Price = { - __typename?: "Price" - amount?: Maybe + __typename: "Price" + amount: Maybe } export interface FixtureEntryPoints { diff --git a/packages/core/types/src/modules-sdk/__fixtures__/remote-query.ts b/packages/core/types/src/modules-sdk/__fixtures__/remote-query.ts index 5169ee35fb4c3..8902a41d83c7f 100644 --- a/packages/core/types/src/modules-sdk/__fixtures__/remote-query.ts +++ b/packages/core/types/src/modules-sdk/__fixtures__/remote-query.ts @@ -31,207 +31,207 @@ export type Scalars = { } export type SimpleProduct = { - __typename?: "SimpleProduct" + __typename: "SimpleProduct" id: Scalars["ID"]["output"] handle: string - title?: Scalars["String"]["output"] - variants?: Maybe>>> - sales_channels_link?: Array< + title: Scalars["String"]["output"] + variants: Maybe>>> + sales_channels_link: Array< Pick< LinkProductSalesChannel, "product_id" | "sales_channel_id" | "__typename" > > - sales_channels?: Array> + sales_channels: Array> } export type Product = { - __typename?: "Product" + __typename: "Product" id: Scalars["ID"]["output"] handle: Scalars["String"]["output"] title: Scalars["String"]["output"] - description?: Scalars["String"]["output"] - variants?: Array - sales_channels_link?: Array - sales_channels?: Array + description: Scalars["String"]["output"] + variants: Array + sales_channels_link: Array + sales_channels: Array } export type ProductVariant = { - __typename?: "ProductVariant" + __typename: "ProductVariant" id: Scalars["ID"]["output"] handle: Scalars["String"]["output"] title: Scalars["String"]["output"] - product?: Maybe + product: Maybe } export type ProductCategory = { - __typename?: "ProductCategory" + __typename: "ProductCategory" id: Scalars["ID"]["output"] handle: Scalars["String"]["output"] - title?: Maybe + title: Maybe } export type SalesChannel = { - __typename?: "SalesChannel" + __typename: "SalesChannel" id: Scalars["ID"]["output"] - name?: Maybe - description?: Maybe - created_at?: Maybe - updated_at?: Maybe - products_link?: Maybe>> - api_keys_link?: Maybe>> - locations_link?: Maybe>> + name: Maybe + description: Maybe + created_at: Maybe + updated_at: Maybe + products_link: Maybe>> + api_keys_link: Maybe>> + locations_link: Maybe>> } export type LinkCartPaymentCollection = { - __typename?: "LinkCartPaymentCollection" + __typename: "LinkCartPaymentCollection" cart_id: Scalars["String"]["output"] payment_collection_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkCartPromotion = { - __typename?: "LinkCartPromotion" + __typename: "LinkCartPromotion" cart_id: Scalars["String"]["output"] promotion_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkLocationFulfillmentProvider = { - __typename?: "LinkLocationFulfillmentProvider" + __typename: "LinkLocationFulfillmentProvider" stock_location_id: Scalars["String"]["output"] fulfillment_provider_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkLocationFulfillmentSet = { - __typename?: "LinkLocationFulfillmentSet" + __typename: "LinkLocationFulfillmentSet" stock_location_id: Scalars["String"]["output"] fulfillment_set_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkOrderCart = { - __typename?: "LinkOrderCart" + __typename: "LinkOrderCart" order_id: Scalars["String"]["output"] cart_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkOrderFulfillment = { - __typename?: "LinkOrderFulfillment" + __typename: "LinkOrderFulfillment" order_id: Scalars["String"]["output"] fulfillment_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkOrderPaymentCollection = { - __typename?: "LinkOrderPaymentCollection" + __typename: "LinkOrderPaymentCollection" order_id: Scalars["String"]["output"] payment_collection_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkOrderPromotion = { - __typename?: "LinkOrderPromotion" + __typename: "LinkOrderPromotion" order_id: Scalars["String"]["output"] promotion_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkReturnFulfillment = { - __typename?: "LinkReturnFulfillment" + __typename: "LinkReturnFulfillment" return_id: Scalars["String"]["output"] fulfillment_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkProductSalesChannel = { - __typename?: "LinkProductSalesChannel" + __typename: "LinkProductSalesChannel" product_id: Scalars["String"]["output"] sales_channel_id: Scalars["String"]["output"] - product?: Maybe - sales_channel?: Maybe + product: Maybe + sales_channel: Maybe createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkProductVariantInventoryItem = { - __typename?: "LinkProductVariantInventoryItem" + __typename: "LinkProductVariantInventoryItem" variant_id: Scalars["String"]["output"] inventory_item_id: Scalars["String"]["output"] required_quantity: Scalars["Int"]["output"] - variant?: Maybe + variant: Maybe createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkProductVariantPriceSet = { - __typename?: "LinkProductVariantPriceSet" + __typename: "LinkProductVariantPriceSet" variant_id: Scalars["String"]["output"] price_set_id: Scalars["String"]["output"] - variant?: Maybe + variant: Maybe createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkPublishableApiKeySalesChannel = { - __typename?: "LinkPublishableApiKeySalesChannel" + __typename: "LinkPublishableApiKeySalesChannel" publishable_key_id: Scalars["String"]["output"] sales_channel_id: Scalars["String"]["output"] - sales_channel?: Maybe + sales_channel: Maybe createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkRegionPaymentProvider = { - __typename?: "LinkRegionPaymentProvider" + __typename: "LinkRegionPaymentProvider" region_id: Scalars["String"]["output"] payment_provider_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkSalesChannelStockLocation = { - __typename?: "LinkSalesChannelStockLocation" + __typename: "LinkSalesChannelStockLocation" sales_channel_id: Scalars["String"]["output"] stock_location_id: Scalars["String"]["output"] - sales_channel?: Maybe + sales_channel: Maybe createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export type LinkShippingOptionPriceSet = { - __typename?: "LinkShippingOptionPriceSet" + __typename: "LinkShippingOptionPriceSet" shipping_option_id: Scalars["String"]["output"] price_set_id: Scalars["String"]["output"] createdAt: Scalars["String"]["output"] updatedAt: Scalars["String"]["output"] - deletedAt?: Maybe + deletedAt: Maybe } export interface FixtureEntryPoints { diff --git a/packages/modules/providers/locking-postgres/src/models/locking.ts b/packages/modules/providers/locking-postgres/src/models/locking.ts index 8b9558befbcb6..8868dec65327c 100644 --- a/packages/modules/providers/locking-postgres/src/models/locking.ts +++ b/packages/modules/providers/locking-postgres/src/models/locking.ts @@ -1,32 +1,9 @@ -import { generateEntityId } from "@medusajs/framework/utils" -import { - BeforeCreate, - Entity, - OnInit, - PrimaryKey, - Property, -} from "@mikro-orm/core" +import { model } from "@medusajs/framework/utils" -@Entity({ tableName: "locking" }) -class Locking { - @PrimaryKey({ columnType: "text" }) - id!: string - - @Property({ columnType: "text", nullable: true }) - owner_id: string | null = null - - @Property({ columnType: "timestamptz", nullable: true }) - expiration: Date | null = null - - @BeforeCreate() - onCreate() { - this.id = generateEntityId(this.id, "lk") - } - - @OnInit() - onInit() { - this.id = generateEntityId(this.id, "lk") - } -} +const Locking = model.define("Locking", { + id: model.id({ prefix: "lk" }).primaryKey(), + owner_id: model.text().nullable(), + expiration: model.dateTime().nullable(), +}) export default Locking From 0a077d48e14976bafcf19705fc48e66756362fd6 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Fri, 6 Dec 2024 14:23:07 +0100 Subject: [PATCH 05/13] chore(workflow-engine): Migrate to DML (#10477) RESOLVES FRMW-2832 RESOLVES FRMW-2833 **What** Migrate workflow engines to DML. Alos includes and update to the linkable generation which now takes into account id and primary keys to generate the linkable instead of only primary keys --- .changeset/clean-paws-build.md | 7 + .../__tests__/joiner-config-builder.spec.ts | 4 +- .../src/modules-sdk/joiner-config-builder.ts | 7 +- .../integration-tests/__tests__/index.spec.ts | 115 +++++++----- .../.snapshot-medusa-workflows.json | 171 ++++++++++++++++++ .../src/migrations/Migration20241206101446.ts | 27 +++ .../src/models/index.ts | 2 +- .../src/models/workflow-execution.ts | 102 +++-------- .../src/services/workflows-module.ts | 7 +- .../integration-tests/__tests__/index.spec.ts | 123 +++++++------ .../integration-tests/utils/database.ts | 4 +- .../.snapshot-medusa-workflows.json | 163 +++++++++++++++++ .../src/migrations/Migration20241206123341.ts | 27 +++ .../workflow-engine-redis/src/models/index.ts | 2 +- .../src/models/workflow-execution.ts | 102 +++-------- .../src/services/workflows-module.ts | 7 +- 16 files changed, 610 insertions(+), 260 deletions(-) create mode 100644 .changeset/clean-paws-build.md create mode 100644 packages/modules/workflow-engine-inmemory/src/migrations/.snapshot-medusa-workflows.json create mode 100644 packages/modules/workflow-engine-inmemory/src/migrations/Migration20241206101446.ts create mode 100644 packages/modules/workflow-engine-redis/src/migrations/.snapshot-medusa-workflows.json create mode 100644 packages/modules/workflow-engine-redis/src/migrations/Migration20241206123341.ts diff --git a/.changeset/clean-paws-build.md b/.changeset/clean-paws-build.md new file mode 100644 index 0000000000000..95b3498134ba7 --- /dev/null +++ b/.changeset/clean-paws-build.md @@ -0,0 +1,7 @@ +--- +"@medusajs/workflow-engine-inmemory": patch +"@medusajs/workflow-engine-redis": patch +"@medusajs/utils": patch +--- + +chore(workflow-engine): Migrate to DML diff --git a/packages/core/utils/src/modules-sdk/__tests__/joiner-config-builder.spec.ts b/packages/core/utils/src/modules-sdk/__tests__/joiner-config-builder.spec.ts index e7e93dfc5d64a..dcdfe6c00efb7 100644 --- a/packages/core/utils/src/modules-sdk/__tests__/joiner-config-builder.spec.ts +++ b/packages/core/utils/src/modules-sdk/__tests__/joiner-config-builder.spec.ts @@ -668,8 +668,8 @@ describe("joiner-config-builder", () => { serviceName: "myService", field: "car", entity: "Car", - linkable: "car_number_plate", - primaryKey: "number_plate", + linkable: "car_id", + primaryKey: "id", }) expect(linkConfig.user.toJSON()).toEqual({ serviceName: "myService", diff --git a/packages/core/utils/src/modules-sdk/joiner-config-builder.ts b/packages/core/utils/src/modules-sdk/joiner-config-builder.ts index 8c5809b6fbbba..b31278a88b5ae 100644 --- a/packages/core/utils/src/modules-sdk/joiner-config-builder.ts +++ b/packages/core/utils/src/modules-sdk/joiner-config-builder.ts @@ -17,7 +17,7 @@ import { toCamelCase, upperCaseFirst, } from "../common" -import { DmlEntity } from "../dml" +import { DmlEntity, IdProperty } from "../dml" import { toGraphQLSchema } from "../dml/helpers/create-graphql" import { PrimaryKeyModifier } from "../dml/properties/primary-key" import { BaseRelationship } from "../dml/relations/base" @@ -396,7 +396,10 @@ export function buildLinkConfigFromModelObjects< } const parsedProperty = (value as PropertyType).parse(property) - if (PrimaryKeyModifier.isPrimaryKeyModifier(value)) { + if ( + PrimaryKeyModifier.isPrimaryKeyModifier(value) || + IdProperty.isIdProperty(value) + ) { const linkableKeyName = parsedProperty.dataType.options?.linkable ?? `${camelToSnakeCase(model.name).toLowerCase()}_${property}` diff --git a/packages/modules/workflow-engine-inmemory/integration-tests/__tests__/index.spec.ts b/packages/modules/workflow-engine-inmemory/integration-tests/__tests__/index.spec.ts index e499e9381b37f..765d64facacbe 100644 --- a/packages/modules/workflow-engine-inmemory/integration-tests/__tests__/index.spec.ts +++ b/packages/modules/workflow-engine-inmemory/integration-tests/__tests__/index.spec.ts @@ -1,4 +1,7 @@ -import { WorkflowManager } from "@medusajs/framework/orchestration" +import { + DistributedTransactionType, + WorkflowManager, +} from "@medusajs/framework/orchestration" import { Context, IWorkflowEngineService, @@ -60,6 +63,20 @@ moduleIntegrationTestRunner({ serviceName: "workflows", field: "workflowExecution", }, + transaction_id: { + linkable: "workflow_execution_transaction_id", + entity: "WorkflowExecution", + primaryKey: "transaction_id", + serviceName: "workflows", + field: "workflowExecution", + }, + workflow_id: { + linkable: "workflow_execution_workflow_id", + entity: "WorkflowExecution", + primaryKey: "workflow_id", + serviceName: "workflows", + field: "workflowExecution", + }, }, }) }) @@ -87,12 +104,12 @@ moduleIntegrationTestRunner({ }) // Validate context event group id - expect(workflowEventGroupIdStep1Mock.mock.calls[0][1]).toEqual( - expect.objectContaining({ eventGroupId }) - ) - expect(workflowEventGroupIdStep2Mock.mock.calls[0][1]).toEqual( - expect.objectContaining({ eventGroupId }) - ) + expect( + (workflowEventGroupIdStep1Mock.mock.calls[0] as any[])[1] + ).toEqual(expect.objectContaining({ eventGroupId })) + expect( + (workflowEventGroupIdStep2Mock.mock.calls[0] as any[])[1] + ).toEqual(expect.objectContaining({ eventGroupId })) }) it("should execute an async workflow keeping track of the event group id that has been auto generated", async () => { @@ -114,14 +131,19 @@ moduleIntegrationTestRunner({ stepResponse: { hey: "oh" }, }) - const generatedEventGroupId = (workflowEventGroupIdStep1Mock.mock - .calls[0][1] as unknown as Context)!.eventGroupId + const generatedEventGroupId = (( + workflowEventGroupIdStep1Mock.mock.calls[0] as any[] + )[1] as unknown as Context)!.eventGroupId // Validate context event group id - expect(workflowEventGroupIdStep1Mock.mock.calls[0][1]).toEqual( + expect( + (workflowEventGroupIdStep1Mock.mock.calls[0] as any[])[1] + ).toEqual( expect.objectContaining({ eventGroupId: generatedEventGroupId }) ) - expect(workflowEventGroupIdStep2Mock.mock.calls[0][1]).toEqual( + expect( + (workflowEventGroupIdStep2Mock.mock.calls[0] as any[])[1] + ).toEqual( expect.objectContaining({ eventGroupId: generatedEventGroupId }) ) }) @@ -139,10 +161,9 @@ moduleIntegrationTestRunner({ throwOnError: true, }) - let executionsList = await query({ - workflow_executions: { - fields: ["workflow_id", "transaction_id", "state"], - }, + let { data: executionsList } = await query.graph({ + entity: "workflow_executions", + fields: ["workflow_id", "transaction_id", "state"], }) expect(executionsList).toHaveLength(1) @@ -157,11 +178,10 @@ moduleIntegrationTestRunner({ stepResponse: { uhuuuu: "yeaah!" }, }) - executionsList = await query({ - workflow_executions: { - fields: ["id"], - }, - }) + ;({ data: executionsList } = await query.graph({ + entity: "workflow_executions", + fields: ["id"], + })) expect(executionsList).toHaveLength(0) expect(result).toEqual({ @@ -180,10 +200,9 @@ moduleIntegrationTestRunner({ transactionId: "transaction_1", }) - let executionsList = await query({ - workflow_executions: { - fields: ["id"], - }, + let { data: executionsList } = await query.graph({ + entity: "workflow_executions", + fields: ["id"], }) expect(executionsList).toHaveLength(1) @@ -208,40 +227,38 @@ moduleIntegrationTestRunner({ expect(workflow2Step3Invoke.mock.calls[0][0]).toEqual({ uhuuuu: "yeaah!", }) - - executionsList = await query({ - workflow_executions: { - fields: ["id"], - }, - }) + ;({ data: executionsList } = await query.graph({ + entity: "workflow_executions", + fields: ["id"], + })) expect(executionsList).toHaveLength(1) }) it("should revert the entire transaction when a step timeout expires", async () => { - const { transaction } = await workflowOrcModule.run( + const { transaction } = (await workflowOrcModule.run( "workflow_step_timeout", { input: {}, throwOnError: false, } - ) + )) as Awaited<{ transaction: DistributedTransactionType }> - expect(transaction.flow.state).toEqual("reverted") + expect(transaction.getFlow().state).toEqual("reverted") }) it("should revert the entire transaction when the transaction timeout expires", async () => { - const { transaction } = await workflowOrcModule.run( + const { transaction } = (await workflowOrcModule.run( "workflow_transaction_timeout", { input: {}, throwOnError: false, } - ) + )) as Awaited<{ transaction: DistributedTransactionType }> await setTimeoutPromise(200) - expect(transaction.flow.state).toEqual("reverted") + expect(transaction.getFlow().state).toEqual("reverted") }) it.skip("should subscribe to a async workflow and receive the response when it finishes", (done) => { @@ -393,7 +410,7 @@ moduleIntegrationTestRunner({ }) it("should fetch an idempotent workflow after its completion", async () => { - const { transaction: firstRun } = await workflowOrcModule.run( + const { transaction: firstRun } = (await workflowOrcModule.run( "workflow_idempotent", { input: { @@ -402,15 +419,14 @@ moduleIntegrationTestRunner({ throwOnError: true, transactionId: "transaction_1", } - ) + )) as Awaited<{ transaction: DistributedTransactionType }> - let executionsList = await query({ - workflow_executions: { - fields: ["id"], - }, + let { data: executionsList } = await query.graph({ + entity: "workflow_executions", + fields: ["id"], }) - const { transaction: secondRun } = await workflowOrcModule.run( + const { transaction: secondRun } = (await workflowOrcModule.run( "workflow_idempotent", { input: { @@ -419,15 +435,16 @@ moduleIntegrationTestRunner({ throwOnError: true, transactionId: "transaction_1", } - ) + )) as Awaited<{ transaction: DistributedTransactionType }> - const executionsListAfter = await query({ - workflow_executions: { - fields: ["id"], - }, + const { data: executionsListAfter } = await query.graph({ + entity: "workflow_executions", + fields: ["id"], }) - expect(secondRun.flow.startedAt).toEqual(firstRun.flow.startedAt) + expect(secondRun.getFlow().startedAt).toEqual( + firstRun.getFlow().startedAt + ) expect(executionsList).toHaveLength(1) expect(executionsListAfter).toHaveLength(1) }) diff --git a/packages/modules/workflow-engine-inmemory/src/migrations/.snapshot-medusa-workflows.json b/packages/modules/workflow-engine-inmemory/src/migrations/.snapshot-medusa-workflows.json new file mode 100644 index 0000000000000..0c8c584c91589 --- /dev/null +++ b/packages/modules/workflow-engine-inmemory/src/migrations/.snapshot-medusa-workflows.json @@ -0,0 +1,171 @@ +{ + "namespaces": [ + "public" + ], + "name": "public", + "tables": [ + { + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "transaction_id": { + "name": "transaction_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "execution": { + "name": "execution", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "context": { + "name": "context", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "state": { + "name": "state", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "enumItems": [ + "not_started", + "invoking", + "waiting_to_compensate", + "compensating", + "done", + "reverted", + "failed" + ], + "mappedType": "enum" + }, + "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": "workflow_execution", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_workflow_execution_deleted_at", + "columnNames": [], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_workflow_execution_deleted_at\" ON \"workflow_execution\" (deleted_at) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_workflow_execution_id", + "columnNames": [], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_workflow_execution_id\" ON \"workflow_execution\" (id) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_workflow_execution_workflow_id_transaction_id_unique", + "columnNames": [], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_workflow_execution_workflow_id_transaction_id_unique\" ON \"workflow_execution\" (workflow_id, transaction_id) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_workflow_execution_workflow_id", + "columnNames": [], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_workflow_execution_workflow_id\" ON \"workflow_execution\" (workflow_id) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_workflow_execution_transaction_id", + "columnNames": [], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_workflow_execution_transaction_id\" ON \"workflow_execution\" (transaction_id) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_workflow_execution_state", + "columnNames": [], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_workflow_execution_state\" ON \"workflow_execution\" (state) WHERE deleted_at IS NULL" + }, + { + "keyName": "workflow_execution_pkey", + "columnNames": [ + "workflow_id", + "transaction_id" + ], + "composite": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {} + } + ] +} diff --git a/packages/modules/workflow-engine-inmemory/src/migrations/Migration20241206101446.ts b/packages/modules/workflow-engine-inmemory/src/migrations/Migration20241206101446.ts new file mode 100644 index 0000000000000..be8b1cbac182a --- /dev/null +++ b/packages/modules/workflow-engine-inmemory/src/migrations/Migration20241206101446.ts @@ -0,0 +1,27 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20241206101446 extends Migration { + async up(): Promise { + this.addSql( + `DROP INDEX IF EXISTS "IDX_workflow_execution_id"; + DROP INDEX IF EXISTS "IDX_workflow_execution_workflow_id"; + DROP INDEX IF EXISTS "IDX_workflow_execution_transaction_id"; + DROP INDEX IF EXISTS "IDX_workflow_execution_state";` + ) + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_workflow_execution_deleted_at" ON "workflow_execution" (deleted_at) WHERE deleted_at IS NULL;' + ) + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_workflow_execution_id" ON "workflow_execution" (id) WHERE deleted_at IS NULL;' + ) + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_workflow_execution_workflow_id" ON "workflow_execution" (workflow_id) WHERE deleted_at IS NULL;' + ) + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_workflow_execution_transaction_id" ON "workflow_execution" (transaction_id) WHERE deleted_at IS NULL;' + ) + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_workflow_execution_state" ON "workflow_execution" (state) WHERE deleted_at IS NULL;' + ) + } +} diff --git a/packages/modules/workflow-engine-inmemory/src/models/index.ts b/packages/modules/workflow-engine-inmemory/src/models/index.ts index 78fcbfa9214f9..fa5b8a3dd019e 100644 --- a/packages/modules/workflow-engine-inmemory/src/models/index.ts +++ b/packages/modules/workflow-engine-inmemory/src/models/index.ts @@ -1 +1 @@ -export { default as WorkflowExecution } from "./workflow-execution" +export { WorkflowExecution } from "./workflow-execution" diff --git a/packages/modules/workflow-engine-inmemory/src/models/workflow-execution.ts b/packages/modules/workflow-engine-inmemory/src/models/workflow-execution.ts index 22e693d4283eb..c41bc8936ed1b 100644 --- a/packages/modules/workflow-engine-inmemory/src/models/workflow-execution.ts +++ b/packages/modules/workflow-engine-inmemory/src/models/workflow-execution.ts @@ -1,76 +1,30 @@ import { TransactionState } from "@medusajs/framework/orchestration" -import { DALUtils, generateEntityId } from "@medusajs/framework/utils" -import { - BeforeCreate, - Entity, - Enum, - Filter, - Index, - OnInit, - OptionalProps, - PrimaryKey, - Property, - Unique, -} from "@mikro-orm/core" - -type OptionalFields = "deleted_at" - -@Entity() -@Unique({ - name: "IDX_workflow_execution_workflow_id_transaction_id_unique", - properties: ["workflow_id", "transaction_id"], -}) -@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) -export default class WorkflowExecution { - [OptionalProps]?: OptionalFields - - @Property({ columnType: "text", nullable: false }) - @Index({ name: "IDX_workflow_execution_id" }) - id!: string - - @Index({ name: "IDX_workflow_execution_workflow_id" }) - @PrimaryKey({ columnType: "text" }) - workflow_id: string - - @Index({ name: "IDX_workflow_execution_transaction_id" }) - @PrimaryKey({ columnType: "text" }) - transaction_id: string - - @Property({ columnType: "jsonb", nullable: true }) - execution: Record | null = null - - @Property({ columnType: "jsonb", nullable: true }) - context: Record | null = null - - @Index({ name: "IDX_workflow_execution_state" }) - @Enum(() => TransactionState) - state: TransactionState - - @Property({ - onCreate: () => new Date(), - columnType: "timestamptz", - defaultRaw: "now()", - }) - created_at: Date - - @Property({ - onCreate: () => new Date(), - onUpdate: () => new Date(), - columnType: "timestamptz", - defaultRaw: "now()", +import { model } from "@medusajs/framework/utils" + +export const WorkflowExecution = model + .define("workflow_execution", { + id: model.id({ prefix: "wf_exec" }), + workflow_id: model.text().primaryKey(), + transaction_id: model.text().primaryKey(), + execution: model.json().nullable(), + context: model.json().nullable(), + state: model.enum(TransactionState), }) - updated_at: Date - - @Property({ columnType: "timestamptz", nullable: true }) - deleted_at: Date | null = null - - @BeforeCreate() - onCreate() { - this.id = generateEntityId(this.id, "wf_exec") - } - - @OnInit() - onInit() { - this.id = generateEntityId(this.id, "wf_exec") - } -} + .indexes([ + { + on: ["id"], + where: "deleted_at IS NULL", + }, + { + on: ["workflow_id"], + where: "deleted_at IS NULL", + }, + { + on: ["transaction_id"], + where: "deleted_at IS NULL", + }, + { + on: ["state"], + where: "deleted_at IS NULL", + }, + ]) diff --git a/packages/modules/workflow-engine-inmemory/src/services/workflows-module.ts b/packages/modules/workflow-engine-inmemory/src/services/workflows-module.ts index 9771f48784aac..3b67e36d2a5c8 100644 --- a/packages/modules/workflow-engine-inmemory/src/services/workflows-module.ts +++ b/packages/modules/workflow-engine-inmemory/src/services/workflows-module.ts @@ -1,6 +1,7 @@ import { Context, DAL, + InferEntityType, InternalModuleDeclaration, MedusaContainer, ModulesSdkTypes, @@ -25,9 +26,11 @@ type InjectedDependencies = { } export class WorkflowsModuleService< - TWorkflowExecution extends WorkflowExecution = WorkflowExecution + TWorkflowExecution extends InferEntityType< + typeof WorkflowExecution + > = InferEntityType > extends ModulesSdkUtils.MedusaService<{ - WorkflowExecution: { dto: WorkflowExecution } + WorkflowExecution: { dto: InferEntityType } }>({ WorkflowExecution }) { protected baseRepository_: DAL.RepositoryService protected workflowExecutionService_: ModulesSdkTypes.IMedusaInternalService diff --git a/packages/modules/workflow-engine-redis/integration-tests/__tests__/index.spec.ts b/packages/modules/workflow-engine-redis/integration-tests/__tests__/index.spec.ts index 1e30157a24216..00f60a894afb9 100644 --- a/packages/modules/workflow-engine-redis/integration-tests/__tests__/index.spec.ts +++ b/packages/modules/workflow-engine-redis/integration-tests/__tests__/index.spec.ts @@ -1,10 +1,12 @@ import { + DistributedTransactionType, TransactionStepTimeoutError, TransactionTimeoutError, WorkflowManager, } from "@medusajs/framework/orchestration" import { IWorkflowEngineService, + Logger, MedusaContainer, RemoteQueryFunction, } from "@medusajs/framework/types" @@ -99,6 +101,20 @@ moduleIntegrationTestRunner({ serviceName: "workflows", field: "workflowExecution", }, + transaction_id: { + entity: "WorkflowExecution", + field: "workflowExecution", + linkable: "workflow_execution_transaction_id", + primaryKey: "transaction_id", + serviceName: "workflows", + }, + workflow_id: { + entity: "WorkflowExecution", + field: "workflowExecution", + linkable: "workflow_execution_workflow_id", + primaryKey: "workflow_id", + serviceName: "workflows", + }, }, }) }) @@ -112,10 +128,9 @@ moduleIntegrationTestRunner({ throwOnError: true, }) - let executionsList = await query({ - workflow_executions: { - fields: ["workflow_id", "transaction_id", "state"], - }, + let { data: executionsList } = await query.graph({ + entity: "workflow_executions", + fields: ["workflow_id", "transaction_id", "state"], }) expect(executionsList).toHaveLength(1) @@ -130,11 +145,10 @@ moduleIntegrationTestRunner({ stepResponse: { uhuuuu: "yeaah!" }, }) - executionsList = await query({ - workflow_executions: { - fields: ["id"], - }, - }) + ;({ data: executionsList } = await query.graph({ + entity: "workflow_executions", + fields: ["id"], + })) expect(executionsList).toHaveLength(0) expect(result).toEqual({ @@ -153,10 +167,9 @@ moduleIntegrationTestRunner({ transactionId: "transaction_1", }) - let executionsList = await query({ - workflow_executions: { - fields: ["id"], - }, + let { data: executionsList } = await query.graph({ + entity: "workflow_executions", + fields: ["id"], }) expect(executionsList).toHaveLength(1) @@ -170,12 +183,10 @@ moduleIntegrationTestRunner({ }, stepResponse: { uhuuuu: "yeaah!" }, }) - - executionsList = await query({ - workflow_executions: { - fields: ["id"], - }, - }) + ;({ data: executionsList } = await query.graph({ + entity: "workflow_executions", + fields: ["id"], + })) expect(executionsList).toHaveLength(1) }) @@ -188,10 +199,9 @@ moduleIntegrationTestRunner({ transactionId: "transaction_1", }) - let executionsList = await query({ - workflow_executions: { - fields: ["id"], - }, + let { data: executionsList } = await query.graph({ + entity: "workflow_executions", + fields: ["id"], }) expect(executionsList).toHaveLength(1) @@ -205,12 +215,10 @@ moduleIntegrationTestRunner({ }, stepResponse: { uhuuuu: "yeaah!" }, }) - - executionsList = await query({ - workflow_executions: { - fields: ["id", "state"], - }, - }) + ;({ data: executionsList } = await query.graph({ + entity: "workflow_executions", + fields: ["id", "state"], + })) expect(executionsList).toHaveLength(1) expect(executionsList[0].state).toEqual("reverted") @@ -237,10 +245,9 @@ moduleIntegrationTestRunner({ }, }) - let executionsList = await query({ - workflow_executions: { - fields: ["id"], - }, + let { data: executionsList } = await query.graph({ + entity: "workflow_executions", + fields: ["id"], }) expect(executionsList).toHaveLength(1) @@ -260,12 +267,10 @@ moduleIntegrationTestRunner({ }) expect(setStepError).toEqual({ uhuuuu: "yeaah!" }) - - executionsList = await query({ - workflow_executions: { - fields: ["id", "state", "context"], - }, - }) + ;({ data: executionsList } = await query.graph({ + entity: "workflow_executions", + fields: ["id", "state", "context"], + })) expect(executionsList).toHaveLength(1) expect(executionsList[0].state).toEqual("failed") @@ -273,7 +278,7 @@ moduleIntegrationTestRunner({ }) it("should revert the entire transaction when a step timeout expires", async () => { - const { transaction, result, errors } = await workflowOrcModule.run( + const { transaction, result, errors } = (await workflowOrcModule.run( "workflow_step_timeout", { input: { @@ -282,9 +287,13 @@ moduleIntegrationTestRunner({ throwOnError: false, logOnError: true, } - ) + )) as Awaited<{ + transaction: DistributedTransactionType + result: any + errors: any + }> - expect(transaction.flow.state).toEqual("reverted") + expect(transaction.getFlow().state).toEqual("reverted") expect(result).toEqual({ myInput: "123", }) @@ -294,16 +303,20 @@ moduleIntegrationTestRunner({ }) it("should revert the entire transaction when the transaction timeout expires", async () => { - const { transaction, result, errors } = await workflowOrcModule.run( + const { transaction, result, errors } = (await workflowOrcModule.run( "workflow_transaction_timeout", { input: {}, transactionId: "trx", throwOnError: false, } - ) + )) as Awaited<{ + transaction: DistributedTransactionType + result: any + errors: any + }> - expect(transaction.flow.state).toEqual("reverted") + expect(transaction.getFlow().state).toEqual("reverted") expect(result).toEqual({ executed: true }) expect(errors).toHaveLength(1) expect(errors[0].action).toEqual("step_1") @@ -323,7 +336,7 @@ moduleIntegrationTestRunner({ await setTimeout(200) - const { transaction, result, errors } = await workflowOrcModule.run( + const { transaction, result, errors } = (await workflowOrcModule.run( "workflow_step_timeout_async", { input: { @@ -332,9 +345,13 @@ moduleIntegrationTestRunner({ transactionId: "transaction_1", throwOnError: false, } - ) + )) as Awaited<{ + transaction: DistributedTransactionType + result: any + errors: any + }> - expect(transaction.flow.state).toEqual("reverted") + expect(transaction.getFlow().state).toEqual("reverted") expect(result).toEqual(undefined) expect(errors).toHaveLength(1) expect(errors[0].action).toEqual("step_1_async") @@ -354,16 +371,20 @@ moduleIntegrationTestRunner({ await setTimeout(200) - const { transaction, result, errors } = await workflowOrcModule.run( + const { transaction, result, errors } = (await workflowOrcModule.run( "workflow_transaction_timeout_async", { input: {}, transactionId: "transaction_1", throwOnError: false, } - ) + )) as Awaited<{ + transaction: DistributedTransactionType + result: any + errors: any + }> - expect(transaction.flow.state).toEqual("reverted") + expect(transaction.getFlow().state).toEqual("reverted") expect(result).toEqual(undefined) expect(errors).toHaveLength(1) expect(errors[0].action).toEqual("step_1") diff --git a/packages/modules/workflow-engine-redis/integration-tests/utils/database.ts b/packages/modules/workflow-engine-redis/integration-tests/utils/database.ts index 3fe2da068123a..7b027f5995841 100644 --- a/packages/modules/workflow-engine-redis/integration-tests/utils/database.ts +++ b/packages/modules/workflow-engine-redis/integration-tests/utils/database.ts @@ -15,11 +15,11 @@ const redisUrl = process.env.REDIS_URL || "redis://localhost:6379" const redis = new Redis(redisUrl) interface TestDatabase { - clearTables(knex): Promise + clearTables(): Promise } export const TestDatabase: TestDatabase = { - clearTables: async (knex) => { + clearTables: async () => { await cleanRedis() }, } diff --git a/packages/modules/workflow-engine-redis/src/migrations/.snapshot-medusa-workflows.json b/packages/modules/workflow-engine-redis/src/migrations/.snapshot-medusa-workflows.json new file mode 100644 index 0000000000000..66a9a97c216f0 --- /dev/null +++ b/packages/modules/workflow-engine-redis/src/migrations/.snapshot-medusa-workflows.json @@ -0,0 +1,163 @@ +{ + "namespaces": [ + "public" + ], + "name": "public", + "tables": [ + { + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "transaction_id": { + "name": "transaction_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "execution": { + "name": "execution", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "context": { + "name": "context", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "state": { + "name": "state", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "enumItems": [ + "not_started", + "invoking", + "waiting_to_compensate", + "compensating", + "done", + "reverted", + "failed" + ], + "mappedType": "enum" + }, + "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": "workflow_execution", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_workflow_execution_deleted_at", + "columnNames": [], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_workflow_execution_deleted_at\" ON \"workflow_execution\" (deleted_at) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_workflow_execution_id", + "columnNames": [], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_workflow_execution_id\" ON \"workflow_execution\" (id) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_workflow_execution_workflow_id", + "columnNames": [], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_workflow_execution_workflow_id\" ON \"workflow_execution\" (workflow_id) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_workflow_execution_transaction_id", + "columnNames": [], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_workflow_execution_transaction_id\" ON \"workflow_execution\" (transaction_id) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_workflow_execution_state", + "columnNames": [], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_workflow_execution_state\" ON \"workflow_execution\" (state) WHERE deleted_at IS NULL" + }, + { + "keyName": "workflow_execution_pkey", + "columnNames": [ + "workflow_id", + "transaction_id" + ], + "composite": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {} + } + ] +} diff --git a/packages/modules/workflow-engine-redis/src/migrations/Migration20241206123341.ts b/packages/modules/workflow-engine-redis/src/migrations/Migration20241206123341.ts new file mode 100644 index 0000000000000..50c0680ae90c5 --- /dev/null +++ b/packages/modules/workflow-engine-redis/src/migrations/Migration20241206123341.ts @@ -0,0 +1,27 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20241206123341 extends Migration { + async up(): Promise { + this.addSql( + `DROP INDEX IF EXISTS "IDX_workflow_execution_id"; + DROP INDEX IF EXISTS "IDX_workflow_execution_workflow_id"; + DROP INDEX IF EXISTS "IDX_workflow_execution_transaction_id"; + DROP INDEX IF EXISTS "IDX_workflow_execution_state";` + ) + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_workflow_execution_deleted_at" ON "workflow_execution" (deleted_at) WHERE deleted_at IS NULL;' + ) + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_workflow_execution_id" ON "workflow_execution" (id) WHERE deleted_at IS NULL;' + ) + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_workflow_execution_workflow_id" ON "workflow_execution" (workflow_id) WHERE deleted_at IS NULL;' + ) + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_workflow_execution_transaction_id" ON "workflow_execution" (transaction_id) WHERE deleted_at IS NULL;' + ) + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_workflow_execution_state" ON "workflow_execution" (state) WHERE deleted_at IS NULL;' + ) + } +} diff --git a/packages/modules/workflow-engine-redis/src/models/index.ts b/packages/modules/workflow-engine-redis/src/models/index.ts index 78fcbfa9214f9..fa5b8a3dd019e 100644 --- a/packages/modules/workflow-engine-redis/src/models/index.ts +++ b/packages/modules/workflow-engine-redis/src/models/index.ts @@ -1 +1 @@ -export { default as WorkflowExecution } from "./workflow-execution" +export { WorkflowExecution } from "./workflow-execution" diff --git a/packages/modules/workflow-engine-redis/src/models/workflow-execution.ts b/packages/modules/workflow-engine-redis/src/models/workflow-execution.ts index 22e693d4283eb..c41bc8936ed1b 100644 --- a/packages/modules/workflow-engine-redis/src/models/workflow-execution.ts +++ b/packages/modules/workflow-engine-redis/src/models/workflow-execution.ts @@ -1,76 +1,30 @@ import { TransactionState } from "@medusajs/framework/orchestration" -import { DALUtils, generateEntityId } from "@medusajs/framework/utils" -import { - BeforeCreate, - Entity, - Enum, - Filter, - Index, - OnInit, - OptionalProps, - PrimaryKey, - Property, - Unique, -} from "@mikro-orm/core" - -type OptionalFields = "deleted_at" - -@Entity() -@Unique({ - name: "IDX_workflow_execution_workflow_id_transaction_id_unique", - properties: ["workflow_id", "transaction_id"], -}) -@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) -export default class WorkflowExecution { - [OptionalProps]?: OptionalFields - - @Property({ columnType: "text", nullable: false }) - @Index({ name: "IDX_workflow_execution_id" }) - id!: string - - @Index({ name: "IDX_workflow_execution_workflow_id" }) - @PrimaryKey({ columnType: "text" }) - workflow_id: string - - @Index({ name: "IDX_workflow_execution_transaction_id" }) - @PrimaryKey({ columnType: "text" }) - transaction_id: string - - @Property({ columnType: "jsonb", nullable: true }) - execution: Record | null = null - - @Property({ columnType: "jsonb", nullable: true }) - context: Record | null = null - - @Index({ name: "IDX_workflow_execution_state" }) - @Enum(() => TransactionState) - state: TransactionState - - @Property({ - onCreate: () => new Date(), - columnType: "timestamptz", - defaultRaw: "now()", - }) - created_at: Date - - @Property({ - onCreate: () => new Date(), - onUpdate: () => new Date(), - columnType: "timestamptz", - defaultRaw: "now()", +import { model } from "@medusajs/framework/utils" + +export const WorkflowExecution = model + .define("workflow_execution", { + id: model.id({ prefix: "wf_exec" }), + workflow_id: model.text().primaryKey(), + transaction_id: model.text().primaryKey(), + execution: model.json().nullable(), + context: model.json().nullable(), + state: model.enum(TransactionState), }) - updated_at: Date - - @Property({ columnType: "timestamptz", nullable: true }) - deleted_at: Date | null = null - - @BeforeCreate() - onCreate() { - this.id = generateEntityId(this.id, "wf_exec") - } - - @OnInit() - onInit() { - this.id = generateEntityId(this.id, "wf_exec") - } -} + .indexes([ + { + on: ["id"], + where: "deleted_at IS NULL", + }, + { + on: ["workflow_id"], + where: "deleted_at IS NULL", + }, + { + on: ["transaction_id"], + where: "deleted_at IS NULL", + }, + { + on: ["state"], + where: "deleted_at IS NULL", + }, + ]) diff --git a/packages/modules/workflow-engine-redis/src/services/workflows-module.ts b/packages/modules/workflow-engine-redis/src/services/workflows-module.ts index 37c371356ff59..7463623e94497 100644 --- a/packages/modules/workflow-engine-redis/src/services/workflows-module.ts +++ b/packages/modules/workflow-engine-redis/src/services/workflows-module.ts @@ -1,6 +1,7 @@ import { Context, DAL, + InferEntityType, InternalModuleDeclaration, ModulesSdkTypes, WorkflowsSdkTypes, @@ -25,9 +26,11 @@ type InjectedDependencies = { } export class WorkflowsModuleService< - TWorkflowExecution extends WorkflowExecution = WorkflowExecution + TWorkflowExecution extends InferEntityType< + typeof WorkflowExecution + > = InferEntityType > extends ModulesSdkUtils.MedusaService<{ - WorkflowExecution: { dto: WorkflowExecution } + WorkflowExecution: { dto: InferEntityType } }>({ WorkflowExecution }) { protected baseRepository_: DAL.RepositoryService protected workflowExecutionService_: ModulesSdkTypes.IMedusaInternalService From 21b0e0c26b62de281556cf5b6f9c2252bb72188f Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Fri, 6 Dec 2024 16:34:02 +0200 Subject: [PATCH 06/13] docs: revise admin customization pages (#10466) * docs: revise admin customization pages * fix prerequisites link * apply lint --- .../data-models/check-constraints/page.mdx | 12 +- .../data-models/relationships/page.mdx | 2 +- .../workflows/compensation-function/page.mdx | 2 +- .../custom-features/api-route/page.mdx | 2 +- .../customization/customize-admin/page.mdx | 21 +- .../customize-admin/route/page.mdx | 486 ++++++++++++++---- .../customize-admin/widget/page.mdx | 169 ++++-- www/apps/book/generated/edit-dates.mjs | 6 +- www/apps/book/sidebar.mjs | 2 +- 9 files changed, 538 insertions(+), 164 deletions(-) diff --git a/www/apps/book/app/learn/advanced-development/data-models/check-constraints/page.mdx b/www/apps/book/app/learn/advanced-development/data-models/check-constraints/page.mdx index 59c11996e6349..fc6b1dd15df64 100644 --- a/www/apps/book/app/learn/advanced-development/data-models/check-constraints/page.mdx +++ b/www/apps/book/app/learn/advanced-development/data-models/check-constraints/page.mdx @@ -29,12 +29,12 @@ export const checks1Highlights = [ ```ts highlights={checks1Highlights} import { model } from "@medusajs/framework/utils" -const CustomProduct = model.define('custom_product', { +const CustomProduct = model.define("custom_product", { // ... price: model.bigNumber(), }) .checks([ - (columns) => `${columns.price} >= 0` + (columns) => `${columns.price} >= 0`, ]) ``` @@ -53,15 +53,15 @@ export const checks2Highlights = [ ```ts highlights={checks2Highlights} import { model } from "@medusajs/framework/utils" -const CustomProduct = model.define('custom_product', { +const CustomProduct = model.define("custom_product", { // ... price: model.bigNumber(), }) .checks([ { - name: 'custom_product_price_check', - expression: (columns) => `${columns.price} >= 0` - } + name: "custom_product_price_check", + expression: (columns) => `${columns.price} >= 0`, + }, ]) ``` diff --git a/www/apps/book/app/learn/advanced-development/data-models/relationships/page.mdx b/www/apps/book/app/learn/advanced-development/data-models/relationships/page.mdx index 8ecc950a274bc..fcd5366ae2805 100644 --- a/www/apps/book/app/learn/advanced-development/data-models/relationships/page.mdx +++ b/www/apps/book/app/learn/advanced-development/data-models/relationships/page.mdx @@ -152,7 +152,7 @@ const Order = model.define("order", { mappedBy: "orders", pivotTable: "order_product", joinColumn: "order_id", - inverseJoinColumn: "product_id" + inverseJoinColumn: "product_id", }), }) diff --git a/www/apps/book/app/learn/advanced-development/workflows/compensation-function/page.mdx b/www/apps/book/app/learn/advanced-development/workflows/compensation-function/page.mdx index 0dd06ce533aa0..ce4e551d349c0 100644 --- a/www/apps/book/app/learn/advanced-development/workflows/compensation-function/page.mdx +++ b/www/apps/book/app/learn/advanced-development/workflows/compensation-function/page.mdx @@ -236,7 +236,7 @@ const step1 = createStep( ) return new StepResponse(ids, prevData) - }, + } ) ``` diff --git a/www/apps/book/app/learn/customization/custom-features/api-route/page.mdx b/www/apps/book/app/learn/customization/custom-features/api-route/page.mdx index f4d424556be2e..979a0e5a962d4 100644 --- a/www/apps/book/app/learn/customization/custom-features/api-route/page.mdx +++ b/www/apps/book/app/learn/customization/custom-features/api-route/page.mdx @@ -97,7 +97,7 @@ You create a validation schema in a TypeScript or JavaScript file under a sub-di import { z } from "zod" export const PostAdminCreateBrand = z.object({ - name: z.string() + name: z.string(), }) ``` diff --git a/www/apps/book/app/learn/customization/customize-admin/page.mdx b/www/apps/book/app/learn/customization/customize-admin/page.mdx index e23488a45388f..806a4c78e4c9b 100644 --- a/www/apps/book/app/learn/customization/customize-admin/page.mdx +++ b/www/apps/book/app/learn/customization/customize-admin/page.mdx @@ -1,10 +1,23 @@ export const metadata = { - title: `${pageNumber} Customize Admin to Add Brands`, + title: `${pageNumber} Customize Medusa Admin Dashboard`, } # {metadata.title} -In the next chapters, you'll continue with the brands example to learn how to customize the Medusa Admin to: +In the previous chapters, you've customized your Medusa application to [add brands](../custom-features/module/page.mdx), [expose an API route to create brands](../custom-features/api-route/page.mdx), and [linked brands to products](../extend-features/define-link/page.mdx). -- Show a product's brand on its details page using a widget. -- Add a page showing the list of brands in your application using a UI route. +After customizing and extending your application with new features, you may need to provide an interface for admin users to utilize these features. The Medusa Admin dashboard is extendable, allowing you to: + +- Insert components, called [widgets](../../advanced-development/admin/widgets/page.mdx), on existing pages. +- Add new pages, called [UI Routes](../../advanced-development/admin/ui-routes/page.mdx). + +From these customizations, you can send requests to custom API routes, allowing admin users to manage custom resources on the dashboard + +--- + +## Next Chapters: View Brands in Dashboard + +In the next chapters, you'll continue with the brands example to: + +- Add a new section to the product details page that shows the product's brand. +- Add a new page in the dashboard that shows all brands in the store. diff --git a/www/apps/book/app/learn/customization/customize-admin/route/page.mdx b/www/apps/book/app/learn/customization/customize-admin/route/page.mdx index 049cb0b6edb96..37b5eacc96b94 100644 --- a/www/apps/book/app/learn/customization/customize-admin/route/page.mdx +++ b/www/apps/book/app/learn/customization/customize-admin/route/page.mdx @@ -1,196 +1,480 @@ import { Prerequisites } from "docs-ui" export const metadata = { - title: `${pageNumber} Create Brands List UI Route in Admin`, + title: `${pageNumber} Create Brands UI Route in Admin`, } # {metadata.title} - - -This chapter covers how to create a UI route (or page) that shows your brands as a step of the ["Customize Admin" chapter](../page.mdx). - - - -## What is a UI Route? - -A UI route is a React Component that adds a new page to your admin dashboard. - -The UI Route can be shown in the sidebar or added as a nested page. - ---- - -## Prerequisite: Add Retrieve Brand API Route +In this chapter, you'll add a UI route to the admin dashboard that shows the all [brands](../../custom-features/module/page.mdx) in a new page. You'll retrieve the brands from the server and display them in a table with pagination. -Before adding the UI route, you need an API route that retrieves all brands. +## 1. Get Brands API Route + +In a [previous chapter](../../extend-features/query-linked-records/page.mdx), you learned how to add an API route that retrieves brands and their products using [Query](../../../advanced-development/module-links/query/page.mdx). You'll expand that API route to support pagination, so that on the admin dashboard you can show the brands in a paginated table. -Create the file `src/api/admin/brands/route.ts` with the following content: +Replace or create the `GET` API route at `src/api/admin/brands/route.ts` with the following: + +export const apiRouteHighlights = [ + ["15", "metadata", "Pagination details, such as the total count or how many items were skipped."], + ["18", "remoteQueryConfig", "Query configurations parsed from the request."], +] -```ts title="src/api/admin/brands/route.ts" collapsibleLines="1-7" expandMoreButton="Show Imports" +```ts title="src/api/admin/brands/route.ts" highlights={apiRouteHighlights} +// other imports... import { MedusaRequest, MedusaResponse, } from "@medusajs/framework/http" -import { BRAND_MODULE } from "../../../modules/brand" -import BrandModuleService from "../../../modules/brand/service" export const GET = async ( req: MedusaRequest, res: MedusaResponse ) => { - const brandModuleService: BrandModuleService = req.scope.resolve( - BRAND_MODULE - ) - - const limit = req.query.limit || 15 - const offset = req.query.offset || 0 - - const [brands, count] = await brandModuleService.listAndCountBrands({}, { - skip: offset as number, - take: limit as number, + const query = req.scope.resolve("query") + + const { + data: brands, + metadata: { count, take, skip }, + } = await query.graph({ + entity: "brand", + ...req.remoteQueryConfig, }) - res.json({ + res.json({ brands, count, - limit, - offset, + limit: take, + offset: skip, }) } ``` -This adds a `GET` API route at `/admin/brands`. +In the API route, you use Query's `graph` method to retrieve the brands. In the method's object parameter, you spread the `remoteQueryConfig` property of the request object. This property holds configurations for pagination and retrieved fields. + +The query configurations are combined from default configurations, which you'll add next, and the request's query parameters: + +- `fields`: The fields to retrieve in the brands. +- `limit`: The maximum number of items to retrieve. +- `offset`: The number of items to skip before retrieving the returned items. + +When you pass pagination configurations to the `graph` method, the returned object has the pagination's details in a `metadata` property, whose value is an object having the following properties: + +- `count`: The total count of items. +- `take`: The maximum number of items returned in the `data` array. +- `skip`: The number of items skipped before retrieving the returned items. + +You return in the response the retrieved brands and the pagination configurations. + + + +Learn more about pagination with Query in [this chapter](../../../advanced-development/module-links/query/page.mdx#apply-pagination). + + + +--- + +## 2. Add Default Query Configurations + +Next, you'll set the default query configurations of the above API route and allow passing query parameters to change the configurations. + +Medusa provides a `validateAndTransformQuery` middleware that validates the accepted query parameters for a request and sets the default Query configuration. So, in `src/api/middlewares.ts`, add a new middleware configuration object: + +```ts title="src/api/middlewares.ts" +import { + defineMiddlewares, + validateAndTransformQuery, +} from "@medusajs/framework/http" +import { createFindParams } from "@medusajs/medusa/api/utils/validators" +// other imports... + +export const GetBrandsSchema = createFindParams() + +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/admin/brands", + method: "GET", + middlewares: [ + validateAndTransformQuery( + GetBrandsSchema, + { + defaults: [ + "id", + "name", + "products.*", + ], + isList: true, + } + ), + ], + }, + + ], +}) +``` + +You apply the `validateAndTransformQuery` middleware on the `GET /admin/brands` API route. The middleware accepts two parameters: -In the API route, you resolve the Brand Module's main service and use its `listAndCountBrands` method to retrieve the list of brands with their total count. +- A [Zod](https://zod.dev/) schema that a request's query parameters must satisfy. Medusa provides a `createFindParams` utility that generates a Zod schema with the following properties: + - `fields`: A comma-separated string indicating the fields to retrieve. + - `limit`: The maximum number of items to retrieve. + - `offset`: The number of items to skip before retrieving the returned items. + - `order`: The name of the field to sort the items by. Learn more about sorting in [the API reference](!api!/admin#sort-order) +- An object of Query configurations having the following properties: + - `defaults`: An array of default fields and relations to retrieve. + - `isList`: Whether the API route returns a list of items. -This method accepts as a first parameter filters to apply on the retrieved data, and as a second parameter configurations for pagination. +By applying the above middleware, you can pass pagination configurations to `GET /admin/brands`, which will return a paginated list of brands. You'll see how it works when you create the UI route. -Learn more about the `listAndCount` method and its parameters in [this reference](!resources!/service-factory-reference/methods/listAndCount). +Learn more about using the `validateAndTransformQuery` middleware to configure Query in [this chapter](../../../advanced-development/module-links/query/page.mdx#request-query-configurations). --- -## Add a UI Route to Show Brands +## 3. Initialize JS SDK + +In your custom UI route, you'll retrieve the brands by sending a request to the Medusa server. Medusa has a [JS SDK](!resources!/js-sdk) that simplifies sending requests to the core API route. + +If you didn't follow the [previous chapter](../widget/page.mdx), create the file `src/admin/lib/sdk.ts` with the following content: -A UI route is created in a file named `page.tsx` under subdirectories of the `src/admin/routes` directory. The file’s default export must be the UI route’s React component. +![The directory structure of the Medusa application after adding the file](https://res.cloudinary.com/dza7lstvk/image/upload/v1733414606/Medusa%20Book/brands-admin-dir-overview-1_jleg0t.jpg) + +```ts title="src/admin/lib/sdk.ts" +import Medusa from "@medusajs/js-sdk" + +export const sdk = new Medusa({ + baseUrl: "http://localhost:9000", + debug: process.env.NODE_ENV === "development", + auth: { + type: "session", + }, +}) +``` -To create a UI route that shows the list of brands, create the file `src/admin/routes/brands/page.tsx` with the following content: +You initialize the SDK passing it the following options: + +- `baseUrl`: The URL to the Medusa server. +- `debug`: Whether to enable logging debug messages. This should only be enabled in development. +- `auth.type`: The authentication method used in the client application, which is `session` in the Medusa Admin dashboard. + +You can now use the SDK to send requests to the Medusa server. + + + +Learn more about the JS SDK and its options in [this reference](!resources!/js-sdk). + + + +--- + +## 4. Add a UI Route to Show Brands + +You'll now add the UI route that shows the paginated list of brands. A UI route is a React component created in a `page.tsx` file under a sub-directory of `src/admin/routes`. The file's path relative to src/admin/routes determines its path in the dashboard. + + + +Learn more about UI routes in [this chapter](../../../advanced-development/admin/ui-routes/page.mdx). + + + +So, to add the UI route at the `localhost:9000/app/brands` path, create the file `src/admin/routes/brands/page.tsx` with the following content: + +![Directory structure of the Medusa application after adding the UI route.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733472011/Medusa%20Book/brands-admin-dir-overview-3_syytld.jpg) export const uiRouteHighlights = [ - ["7", "brands", "State variable to store the brands."], - ["12", "fetch", "Retrieve the brands from the custom API route."] + ["8", "BrandsPage", "The UI route that displays a new page."], + ["23", "defineRouteConfig", "Export config to add a link for the UI route in the sidebar."], + ["24", "label", "The sidebar item's label."], + ["25", "icon", "The sidebar item's icon."] ] ```tsx title="src/admin/routes/brands/page.tsx" highlights={uiRouteHighlights} -import { Table, Container, Heading } from "@medusajs/ui" -import { useEffect, useState } from "react" import { defineRouteConfig } from "@medusajs/admin-sdk" import { TagSolid } from "@medusajs/icons" +import { Container, Heading } from "@medusajs/ui" +import { useQuery } from "@tanstack/react-query" +import { sdk } from "../../lib/sdk" +import { useMemo, useState } from "react" const BrandsPage = () => { - const [brands, setBrands] = useState< - Record[] - >([]) - - useEffect(() => { - fetch(`/admin/brands`, { - credentials: "include", - }) - .then((res) => res.json()) - .then(({ brands: brandsData }) => { - setBrands(brandsData) - }) - }, []) - + // TODO retrieve brands return (
- Brands -
-
- - - - ID - Name - - - - {brands.map((brand) => ( - - {brand.id} - {brand.name} - - ))} - -
+
+ Brands +
+ {/* TODO show brands */}
) } +export const config = defineRouteConfig({ + label: "Brands", + icon: TagSolid, +}) + export default BrandsPage +``` + +A route's file must export the React component that will be rendered in the new page. It must be the default export of the file. You can also export configurations that add a link in the sidebar for the UI route. You create these configurations using `defineRouteConfig` from the Admin Extension SDK. + +So far, you only show a "Brands" header. In admin customizations, use components from the [Medusa UI package](!ui!) to maintain a consistent user interface and design in the dashboard. + +### Add Table Component -// TODO export configuration +To show the brands with pagination functionalities, you'll create a new `Table` component that uses the UI package's [Table](!ui!/components/table) component with some alterations to match the design of the Medusa Admin. This new component is taken from the [Admin Components guide](!resources!/admin-components/components/table). + +Create the `Table` component in the file `src/admin/components/table.tsx`: + +![Directory structure of the Medusa application after adding the table component.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733472527/Medusa%20Book/brands-admin-dir-overview-4_avosrf.jpg) + +```tsx title="src/admin/components/table.tsx" +import { useMemo } from "react" +import { Table as UiTable } from "@medusajs/ui" + +export type TableProps = { + columns: { + key: string + label?: string + render?: (value: unknown) => React.ReactNode + }[] + data: Record[] + pageSize: number + count: number + currentPage: number + setCurrentPage: (value: number) => void +} + +export const Table = ({ + columns, + data, + pageSize, + count, + currentPage, + setCurrentPage, +}: TableProps) => { + const pageCount = useMemo(() => { + return Math.ceil(count / pageSize) + }, [count, pageSize]) + + const canNextPage = useMemo(() => { + return currentPage < pageCount - 1 + }, [currentPage, pageCount]) + const canPreviousPage = useMemo(() => { + return currentPage - 1 >= 0 + }, [currentPage]) + + const nextPage = () => { + if (canNextPage) { + setCurrentPage(currentPage + 1) + } + } + + const previousPage = () => { + if (canPreviousPage) { + setCurrentPage(currentPage - 1) + } + } + + console.log(pageCount, canNextPage, canPreviousPage, currentPage) + + return ( +
+ + + + {columns.map((column, index) => ( + + {column.label || column.key} + + ))} + + + + {data.map((item, index) => { + const rowIndex = "id" in item ? item.id as string : index + return ( + + {columns.map((column, index) => ( + + <> + {column.render && column.render(item[column.key])} + {!column.render && ( + <>{item[column.key] as string} + )} + + + ))} + + ) + })} + + + +
+ ) +} ``` -This adds a new page in the admin at `http://localhost:9000/app/brands`. +This component accepts the following props: -In the UI route's component, you retrieve the brands from the `/admin/brands` API route. You show the brands in a table. +- `columns`: An array of the table's columns. +- `data`: The rows in the table. +- `pageSize`: The maximum number of items shown in a page. +- `count`: The total number of items. +- `currentPage`: A zero-based index of the current page. +- `setCurrentPage`: A function to change the current page. - +In the component, you use the UI package's [Table](!ui!/components/table) component to display the data received as a prop in a table that supports pagination. -Admin customizations can use the [Medusa UI package](!ui!) to align your customizations with the admin's design. Also, [this guide](!resources!/admin-components) includes examples of common components in the Medusa Admin. +You can learn more about this component's implementation and how it works in the [Admin Components guide](!resources!/admin-components), which provides more examples of how to build common components in the Medusa Admin dashboard. - +### Retrieve Brands From API Route -### Add UI Route to the Sidebar +You'll now update the UI route to retrieve the brands from the API route you added earlier. -To add the UI route to the sidebar, replace the `TODO` at the end of the file with the following: +First, add the following type in `src/admin/routes/brands/page.tsx`: -```ts title="src/admin/routes/brands/page.tsx" -export const config = defineRouteConfig({ - label: "Brands", - icon: TagSolid, +```tsx title="src/admin/routes/brands/page.tsx" +type BrandsResponse = { + brands: { + id: string + name: string + }[] + count: number + limit: number + offset: number +} +``` + +This is the type of expected response from the `GET /admin/brands` API route. + +Then, replace the `// TODO retrieve brands` in the component with the following: + +export const queryHighlights = [ + ["1", "currentPage", "A zero-based index of the current page of items."], + ["2", "limit", "The maximum number of items per page."], + ["3", "offset", "The number of items to skip before retrieving the page's items."], + ["7", "useQuery", "Retrieve brands using Tanstack Query"], + ["8", "fetch", "Send a request to a custom API route."], + ["8", "`/admin/brands`", "The API route's path."], + ["9", "query", "Query parameters to pass in the request"] +] + +```tsx title="src/admin/routes/brands/page.tsx" highlights={queryHighlights} +const [currentPage, setCurrentPage] = useState(0) +const limit = 15 +const offset = useMemo(() => { + return currentPage * limit +}, [currentPage]) + +const { data } = useQuery({ + queryFn: () => sdk.client.fetch(`/admin/brands`, { + query: { + limit, + offset, + }, + }), + queryKey: [["brands", limit, offset]], }) ``` -You export a `config` variable defined using the `defineRouteConfig` utility. +You first define pagination-related variables: + +- `currentPage`: A zero-based index of the current page of items. +- `limit`: The maximum number of items per page. +- `offset`: The number of items to skip before retrieving the page's items. This is calculated from the `currentPage` and `limit` variables. + +Then, you use `useQuery` from [Tanstack (React) Query](https://tanstack.com/query/latest) to query the Medusa server. Tanstack Query provides features like asynchronous state management and optimized caching. + +In the `queryFn` function that executes the query, you use the JS SDK's `client.fetch` method to send a request to your custom API route. The first parameter is the route's path, and the second is an object of request configuration and data. You pass the query parameters in the `query` property. + +This sends a request to the [Get Brands API route](#1-get-brands-api-route), passing the pagination query parameters. Whenever `currentPage` is updated, the `offset` is also updated, which will send a new request to retrieve the brands for the current page. + +### Display Brands Table + +Finally, you'll display the brands in a table using the component you created earlier. Import the component at the top of `src/admin/routes/brands/page.tsx`: + +```tsx title="src/admin/routes/brands/page.tsx" +import { Table } from "../../components/table" +``` + +Then, replace the `{/* TODO show brands */}` in the return statement with the following: -This indicates that a new item should be added to the sidebar with the title `Brands` and an icon from the [Medusa Icons package](!ui!/icons/overview). +```tsx title="src/admin/routes/brands/page.tsx" + +``` + +This renders a table that shows the ID and name of the brands. --- ## Test it Out -To test it out, start the Medusa application and login into the Medusa Admin. +To test out the UI route, start the Medusa application: -You'll find a new "Brands" sidebar item. If you click on it, a new page opens showing the list of brands in your store. +```bash npm2yarn +npm run dev +``` + +Then, open the admin dashboard at `http://localhost:9000/app`. After you log in, you'll find a new "Brands" sidebar item. Click on it to see the brands in your store. You can also go to `http://localhost:9000/app/brands` to see the page. + +![A new sidebar item is added for the new brands UI route. The UI route shows the table of brands with pagination.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733421074/Medusa%20Book/Screenshot_2024-12-05_at_7.46.52_PM_slcdqd.png) --- ## Summary -By following the examples of the previous chapters, you: +By following the previous chapters, you: -- Created a widget that showed the brand of a product in the Medusa Admin. -- Created a UI route that showed the list of brands in the Medusa Admin. +- Injected a widget into the product details page to show the product's brand. +- Created a UI route in the Medusa Admin that shows the list of brands. --- -## Next Steps +## Next Steps: Integrate Third-Party Systems + +Your customizations often span across systems, where you need to retrieve data or perform operations in a third-party system. -In the next chapters, you'll learn how to integrate third-party systems into your Medusa application to sync brands. +In the next chapters, you'll learn about the concepts that facilitate integrating third-party systems in your application. You'll integrate a dummy third-party system and sync the brands between it and the Medusa application. diff --git a/www/apps/book/app/learn/customization/customize-admin/widget/page.mdx b/www/apps/book/app/learn/customization/customize-admin/widget/page.mdx index 537627544804d..a78b64474ec37 100644 --- a/www/apps/book/app/learn/customization/customize-admin/widget/page.mdx +++ b/www/apps/book/app/learn/customization/customize-admin/widget/page.mdx @@ -1,64 +1,132 @@ import { Prerequisites } from "docs-ui" export const metadata = { - title: `${pageNumber} Show Brand of Product in Admin`, + title: `${pageNumber} Guide: Add Product's Brand Widget in Admin`, } # {metadata.title} - +In this chapter, you'll customize the product details page of the Medusa Admin dashboard to show the product's [brand](../../custom-features/module/page.mdx). You'll create a widget that is injected into a pre-defined zone in the page, and in the widget you'll retrieve the product's brand from the server and display it. -This chapter covers how to show the brand of a product in the Medusa Admin using a widget as a step of the ["Customize Admin" chapter](../page.mdx). + + +## 1. Initialize JS SDK + +In your custom widget, you'll retrieve the product's brand by sending a request to the Medusa server. Medusa has a [JS SDK](!resources!/js-sdk) that simplifies sending requests to the server's API routes. + +So, you'll start by configuring the JS SDK. Create the file `src/admin/lib/sdk.ts` with the following content: + +![The directory structure of the Medusa application after adding the file](https://res.cloudinary.com/dza7lstvk/image/upload/v1733414606/Medusa%20Book/brands-admin-dir-overview-1_jleg0t.jpg) + +```ts title="src/admin/lib/sdk.ts" +import Medusa from "@medusajs/js-sdk" + +export const sdk = new Medusa({ + baseUrl: "http://localhost:9000", + debug: process.env.NODE_ENV === "development", + auth: { + type: "session", + }, +}) +``` + +You initialize the SDK passing it the following options: + +- `baseUrl`: The URL to the Medusa server. +- `debug`: Whether to enable logging debug messages. This should only be enabled in development. +- `auth.type`: The authentication method used in the client application, which is `session` in the Medusa Admin dashboard. + +You can now use the SDK to send requests to the Medusa server. + + + +Learn more about the JS SDK and its options in [this reference](!resources!/js-sdk). -## Widget to Show Brand in Product Details +--- + +## 2. Add Widget to Product Details Page + +You'll now add a widget to the product-details page. A widget is a React component that's injected into pre-defined zones in the Medusa Admin dashboard. It's created in a `.tsx` file under the `src/admin/widgets` directory. + + + +Learn more about widgets in [this documentation](../../../advanced-development/admin/widgets/page.mdx). + + To create a widget that shows a product's brand in its details page, create the file `src/admin/widgets/product-brand.tsx` with the following content: +![Directory structure of the Medusa application after adding the widget](https://res.cloudinary.com/dza7lstvk/image/upload/v1733414684/Medusa%20Book/brands-admin-dir-overview-2_eq5xhi.jpg) + export const highlights = [ - ["7", "data", "Receive the product's details as a prop"], - ["9", "brand", "A state variable to store the brand"], - ["19", "fetch", "Retrieve the brand of a product using the custom API route"], - ["41", "zone", "Show the widget at the top of the product details page."] + ["14", "ProductBrandWidget", "Widget to inject into the page"], + ["15", "data", "Receive the product's details as a prop"], + ["17", "useQuery", "Use Tanstack Query to send the request to the server with the JS SDK."], + ["18", "sdk", "Send the request to retrieve the product with the JS SDK."], + ["19", "fields", "Specify the product's brand to be retrieved."], + ["23", "brandName", "Get brand name from the query request."], + ["53", "defineWidgetConfig", "Export the widget's configurations"], + ["54", "zone", "Show the widget at the top of the product details page."] ] ```tsx title="src/admin/widgets/product-brand.tsx" highlights={highlights} import { defineWidgetConfig } from "@medusajs/admin-sdk" import { DetailWidgetProps, AdminProduct } from "@medusajs/framework/types" -import { useEffect, useState } from "react" -import { Container, Heading } from "@medusajs/ui" +import { clx, Container, Heading, Text } from "@medusajs/ui" +import { useQuery } from "@tanstack/react-query" +import { sdk } from "../lib/sdk" + +type AdminProductBrand = AdminProduct & { + brand?: { + id: string + name: string + } +} const ProductBrandWidget = ({ - data, + data: product, }: DetailWidgetProps) => { - const [brand, setBrand] = useState< - Record | undefined - >() - const [loading, setLoading] = useState(true) - - useEffect(() => { - if (!loading) { - return - } + const { data: queryResult } = useQuery({ + queryFn: () => sdk.admin.product.retrieve(product.id, { + fields: "+brand.*", + }), + queryKey: [["product", product.id]], + }) + const brandName = (queryResult?.product as AdminProductBrand)?.brand?.name - fetch(`/admin/products/${data.id}?fields=+brand.*`, { - credentials: "include", - }) - .then((res) => res.json()) - .then(({ product }) => { - setBrand(product.brand) - setLoading(false) - }) - }, [loading]) - return (
- Brand +
+ Brand +
+
+
+ + Name + + + + {brandName || "-"} +
- {loading && Loading...} - {brand && Name: {brand.name}}
) } @@ -70,32 +138,41 @@ export const config = defineWidgetConfig({ export default ProductBrandWidget ``` -This adds a widget at the top of the product's details page. +A widget's file must export: - +- A React component to be rendered in the specified injection zone. The component must be the file's default export. +- A configuration object created with `defineWidgetConfig` from the Admin Extension SDK. The function receives an object as a parameter that has a `zone` property, whose value is the zone to inject the widget to. -Learn more about widgets [in this guide](../../../basics/admin-customizations/page.mdx). +Since the widget is injected at the top of the product details page, the widget receives the product's details as a parameter. - +In the widget, you use [Tanstack (React) Query](https://tanstack.com/query/latest) to query the Medusa server. Tanstack Query provides features like asynchronous state management and optimized caching. In the `queryFn` function that executes the query, you use the JS SDK to send a request to the [Get Product API Route](!api!/admin#products_getproductsid), passing `+brand.*` in the `fields` query parameter to retrieve the product's brand. -Widgets created in a details page receive the targetted item in a `data` prop. So, the `ProductBrandWidget` receives the product's details in the `data` prop. +You then render a section that shows the brand's name. In admin customizations, use components from the [Medusa UI package](!ui!) to maintain a consistent user interface and design in the dashboard. -In the widget, you fetch the product's brand using the [Get Product API route](!api!/admin#products_getproductsid), passing it the query parameter `fields=+brand.*` to retrieve the product's brand. +--- - +## Test it Out -Admin customizations can use the [Medusa UI package](!ui!) to align your customizations with the admin's design. Also, [this guide](!resources!/admin-components) includes examples of common components in the Medusa Admin. +To test out your widget, start the Medusa application: - +```bash npm2yarn +npm run dev +``` + +Then, open the admin dashboard at `http://localhost:9000/app`. After you log in, open the page of a product that has a brand. You'll see a new section at the top showing the brand's name. + +![The widget is added as the first section of the product details page.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733414415/Medusa%20Book/Screenshot_2024-12-05_at_5.59.25_PM_y85m14.png) --- -## Test it Out +## Admin Components Guides + +When building your widget, you may need more complicated components. For example, you may add a form to the above widget to set the product's brand. -Start your Medusa application and go to a product's details page in the Medusa Admin, you'll find a new block at the top of the page showing the product's brand. +The [Admin Components guides](!resources!/admin-components) show you how to build and use common components in the Medusa Admin, such as forms, tables, JSON data viewer, and more. The components in the guides also follow the Medusa Admin's design convention. --- -## Next Chapter: Add List of Brands Page +## Next Chapter: Add UI Route for Brands -In the next chapter, you'll add a new page or UI route that displays the list of brands in your application. +In the next chapter, you'll add a UI route that displays the list of brands in your application and allows admin users. diff --git a/www/apps/book/generated/edit-dates.mjs b/www/apps/book/generated/edit-dates.mjs index 43c3150d58874..ec5da6af6327e 100644 --- a/www/apps/book/generated/edit-dates.mjs +++ b/www/apps/book/generated/edit-dates.mjs @@ -94,9 +94,9 @@ export const generatedEditDates = { "app/learn/customization/custom-features/workflow/page.mdx": "2024-11-28T10:47:28.084Z", "app/learn/customization/extend-features/extend-create-product/page.mdx": "2024-12-05T09:26:15.796Z", "app/learn/customization/custom-features/page.mdx": "2024-11-28T08:21:55.207Z", - "app/learn/customization/customize-admin/page.mdx": "2024-09-12T12:25:29.853Z", - "app/learn/customization/customize-admin/route/page.mdx": "2024-10-07T12:43:11.335Z", - "app/learn/customization/customize-admin/widget/page.mdx": "2024-12-05T10:40:56.611Z", + "app/learn/customization/customize-admin/page.mdx": "2024-12-06T07:21:02.303Z", + "app/learn/customization/customize-admin/route/page.mdx": "2024-12-06T08:29:57.834Z", + "app/learn/customization/customize-admin/widget/page.mdx": "2024-12-06T08:15:11.426Z", "app/learn/customization/extend-features/define-link/page.mdx": "2024-12-04T17:15:16.004Z", "app/learn/customization/extend-features/page.mdx": "2024-09-12T12:38:57.394Z", "app/learn/customization/extend-features/query-linked-records/page.mdx": "2024-12-05T10:36:32.357Z", diff --git a/www/apps/book/sidebar.mjs b/www/apps/book/sidebar.mjs index f61486a6b5961..382db13feb7cd 100644 --- a/www/apps/book/sidebar.mjs +++ b/www/apps/book/sidebar.mjs @@ -142,7 +142,7 @@ export const sidebar = numberSidebarItems( children: [ { type: "link", - title: "Add Widgets", + title: "Add Widget", path: "/learn/customization/customize-admin/widget", }, { From a76b5336044b9ee367a5ff80a7706a2891a8eecf Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Fri, 6 Dec 2024 17:56:27 +0200 Subject: [PATCH 07/13] docs: revise last chapters of customizations (#10480) --- .../custom-features/workflow/page.mdx | 2 +- .../integrate-systems/handle-event/page.mdx | 286 +++++++++++++----- .../customization/integrate-systems/page.mdx | 25 +- .../integrate-systems/schedule-task/page.mdx | 250 ++++++++------- .../integrate-systems/service/page.mdx | 178 +++++------ .../learn/customization/next-steps/page.mdx | 24 +- www/apps/book/generated/edit-dates.mjs | 12 +- www/apps/book/sidebar.mjs | 6 +- 8 files changed, 447 insertions(+), 336 deletions(-) diff --git a/www/apps/book/app/learn/customization/custom-features/workflow/page.mdx b/www/apps/book/app/learn/customization/custom-features/workflow/page.mdx index 3d5b40bd3f4ae..aa4f20d261c1d 100644 --- a/www/apps/book/app/learn/customization/custom-features/workflow/page.mdx +++ b/www/apps/book/app/learn/customization/custom-features/workflow/page.mdx @@ -124,7 +124,7 @@ You can now create the workflow that runs the `createBrandStep`. A workflow is c Add the following content in the same `src/workflows/create-brand.ts` file: -```ts +```ts title="src/workflows/create-brand.ts" // other imports... import { // ... diff --git a/www/apps/book/app/learn/customization/integrate-systems/handle-event/page.mdx b/www/apps/book/app/learn/customization/integrate-systems/handle-event/page.mdx index d0cad26da6354..dc9f7e51ad619 100644 --- a/www/apps/book/app/learn/customization/integrate-systems/handle-event/page.mdx +++ b/www/apps/book/app/learn/customization/integrate-systems/handle-event/page.mdx @@ -1,31 +1,43 @@ import { Prerequisites } from "docs-ui" export const metadata = { - title: `${pageNumber} Brand Example: Handle Event to Sync Third-Party System`, + title: `${pageNumber} Guide: Sync Brands from Medusa to CMS`, } # {metadata.title} - +In the [previous chapter](../service/page.mdx), you created a CMS Module that integrates a dummy third-party system. You can now perform actions using that module within your custom flows. -This chapter covers how to emit an event when a brand is created, listen to that event in a subscriber, and create the brand in the third-party system as a step of the ["Integrate Systems" chapter](../page.mdx). +In another previous chapter, you [added a workflow](../../custom-features/workflow/page.mdx) that creates a brand. After integrating the CMS, you want to sync that brand to the third-party system as well. + +Medusa has an event system that emits events when an operation is performed. It allows you to listen to those events and perform an asynchronous action in a function called a [subscriber](../../../basics/events-and-subscribers/page.mdx). This is useful to perform actions that aren't integral to the original flow, such as syncing data to a third-party system. + + + +Learn more about Medusa's event system and subscribers in [this chapter](../../../basics/events-and-subscribers/page.mdx). -## 1. Emit Custom Event for Brand Creation +In this chapter, you'll modify the `createBrandWorkflow` you created before to emit a custom event that indicates a brand was created. Then, you'll listen to that event in a subscriber to sync the brand to the third-party CMS. You'll implement the sync logic within a workflow that you execute in the subscriber. -To handle brand-creation event, you'll emit a custom event when a brand is created. +## 1. Emit Event in createBrandWorkflow -In the `createBrandWorkflow` defined in `src/workflows/create-brand/index.ts`, use the `emitEventStep` helper step imported from `@medusajs/medusa/core-flows` after the `createBrandStep`: +Since syncing the brand to the third-party system isn't integral to creating a brand, you'll emit a custom event indicating that a brand was created. + +Medusa provides an `emitEventStep` that allows you to emit an event in your workflows. So, in the `createBrandWorkflow` defined in `src/workflows/create-brand.ts`, use the `emitEventStep` helper step after the `createBrandStep`: export const eventHighlights = [ ["13", "emitEventStep", "Emit an event."], @@ -58,126 +70,183 @@ export const createBrandWorkflow = createWorkflow( ) ``` -The `emitEventStep` accepts as a parameter an object having two properties: +The `emitEventStep` accepts an object parameter having two properties: + +- `eventName`: The name of the event to emit. You'll use this name later to listen to the event in a subscriber. +- `data`: The data payload to emit with the event. This data is passed to subscribers that listen to the event. You add the brand's ID to the data payload, informing the subscribers which brand was created. -- `eventName`: The name of the event to emit. -- `data`: The data payload to emit with the event. This is useful for subscribers to access the created brand. +You'll learn how to handle this event in a later step. --- ## 2. Create Sync to Third-Party System Workflow -Next, you'll create the workflow that syncs the created brand to the third-party system. +The subscriber that will listen to the `brand.created` event will sync the created brand to the third-party CMS. So, you'll implement the syncing logic in a workflow, then execute the workflow in the subscriber. -Create the file `src/workflows/sync-brand-to-system/index.ts` with the following content: +Workflows have a built-in durable execution engine that helps you complete tasks spanning multiple systems. Also, their rollback mechanism ensures that data is consistent across systems even when errors occur during execution. -```ts title="src/workflows/sync-brand-to-system/index.ts" -import { - createWorkflow, - WorkflowResponse, -} from "@medusajs/framework/workflows-sdk" + -export type SyncBrandToSystemInput = { - id: string -} +Learn more about workflows in [this chapter](../../../basics/workflows/page.mdx). -export const syncBrandToSystemWorkflow = createWorkflow( - "sync-brand-to-system", - (input: SyncBrandToSystemInput) => { - // ... - } -) -``` + + +You'll create a `syncBrandToSystemWorkflow` that has two steps: + +- `useQueryGraphStep`: a step that Medusa provides to retrieve data using [Query](../../../advanced-development/module-links/query/page.mdx). You'll use this to retrieve the brand's details using its ID. +- `syncBrandToCmsStep`: a step that you'll create to sync the brand to the CMS. -This defines an empty workflow and its expected input. +### syncBrandToCmsStep -### Create createBrandInSystemStep +To implement the step that syncs the brand to the CMS, create the file `src/workflows/sync-brands-to-cms.ts` with the following content: -Next, create the step that syncs the brand in the file `src/workflows/sync-brand-to-system/steps/create-brand-in-system.ts`: +![Directory structure of the Medusa application after adding the file](https://res.cloudinary.com/dza7lstvk/image/upload/v1733493547/Medusa%20Book/cms-dir-overview-4_u5t0ug.jpg) -export const stepHighlights = [ - ["18", "createBrand", "Create a brand in the third-party system."], - ["27", "deleteBrand", "Delete the brand in the third-party system if an error occurs."] +export const syncStepHighlights = [ + ["8", "InferTypeOf", "Get the `Brand` data model as a type."], + ["14", "cmsModuleService", "Resolve the CMS Module's service from the container."], + ["16", "createBrand", "Create the brand in the third-party CMS."], + ["18", "brand.id", "Pass the brand's ID to the compensation function."], + ["27", "deleteBrand", "Delete the brand in the third-party CMS if an error occurs."] ] -```ts title="src/workflows/sync-brand-to-system/steps/create-brand-in-system.ts" highlights={stepHighlights} collapsibleLines="1-8" expandButtonLabel="Show Imports" -import { - createStep, - StepResponse, -} from "@medusajs/framework/workflows-sdk" -import { SyncBrandToSystemInput } from ".." -import BrandModuleService from "../../../modules/brand/service" -import { BRAND_MODULE } from "../../../modules/brand" +```ts title="src/workflows/sync-brands-to-cms.ts" highlights={syncStepHighlights} collapsibleLines="1-6" expandButtonLabel="Show Imports" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { InferTypeOf } from "@medusajs/framework/types" +import { Brand } from "../modules/brand/models/brand" +import { CMS_MODULE } from "../modules/cms" +import CmsModuleService from "../modules/cms/service" + +type SyncBrandToCmsStepInput = { + brand: InferTypeOf +} -export const createBrandInSystemStep = createStep( - "create-brand-in-system", - async ({ id }: SyncBrandToSystemInput, { container }) => { - const brandModuleService: BrandModuleService = container.resolve( - BRAND_MODULE - ) +const syncBrandToCmsStep = createStep( + "sync-brand-to-cms", + async ({ brand }: SyncBrandToCmsStepInput, { container }) => { + const cmsModuleService: CmsModuleService = container.resolve(CMS_MODULE) - const brand = await brandModuleService.retrieveBrand(id) - - await brandModuleService.client.createBrand(brand) + await cmsModuleService.createBrand(brand) return new StepResponse(null, brand.id) }, async (id, { container }) => { - const brandModuleService: BrandModuleService = container.resolve( - BRAND_MODULE - ) + if (!id) { + return + } + + const cmsModuleService: CmsModuleService = container.resolve(CMS_MODULE) - await brandModuleService.client.deleteBrand(id) + await cmsModuleService.deleteBrand(id) } ) ``` -This step resolves the Brand Module's main service and uses its `client` property to access its internal service that integrates the third-party system. +You create the `syncBrandToCmsStep` that accepts a brand as an input. In the step, you resolve the CMS Module's service from the [Medusa container](../../../basics/medusa-container/page.mdx) and use its `createBrand` method. This method will create the brand in the third-party CMS. + +You also pass the brand's ID to the step's compensation function. In this function, you delete the brand in the third-party CMS if an error occurs during the workflow's execution. + + + +Learn more about compensation functions in [this chapter](../../../advanced-development/workflows/compensation-function/page.mdx). -In the step, you use the `createBrand` method of the client to create the brand in the third-party system. + -In the compensation function, you undo the step's action using the `deleteBrand` method of the client. +### Create Workflow -### Add Step to Workflow +You can now create the workflow that uses the above step. Add the workflow to the same `src/workflows/sync-brands-to-cms.ts` file: -Finally, add this step to the `syncBrandToSystemWorkflow` in `src/workflows/sync-brand-to-system/index.ts`: +export const syncWorkflowHighlights = [ + ["19", "useQueryGraphStep", "Retrieve the brand's details."], + ["23", "id", "Filter by the brand's ID."], + ["26", "throwIfKeyNotFound", "Throw an error if a brand with the specified ID doesn't exist."], + ["30", "syncBrandToCmsStep", "Create the brand in the third-party CMS."] +] -```ts title="src/workflows/sync-brand-to-system/index.ts" +```ts title="src/workflows/sync-brands-to-cms.ts" highlights={syncWorkflowHighlights} // other imports... -import { createBrandInSystemStep } from "./steps/create-brand-in-system" +import { + // ... + createWorkflow, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { useQueryGraphStep } from "@medusajs/medusa/core-flows" // ... -export const syncBrandToSystemWorkflow = createWorkflow( - "sync-brand-to-system", - (input: SyncBrandToSystemInput) => { - createBrandInSystemStep(input) +type SyncBrandToCmsWorkflowInput = { + id: string +} - return new WorkflowResponse(undefined) +export const syncBrandToCmsWorkflow = createWorkflow( + "sync-brand-to-cms", + (input: SyncBrandToCmsWorkflowInput) => { + // @ts-ignore + const { data: brands } = useQueryGraphStep({ + entity: "brand", + fields: ["*"], + filters: { + id: input.id, + }, + options: { + throwIfKeyNotFound: true, + }, + }) + + syncBrandToCmsStep({ + brand: brands[0], + } as SyncBrandToCmsStepInput) + + return new WorkflowResponse({}) } ) ``` -The workflow now calls the step and returns an `undefined` result. +You create a `syncBrandToCmsWorkflow` that accepts the brand's ID as input. The workflow has the following steps: + +- `useQueryGraphStep`: Retrieve the brand's details using Query. You pass the brand's ID as a filter, and set the `throwIfKeyNotFound` option to true so that the step throws an error if a brand with the specified ID doesn't exist. +- `syncBrandToCmsStep`: Create the brand in the third-party CMS. + +You'll execute this workflow in the subscriber next. + + + +Learn more about `useQueryGraphStep` in [this reference](!resources!/references/helper-steps/useQueryGraphStep). + + --- ## 3. Handle brand.created Event -To handle the `brand.created` event, create a subscriber at `src/subscribers/brand-created.ts` with the following content: +You now have a workflow with the logic to sync a brand to the CMS. You need to execute this workflow whenever the `brand.created` event is emitted. So, you'll create a subscriber that listens to and handle the event. + +Subscribers are created in a TypeScript or JavaScript file under the `src/subscribers` directory. So, create the file `src/subscribers/brand-created.ts` with the following content: + +![Directory structure of the Medusa application after adding the subscriber](https://res.cloudinary.com/dza7lstvk/image/upload/v1733493774/Medusa%20Book/cms-dir-overview-5_iqqwvg.jpg) + +export const subscriberHighlights = [ + ["7", "brandCreatedHandler", "The function to execute when the event is emitted."], + ["8", "data", "The event's data payload."], + ["9", "container", "The Medusa container used to resolve resources."], + ["10", "id: string", "The expected data payload's type."], + ["11", "syncBrandToCmsWorkflow", "Execute the workflow to sync the brand to the CMS."], + ["16", "config", "Export the subscriber's configurations."], + ["17", "event", "The event that the subscriber is listening to."] +] -```ts title="src/subscribers/brand-created.ts" +```ts title="src/subscribers/brand-created.ts" highlights={subscriberHighlights} import type { SubscriberConfig, SubscriberArgs, } from "@medusajs/framework" -import { syncBrandToSystemWorkflow } from "../workflows/sync-brand-to-system" +import { syncBrandToCmsWorkflow } from "../workflows/sync-brands-to-cms" export default async function brandCreatedHandler({ event: { data }, container, }: SubscriberArgs<{ id: string }>) { - await syncBrandToSystemWorkflow(container).run({ + await syncBrandToCmsWorkflow(container).run({ input: data, }) } @@ -187,27 +256,84 @@ export const config: SubscriberConfig = { } ``` -The subscriber handler accesses the event payload in the `event.data` property of its object parameter. +A subscriber file must export: + +- The asynchronous function that's executed when the event is emitted. This must be the file's default export. +- An object that holds the subscriber's configurations. It has an `event` property that indicates the name of the event that the subscriber is listening to. + +The subscriber function accepts an object parameter that has two properties: + +- `event`: An object of event details. Its `data` property holds the event's data payload, which is the brand's ID. +- `container`: The Medusa container used to resolve framework and commerce tools. + +In the function, you execute the `syncBrandToCmsWorkflow`, passing it the data payload as an input. So, everytime a brand is created, Medusa will execute this function, which in turn executes the workflow to sync the brand to the CMS. -Learn more about subscribers [in this guide](../../../basics/events-and-subscribers/page.mdx). +Learn more about subscribers in [this chapter](../../../basics/events-and-subscribers/page.mdx). -It then executes the `syncBrandToSystemWorkflow`, passing it the ID of the brand to create in the third-party system. - --- ## Test it Out -To test it out, start the Medusa application and create a brand using the API route created in a [previous chapter](../../custom-features/api-route/page.mdx#test-api-route). +To test the subscriber and workflow out, you'll use the [Create Brand API route](../../custom-features/api-route/page.mdx) you created in a previous chapter. -If you check the logs, you'll find the `brand.created` event was emitted, and that the request to the third-party system was simulated. +First, start the Medusa application: ---- +```bash npm2yarn +npm run dev +``` + +Since the `/admin/brands` API route has a `/admin` prefix, it's only accessible by authenticated admin users. So, to retrieve an authenticated token of your admin user, send a `POST` request to the `/auth/user/emailpass` API Route: + +```bash +curl -X POST 'http://localhost:9000/auth/user/emailpass' \ +-H 'Content-Type: application/json' \ +--data-raw '{ + "email": "admin@medusa-test.com", + "password": "supersecret" +}' +``` + +Make sure to replace the email and password with your admin user's credentials. + + + +Don't have an admin user? Refer to [this guide](../../../installation/page.mdx#create-medusa-admin-user). -## Next Chapter: Sync Brand from Third-Party System to Medusa + + +Then, send a `POST` request to `/admin/brands`, passing the token received from the previous request in the `Authorization` header: + +```bash +curl -X POST 'http://localhost:9000/admin/brands' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer {token}' \ +--data '{ + "name": "Acme" +}' +``` + +This request returns the created brand. If you check the logs, you'll find the `brand.created` event was emitted, and that the request to the third-party system was simulated: + +```plain +info: Processing brand.created which has 1 subscribers +http: POST /admin/brands ← - (200) - 16.418 ms +info: Sending a POST request to /brands. +info: Request Data: { + "id": "01JEDWENYD361P664WRQPMC3J8", + "name": "Acme", + "created_at": "2024-12-06T11:42:32.909Z", + "updated_at": "2024-12-06T11:42:32.909Z", + "deleted_at": null +} +info: API Key: "123" +``` + +--- -In the next chapter, you'll learn how to sync brands in the third-party system into Medusa using a workflow and a scheduled job. +## Next Chapter: Sync Brand from Third-Party CMS to Medusa +You can also automate syncing data from a third-party system to Medusa at a regular interval. In the next chapter, you'll learn how to sync brands from the third-party CMS to Medusa once a day. diff --git a/www/apps/book/app/learn/customization/integrate-systems/page.mdx b/www/apps/book/app/learn/customization/integrate-systems/page.mdx index 588ffbabd7090..ae4b51a07dd0a 100644 --- a/www/apps/book/app/learn/customization/integrate-systems/page.mdx +++ b/www/apps/book/app/learn/customization/integrate-systems/page.mdx @@ -4,25 +4,22 @@ export const metadata = { # {metadata.title} -In this chapter, you'll learn how to integrate a third-party system into Medusa. +Commerce applications often connect to third-party systems that provide additional or specialized features. For example, you may integrate a Content-Management System (CMS) for rich content features, a payment provider to process credit-card payments, and a notification service to send emails. -## How to Integrate a Third-Party System? +Medusa's framework facilitates integrating these systems and orchestrating operations across them, saving you the effort of managing them yourself. You won't find those capabilities in other commerce platforms that in these scenarios become a bottleneck to building customizations and iterating quickly. -To integrate a third-party system into Medusa, you: +In Medusa, you integrate a third-party system by: -1. Implement the methods to interact with the system in a service. It can either be the main module's service, or an internal service in the module that's used by the main one. -2. Implement in workflows custom features around the integration, such as sending data to the third-party system. - - Workflows roll-back mechanism ensures data consistency. This is essential as you integrate multiple systems into your application. -3. Use the workflow in other resources to expose or utilize the custom functionality. +1. Creating a module whose service provides the methods to connect to and perform operations in the third-party system. +2. Building workflows that complete tasks spanning across systems. You use the module that integrates a third-party system in the workflow's steps. +3. Executing the workflows you built in an [API route](../../basics/api-routes/page.mdx), at a scheduled time, or when an event is emitted. --- -## Next Chapters: Syncing Brands Example +## Next Chapters: Sync Brands Example -In the next chapters, you'll implement an example of syncing brands with a third-party system, such as a Content Management System (CMS). +In the previous chapters, you've [added brands](../custom-features/module/page.mdx) to your Medusa application. In the next chapters, you will: -That requires: - -1. Implementing the service that integrates the third-party system. -2. Creating a brand in the third-party system when a brand is created in Medusa. -2. Retrieving the brands from the third-party system to sync them with Medusa's brands at a scheduled interval. +1. Integrate a dummy third-party CMS in the Brand Module. +2. Sync brands to the CMS when a brand is created. +2. Sync brands from the CMS at a daily schedule. diff --git a/www/apps/book/app/learn/customization/integrate-systems/schedule-task/page.mdx b/www/apps/book/app/learn/customization/integrate-systems/schedule-task/page.mdx index ba195c5cec52f..5c21a584edbb2 100644 --- a/www/apps/book/app/learn/customization/integrate-systems/schedule-task/page.mdx +++ b/www/apps/book/app/learn/customization/integrate-systems/schedule-task/page.mdx @@ -1,87 +1,106 @@ import { Prerequisites } from "docs-ui" export const metadata = { - title: `${pageNumber} Schedule Syncing Brands from Third-Party System`, + title: `${pageNumber} Guide: Schedule Syncing Brands from CMS`, } # {metadata.title} - +In the previous chapters, you've [integrated a third-party CMS](../service/page.mdx) and implemented the logic to [sync created brands](../handle-event/page.mdx) from Medusa to the CMS. -This chapter covers how to use workflows and scheduled jobs to sync brands from the third-party system as the last step of the ["Integrate Systems" chapter](../page.mdx). +However, when you integrate a third-party system, you want the data to be in sync between the Medusa application and the system. One way to do so is by automatically syncing the data once a day. + +You can create an action to be automatically executed at a specified interval using scheduled jobs. A scheduled job is an asynchronous function with a specified schedule of when the Medusa application should run it. Scheduled jobs are useful to automate repeated tasks. + + + +Learn more about scheduled jobs in [this chapter](../../../basics/scheduled-jobs/page.mdx). -## 1. Implement Syncing Workflow +In this chapter, you'll create a scheduled job that triggers syncing the brands from the third-party CMS to Medusa once a day. You'll implement the syncing logic in a workflow, and execute that workflow in the scheduled job. -Start by defining the workflow that syncs the brand from the third-party system. +--- + +## 1. Implement Syncing Workflow + +You'll start by implementing the syncing logic in a workflow, then execute the workflow later in the scheduled job. + +Workflows have a built-in durable execution engine that helps you complete tasks spanning multiple systems. Also, their rollback mechanism ensures that data is consistent across systems even when errors occur during execution. + + + +Learn more about workflows in [this chapter](../../../basics/workflows/page.mdx). + + -The workflow has the following steps: +This workflow will have three steps: -1. Retrieve brands from the third-party system. -2. Create new brands in Medusa. -3. Update existing brands in Medusa. +1. `retrieveBrandsFromCmsStep` to retrieve the brands from the CMS. +2. `createBrandsStep` to create the brands retrieved in the first step that don't exist in Medusa. +3. `updateBrandsStep` to update the brands retrieved in the first step that exist in Medusa. -### Retrieve Brands Step +### retrieveBrandsFromCmsStep -To create the step that retrieves the brands from the third-party service, create the file `src/workflows/sync-brands-from-system/steps/retrieve-brands-from-system.ts` with the following content: +To create the step that retrieves the brands from the third-party CMS, create the file `src/workflows/sync-brands-from-cms.ts` with the following content: -```ts title="src/workflows/sync-brands-from-system/steps/retrieve-brands-from-system.ts" collapsibleLines="1-7" expandButtonLabel="Show Imports" +![Directory structure of the Medusa application after creating the file.](https://res.cloudinary.com/dza7lstvk/image/upload/v1733494196/Medusa%20Book/cms-dir-overview-6_z1omsi.jpg) + +```ts title="src/workflows/sync-brands-from-cms.ts" collapsibleLines="1-7" expandButtonLabel="Show Imports" import { createStep, StepResponse, } from "@medusajs/framework/workflows-sdk" -import BrandModuleService from "../../../modules/brand/service" -import { BRAND_MODULE } from "../../../modules/brand" +import CmsModuleService from "../modules/cms/service" +import { CMS_MODULE } from "../modules/cms" -export const retrieveBrandsFromSystemStep = createStep( - "retrieve-brands-from-system", +const retrieveBrandsFromCmsStep = createStep( + "retrieve-brands-from-cms", async (_, { container }) => { - const brandModuleService: BrandModuleService = container.resolve( - BRAND_MODULE + const cmsModuleService: CmsModuleService = container.resolve( + CMS_MODULE ) - const brands = await brandModuleService.client.retrieveBrands() + const brands = await cmsModuleService.retrieveBrands() return new StepResponse(brands) } ) ``` -In this step, you resolve the Brand Module's main service from the container, and use its client service to retrieve the brands from the third-party system. - -The step returns the retrieved brands. +You create a `retrieveBrandsFromSystemStep` that resolves the CMS Module's service and uses its `retrieveBrands` method to retrieve the brands in the CMS. You return those brands in the step's response. -### Create Brands Step +### createBrandsStep -Next, create the step that creates new brands in Medusa in the file `src/workflows/sync-brands-from-system/steps/create-brands.ts`: +The brands retrieved in the first step may have brands that don't exist in Medusa. So, you'll create a step that creates those brands. Add the step to the same `src/workflows/sync-brands-from-cms.ts` file: export const createBrandsHighlights = [ - ["21", "createBrands", "Create the brands in Medusa"], - ["30", "deleteBrands", "Delete the brands from Medusa"] + ["22", "createBrands", "Create the brands in Medusa"], + ["35", "deleteBrands", "Delete the brands from Medusa"] ] -```ts title="src/workflows/sync-brands-from-system/steps/create-brands.ts" highlights={createBrandsHighlights} collapsibleLines="1-9" expandButtonLabel="Show Imports" -import { - createStep, - StepResponse, -} from "@medusajs/framework/workflows-sdk" -import { InferTypeOf } from "@medusajs/framework/types" -import BrandModuleService from "../../../modules/brand/service" -import { BRAND_MODULE } from "../../../modules/brand" -import { Brand } from "../../../modules/brand/models/brand" +```ts title="src/workflows/sync-brands-from-cms.ts" highlights={createBrandsHighlights} collapsibleLines="1-8" expandButtonLabel="Show Imports" +// other imports... +import BrandModuleService from "../modules/brand/service" +import { BRAND_MODULE } from "../modules/brand" + +// ... + +type CreateBrand = { + name: string +} type CreateBrandsInput = { - brands: InferTypeOf[] + brands: CreateBrand[] } export const createBrandsStep = createStep( @@ -93,52 +112,52 @@ export const createBrandsStep = createStep( const brands = await brandModuleService.createBrands(input.brands) - return new StepResponse(brands, brands.map((brand) => brand.id)) + return new StepResponse(brands, brands) }, - async (ids: string[], { container }) => { + async (brands, { container }) => { + if (!brands) { + return + } + const brandModuleService: BrandModuleService = container.resolve( BRAND_MODULE ) - await brandModuleService.deleteBrands(ids) + await brandModuleService.deleteBrands(brands.map((brand) => brand.id)) } ) ``` -This step receives the brands to create as input. - - +The `createBrandsStep` accepts the brands to create as an input. It resolves the [Brand Module](../../custom-features/module/page.mdx)'s service and uses the generated `createBrands` method to create the brands. -Since a data model is a variable, use the `InferTypeOf` utility imported from `@medusajs/framework/types` to infer its type. +The step passes the created brands to the compensation function, which deletes those brands if an error occurs during the workflow's execution. - + -In the step, you resolve the Brand Module's main service and uses its `createBrands` method to create the brands. +Learn more about compensation functions in [this chapter](../../../advanced-development/workflows/compensation-function/page.mdx). -You return the created brands and pass their IDs to the compensation function, which deletes the brands if an error occurs. + ### Update Brands Step -To create the step that updates existing brands in Medusa, create the file `src/workflows/sync-brands-from-system/steps/update-brands.ts` with the following content: +The brands retrieved in the first step may also have brands that exist in Medusa. So, you'll create a step that updates their details to match that of the CMS. Add the step to the same `src/workflows/sync-brands-from-cms.ts` file: export const updateBrandsHighlights = [ - ["21", "prevUpdatedBrands", "Retrieve the data of the brands before the update."], - ["25", "updateBrands", "Update the brands in Medusa."], - ["34", "updateBrands", "Revert the update by reverting the brands' to before the update."] + ["19", "prevUpdatedBrands", "Retrieve the data of the brands before the update."], + ["23", "updateBrands", "Update the brands in Medusa."], + ["36", "updateBrands", "Revert the update by reverting the brands' details to before the update."] ] -```ts title="src/workflows/sync-brands-from-system/steps/update-brands.ts" highlights={updateBrandsHighlights} collapsibleLines="1-9" expandButtonLabel="Show Imports" -import { - createStep, - StepResponse, -} from "@medusajs/framework/workflows-sdk" -import { InferTypeOf } from "@medusajs/framework/types" -import BrandModuleService from "../../../modules/brand/service" -import { BRAND_MODULE } from "../../../modules/brand" -import { Brand } from "../../../modules/brand/models/brand" +```ts title="src/workflows/sync-brands-from-cms.ts" highlights={updateBrandsHighlights} +// ... + +type UpdateBrand = { + id: string + name: string +} type UpdateBrandsInput = { - brands: InferTypeOf[] + brands: UpdateBrand[] } export const updateBrandsStep = createStep( @@ -157,6 +176,10 @@ export const updateBrandsStep = createStep( return new StepResponse(updatedBrands, prevUpdatedBrands) }, async (prevUpdatedBrands, { container }) => { + if (!prevUpdatedBrands) { + return + } + const brandModuleService: BrandModuleService = container.resolve( BRAND_MODULE ) @@ -166,27 +189,24 @@ export const updateBrandsStep = createStep( ) ``` -This step receives the brands to update as input. - -In the step, you retrieve the brands first to pass them later to the compensation function, then update and return the brands. +The `updateBrandsStep` receives the brands to update in Medusa. In the step, you retrieve the brand's details in Medusa before the update to pass them to the compensation function. You then update the brands using the Brand Module's `updateBrands` generated method. -In the compensation function, you update the brands are again but to their data before the update made by the step. +In the compensation function, which receives the brand's old data, you revert the update using the same `updateBrands` method. ### Create Workflow -Finally, create the workflow in the file `src/workflows/sync-brands-from-system/index.ts` with the following content: +Finally, you'll create the workflow that uses the above steps to sync the brands from the CMS to Medusa. Add to the same `src/workflows/sync-brands-from-cms.ts` file the following: -```ts title="src/workflows/sync-brands-from-system/index.ts" +```ts title="src/workflows/sync-brands-from-cms.ts" +// other imports... import { + // ... createWorkflow, - WorkflowResponse, transform, + WorkflowResponse, } from "@medusajs/framework/workflows-sdk" -import { InferTypeOf } from "@medusajs/framework/types" -import { retrieveBrandsFromSystemStep } from "./steps/retrieve-brands-from-system" -import { createBrandsStep } from "./steps/create-brands" -import { updateBrandsStep } from "./steps/update-brands" -import { Brand } from "../../modules/brand/models/brand" + +// ... export const syncBrandsFromSystemWorkflow = createWorkflow( "sync-brands-from-system", @@ -198,35 +218,37 @@ export const syncBrandsFromSystemWorkflow = createWorkflow( ) ``` -For now, you only add the `retrieveBrandsFromSystemStep` to the workflow that retrieves the brands from the third-party system. +In the workflow, you only use the `retrieveBrandsFromSystemStep` for now, which retrieves the brands from the third-party CMS. -### Identify Brands to Create or Update in Workflow +Next, you need to identify which brands must be created or updated. Since workflows are constructed internally and are only evaluated during execution, you can't access values to perform data manipulation directly. Instead, use [transform](../../../advanced-development/workflows/variable-manipulation/page.mdx) from the Workflows SDK that gives you access to the real-time values of the data, allowing you to create new variables using those values. -Next, you need to identify which brands must be created or updated. + -Since workflows are constructed internally and are only evaluated during execution, you can't access any data's value to perform data manipulation or checks. +Learn more about data manipulation using `transform` in [this chapter](../../../advanced-development/workflows/variable-manipulation/page.mdx). -Instead, use the `transform` utility function imported from `@medusajs/framework/workflows-sdk`, which gives you access to the real-time values of the data to perform actions on them. + So, replace the `TODO` with the following: -```ts title="src/workflows/sync-brands-from-system/index.ts" +```ts title="src/workflows/sync-brands-from-cms.ts" const { toCreate, toUpdate } = transform( { brands, }, (data) => { - const toCreate: InferTypeOf[] = [] - const toUpdate: InferTypeOf[] = [] + const toCreate: CreateBrand[] = [] + const toUpdate: UpdateBrand[] = [] data.brands.forEach((brand) => { if (brand.external_id) { toUpdate.push({ - ...brand, - id: brand.external_id, + id: brand.external_id as string, + name: brand.name as string, }) } else { - toCreate.push(brand) + toCreate.push({ + name: brand.name as string, + }) } }) @@ -242,19 +264,11 @@ const { toCreate, toUpdate } = transform( 1. The data to be passed to the function in the second parameter. 2. A function to execute only when the workflow is executed. Its return value can be consumed by the rest of the workflow. -In the function, you sort the brands as to be created or to be updated based on whether they have an `external_id` property. - - +In `transform`'s function, you loop over the brands array to check which should be created or updated. This logic assumes that a brand in the CMS has an `external_id` property whose value is the brand's ID in Medusa. -This approach assumes that the third-party system stores the ID of the brand in Medusa in `external_id`. - - +You now have the list of brands to create and update. So, replace the new `TODO` with the following: -### Create and Update the Brands - -Finally, replace the new `TODO` with the following: - -```ts title="src/workflows/sync-brands-from-system/index.ts" +```ts title="src/workflows/sync-brands-from-cms.ts" const created = createBrandsStep({ brands: toCreate }) const updated = updateBrandsStep({ brands: toUpdate }) @@ -264,24 +278,28 @@ return new WorkflowResponse({ }) ``` -You pass the brands to be created to the `createBrandsStep`, and the brands to be updated to the `updateBrandsStep`. +You first run the `createBrandsStep` to create the brands that don't exist in Medusa, then the `updateBrandsStep` to update the brands that exist in Medusa. You pass the arrays returned by `transform` as the inputs for the steps. -Then, you return the created and updated brands. +Finally, you return an object of the created and updated brands. You'll execute this workflow in the scheduled job next. --- ## 2. Schedule Syncing Task -To schedule a task that syncs brands from the third-party system, create a scheduled job at `src/jobs/sync-brands-from-system.ts`: +You now have the workflow to sync the brands from the CMS to Medusa. Next, you'll create a scheduled job that runs this workflow once a day to ensure the data between Medusa and the CMS are always in sync. + +A scheduled job is created in a TypeScript or JavaScript file under the `src/jobs` directory. So, create the file `src/jobs/sync-brands-from-cms.ts` with the following content: + +![Directory structure of the Medusa application after adding the scheduled job](https://res.cloudinary.com/dza7lstvk/image/upload/v1733494592/Medusa%20Book/cms-dir-overview-7_dkjb9s.jpg) ```ts title="src/jobs/sync-brands-from-system.ts" import { MedusaContainer } from "@medusajs/framework/types" -import { syncBrandsFromSystemWorkflow } from "../workflows/sync-brands-from-system" +import { syncBrandsFromCmsWorkflow } from "../workflows/sync-brands-from-cms" export default async function (container: MedusaContainer) { const logger = container.resolve("logger") - const { result } = await syncBrandsFromSystemWorkflow(container).run() + const { result } = await syncBrandsFromCmsWorkflow(container).run() logger.info( `Synced brands from third-party system: ${ @@ -291,31 +309,37 @@ export default async function (container: MedusaContainer) { export const config = { name: "sync-brands-from-system", - schedule: "* * * * *", + schedule: "0 0 * * *", // change to * * * * * for debugging } ``` -This defines a scheduled job that runs every minute (for testing purposes). - - +A scheduled job file must export: -Learn more about scheduled jobs [in this guide](../../../basics/scheduled-jobs/page.mdx). +- An asynchronous function that will be executed at the specified schedule. This function must be the file's default export. +- An object of scheduled jobs configuration. It has two properties: + - `name`: A unique name for the scheduled job. + - `schedule`: A string that holds a [cron expression](https://crontab.guru/) indicating the schedule to run the job. - +The scheduled job function accepts as a parameter the [Medusa container](../../../basics/medusa-container/page.mdx) used to resolve framework and commerce tools. You then execute the `syncBrandsFromCmsWorkflow` and use its result to log how many brands were created or updated. -The scheduled job executes the `syncBrandsFromSystemWorkflow` and prints how many brands were created and updated. +Based on the cron expression specified in `config.schedule`, Medusa will run the scheduled job every day at midnight. You can also change it to `* * * * *` to run it every minute for easier debugging. --- ## Test it Out -To test it out, start the Medusa application. In a minute, the scheduled job will run and you'll see a logged message indicating how many brands were created or updated. +To test out the scheduled job, start the Medusa application: + +```bash npm2yarn +npm run dev +``` + +If you set the schedule to `* * * * *` for debugging, the scheduled job will run in a minute. You'll see in the logs how many brands were created or updated. --- ## Summary -In the previous chapters, you: +By following the previous chapters, you utilized Medusa's framework and orchestration tools to perform and automate tasks that span across systems. -- Created a service that acts as a client integrating a third-party system. -- Implemented two-way sync of brands between the third-party system and Medusa using a subscriber and a scheduled job. +With Medusa, you can integrate any service from your commerce ecosystem with ease. You don't have to set up separate applications to manage your different customizations, or worry about data inconsistency across systems. Your efforts only go into implementing the business logic that ties your systems together. diff --git a/www/apps/book/app/learn/customization/integrate-systems/service/page.mdx b/www/apps/book/app/learn/customization/integrate-systems/service/page.mdx index 82b96e44a10d7..daf0fb1121bab 100644 --- a/www/apps/book/app/learn/customization/integrate-systems/service/page.mdx +++ b/www/apps/book/app/learn/customization/integrate-systems/service/page.mdx @@ -1,41 +1,46 @@ import { Prerequisites } from "docs-ui" export const metadata = { - title: `${pageNumber} Integrate Third-Party Brand System in a Service`, + title: `${pageNumber} Guide: Integrate CMS Brand System`, } # {metadata.title} - +In the previous chapters, you've created a [Brand Module](../../custom-features/module/page.mdx) that adds brands to your application. In this chapter, you'll integrate a dummy Content-Management System (CMS) in a new module. The module's service will provide methods to retrieve and manage brands in the CMS. You'll later use this service to sync data from and to the CMS. -This chapter covers how to integrate a dummy third-party system in a service as a step of the ["Integrate Systems" chapter](../page.mdx). + + +Learn more about modules in [this chapter](../../../basics/modules/page.mdx). -## 1. Create Service +## 1. Create Module Directory - +You'll integrate the third-party system in a new CMS Module. So, create the directory `src/modules/cms` that will hold the module's resources. + +![Directory structure after adding the directory for the CMS Module](https://res.cloudinary.com/dza7lstvk/image/upload/v1733492447/Medusa%20Book/cms-dir-overview-1_gasguk.jpg) + +--- -Start by creating the file `src/modules/brand/services/client.ts` with the following content: +## 2. Create Module Service + +Next, you'll create the module's service. It will provide methods to connect and perform actions with the third-party system. + +Create the CMS Module's service at `src/modules/cms/service.ts` with the following content: + +![Directory structure after adding the CMS Module's service](https://res.cloudinary.com/dza7lstvk/image/upload/v1733492583/Medusa%20Book/cms-dir-overview-2_zwcwh3.jpg) export const serviceHighlights = [ - ["4", "BrandClientOptions", "Define the options that the Brand Module receives necessary for the integration."], - ["8", "InjectedDependencies", "Define the dependencies injected into the service."], - ["20", "moduleDef", "Retrieve the module's configuration."] + ["3", "ModuleOptions", "The options that the CMS Module receives."], + ["7", "InjectedDependencies", "The dependencies injected into the service from the module's container."], + ["16", "logger", "Dependencies injected from the module's container"], + ["16", "options", "Options passed to the module in the configurations."] ] -```ts title="src/modules/brand/services/client.ts" highlights={serviceHighlights} +```ts title="src/modules/cms/service.ts" highlights={serviceHighlights} import { Logger, ConfigModule } from "@medusajs/framework/types" -import { BRAND_MODULE } from ".." -export type BrandClientOptions = { +export type ModuleOptions = { apiKey: string } @@ -44,68 +49,54 @@ type InjectedDependencies = { configModule: ConfigModule } -export class BrandClient { - private options_: BrandClientOptions +class CmsModuleService { + private options_: ModuleOptions private logger_: Logger - constructor({ logger, configModule }: InjectedDependencies) { + constructor({ logger }: InjectedDependencies, options: ModuleOptions) { this.logger_ = logger + this.options_ = options - const moduleDef = configModule.modules[BRAND_MODULE] - if (typeof moduleDef !== "boolean") { - this.options_ = moduleDef.options as BrandClientOptions - } + // TODO initialize SDK } } -``` -This creates a `BrandClient` service. Using dependency injection, you resolve the `logger` and `configModule` from the Module's container. - -`logger` is useful to log messages, and `configModule` has configurations exported in `medusa-config.ts`. - -You also define an `options_` property in your service to store the module's options. - -The `configModule`'s `modules` property is an object whose keys are registered module names and values are the module's configuration. +export default CmsModuleService +``` -If the module's configuration isn't a boolean, it has an `options` property that holds the module's options. You use it to set the `options_` property's value. +You create a `CmsModuleService` that will hold the methods to connect to the third-party CMS. A service's constructor accepts two parameters: - +1. The module's container. Since a module is [isolated](../../../advanced-development/modules/isolation/page.mdx), it has a [local container](../../../advanced-development/modules/container/page.mdx) different than the Medusa container you use in other customizations. This container holds framework tools like the [Logger utility](../../../debugging-and-testing/logging/page.mdx) and resources within the module. +2. Options passed to the module when it's later added in Medusa's configurations. These options are useful to pass secret keys or configurations that ensure your module is re-usable across applications. For the CMS Module, you accept the API key to connect to the dummy CMS as an option. -If the service integrating the third-party system was a main service, it receives the module's options as a second parameter. - - +When integrating a third-party system that has a Node.js SDK or client, you can initialize that client in the constructor to be used in the service's methods. ### Integration Methods -Next, add the following methods to simulate sending requests to the third-party system: +Next, you'll add methods that simulate sending requests to a third-party CMS. You'll use these methods later to sync brands from and to the CMS. + +Add the following methods in the `CmsModuleService`: export const methodsHighlights = [ - ["10", "sendRequest", "Since the third-party system isn't real, this method only logs a message."], - ["19", "createBrand", "A method that creates a brand in the third-party system."], - ["23", "deleteBrand", "A method that deletes a brand in the third-party system."], - ["27", "retrieveBrands", "A method that retrieves a brand from a third-party system."] + ["6", "sendRequest", "Since the third-party system isn't real, this method only logs a message."], + ["12", "createBrand", "A method that creates a brand in the third-party system."], + ["16", "deleteBrand", "A method that deletes a brand in the third-party system."], + ["20", "retrieveBrands", "A method that retrieves a brand from a third-party system."] ] ```ts title="src/modules/brand/services/client.ts" highlights={methodsHighlights} -// other imports... -import { InferTypeOf } from "@medusajs/framework/types" -import { Brand } from "../models/brand" - export class BrandClient { // ... // a dummy method to simulate sending a request, // in a realistic scenario, you'd use an SDK, fetch, or axios clients private async sendRequest(url: string, method: string, data?: any) { - this.logger_.info(`Sending a ${ - method - } request to ${url}. data: ${JSON.stringify(data, null, 2)}`) - this.logger_.info(`Client Options: ${ - JSON.stringify(this.options_, null, 2) - }`) + this.logger_.info(`Sending a ${method} request to ${url}.`) + this.logger_.info(`Request Data: ${JSON.stringify(data, null, 2)}`) + this.logger_.info(`API Key: ${JSON.stringify(this.options_.apiKey, null, 2)}`) } - async createBrand(brand: InferTypeOf) { + async createBrand(brand: Record) { await this.sendRequest("/brands", "POST", brand) } @@ -113,7 +104,7 @@ export class BrandClient { await this.sendRequest(`/brands/${id}`, "DELETE") } - async retrieveBrands() { + async retrieveBrands(): Promise[]> { await this.sendRequest("/brands", "GET") return [] @@ -121,85 +112,70 @@ export class BrandClient { } ``` -The `sendRequest` method is a dummy method to simulate sending a request to a third-party system. +The `sendRequest` method sends requests to the third-party CMS. Since this guide isn't using a real CMS, it only simulates the sending by logging messages in the terminal. You also add three methods that use the `sendRequest` method: -- `createBrand` that creates a brand in the third-party system. To reference a brand's type, you use the `InferTypeOf` utility imported from `@medusajs/framework/types`. This transforms a data model, which is a variable, to its equivalent type. +- `createBrand` that creates a brand in the third-party system. - `deleteBrand` that deletes the brand in the third-party system. - `retrieveBrands` to retrieve a brand from the third-party system. --- -## 2. Export Service +## 3. Export Module Definition -If the service integrating the third-party system is the module's main service, you only need to export it in the module definition. +After creating the module's service, you'll export the module definition indicating the module's name and service. -However, since this service is an internal service in the Brand Module, you must export it in a `src/modules/brand/services/index.ts` file: +Create the file `src/modules/cms/index.ts` with the following content: -```ts title="src/modules/brand/services/index.ts" -export * from "./client" -``` +![Directory structure of the Medusa application after adding the module definition file](https://res.cloudinary.com/dza7lstvk/image/upload/v1733492991/Medusa%20Book/cms-dir-overview-3_b0byks.jpg) -This registers the service in the module's container, allowing you to access it in the module's main service. +```ts title="src/modules/cms/index.ts" +import { Module } from "@medusajs/framework/utils" +import CmsModuleService from "./service" ---- - -## 3. Add Internal Service in Main Service - -In the main service at `src/modules/brand/service.ts`, add the following imports and types at the top of the file: - -```ts title="src/modules/brand/service.ts" -// other imports... -import { BrandClient, BrandClientOptions } from "./services" - -type InjectedDependencies = { - brandClient: BrandClient -} -``` - -Then, add the following in the `BrandModuleService` class: +export const CMS_MODULE = "cms" -```ts title="src/modules/brand/service.ts" -class BrandModuleService extends MedusaService({ - Brand, -}) { - public client: BrandClient - - constructor({ brandClient }: InjectedDependencies) { - super(...arguments) - - this.client = brandClient - } -} +export default Module(CMS_MODULE, { + service: CmsModuleService, +}) ``` -In the main module service, you first resolve through dependency injection the `brandClient` from the container and set it in a public property `client`. +You use `Module` from the Modules SDK to export the module's defintion, indicating that the module's name is `cms` and its service is `CmsModuleService`. --- -## 4. Pass Options to the Module +## 4. Add Module to Medusa's Configurations -To pass options in the module, change its configurations in `medusa-config.ts`: +Finally, add the module to the Medusa configurations at `medusa-config.ts`: ```ts title="medusa-config.ts" module.exports = defineConfig({ // ... modules: [ + // ... { - resolve: "./src/modules/brand", + resolve: "./src/modules/cms", options: { - apiKey: process.env.BRAND_API_KEY || "temp", + apiKey: process.env.CMS_API_KEY, }, }, ], }) ``` -A module's configuration accepts an `options` property, which can hold any options to pass to the module. +The object passed in `modules` accept an `options` property, whose value is an object of options to pass to the module. These are the options you receive in the `CmsModuleService`'s constructor. + +You can add the `CMS_API_KEY` environment variable to `.env`: + +```bash +CMS_API_KEY=123 +``` --- -## Next Steps: Sync Brand From Medusa to Third-Party System +## Next Steps: Sync Brand From Medusa to CMS + +You can now use the CMS Module's service to perform actions on the third-party CMS. -In the next chapter, you'll learn how to sync brands created in Medusa to the third-party system using a workflow and a scheduled job. +In the next chapter, you'll learn how to emit an event when a brand is created, then handle that event to sync the brand from Medusa to the third-party service. diff --git a/www/apps/book/app/learn/customization/next-steps/page.mdx b/www/apps/book/app/learn/customization/next-steps/page.mdx index 4dc5e068540cc..d5d355adb91ca 100644 --- a/www/apps/book/app/learn/customization/next-steps/page.mdx +++ b/www/apps/book/app/learn/customization/next-steps/page.mdx @@ -1,32 +1,20 @@ export const metadata = { - title: `${pageNumber} Customizations Next Steps`, + title: `${pageNumber} Customizations Next Steps: Learn the Fundamentals`, } # {metadata.title} -The previous examples under the Customization chapter explained more about customizing Medusa for a realistic use case. +The previous guides introduced Medusa's different concepts and how you can use them to customize Medusa for a realistic use case, You added brands to your application, linked them to products, customized the admin dashboard, and integrated a third-party CMS. -Your learning journey doesn't end here, and this only presents some of Medusa's powerful features. - -This chapter guides you into how to continue your learning journey, and what resources will be helpful for you during your development. - -## Follow the Rest of this Documentation - -The next chapters of the documentation provide more in-depth uses of the different concepts you learned about. - -While you can start playing around with Medusa and customize it, it's highly recommended to continue the rest of this documentation to learn about what more you can do with each concept. - ---- +The next chapters will cover each of these concepts in depth, with the different ways you can use them, their options or configurations, and more advanced features that weren't covered in the previous guides. While you can start building with Medusa, it's highly recommended to follow the next chapters for a better understanding of Medusa's fundamentals. ## Helpful Resources Guides -The [Development Resources](!resources!) documentation provides more helpful guides and references for your development journey. - -Some of these guides and references are: +The [Development Resources](!resources!) documentation provides more helpful guides and references for your development journey. Some of these guides and references include: -1. [Service Factory Reference](!resources!/service-factory-reference): Learn about the methods generated by the service factory with examples. -2. [Workflows Reference](!resources!/medusa-workflows-reference): Browse the list of workflows and their hooks. 3. [Commerce Modules](!resources!/commerce-modules): Browse the list of commerce modules in Medusa and their references to learn how to use them. +1. [Service Factory Reference](!resources!/service-factory-reference): Learn about the methods generated by `MedusaService` with examples. +2. [Workflows Reference](!resources!/medusa-workflows-reference): Browse the list of core workflows and their hooks that are useful for your customizations. 4. [Admin Injection Zones](!resources!/admin-widget-injection-zones): Browse the injection zones in the Medusa Admin to learn where you can inject widgets. --- diff --git a/www/apps/book/generated/edit-dates.mjs b/www/apps/book/generated/edit-dates.mjs index ec5da6af6327e..106bd650c190a 100644 --- a/www/apps/book/generated/edit-dates.mjs +++ b/www/apps/book/generated/edit-dates.mjs @@ -91,7 +91,7 @@ export const generatedEditDates = { "app/learn/advanced-development/workflows/variable-manipulation/page.mdx": "2024-11-14T16:11:24.538Z", "app/learn/customization/custom-features/api-route/page.mdx": "2024-11-28T13:12:10.521Z", "app/learn/customization/custom-features/module/page.mdx": "2024-11-28T09:25:29.098Z", - "app/learn/customization/custom-features/workflow/page.mdx": "2024-11-28T10:47:28.084Z", + "app/learn/customization/custom-features/workflow/page.mdx": "2024-12-06T14:34:53.354Z", "app/learn/customization/extend-features/extend-create-product/page.mdx": "2024-12-05T09:26:15.796Z", "app/learn/customization/custom-features/page.mdx": "2024-11-28T08:21:55.207Z", "app/learn/customization/customize-admin/page.mdx": "2024-12-06T07:21:02.303Z", @@ -100,11 +100,11 @@ export const generatedEditDates = { "app/learn/customization/extend-features/define-link/page.mdx": "2024-12-04T17:15:16.004Z", "app/learn/customization/extend-features/page.mdx": "2024-09-12T12:38:57.394Z", "app/learn/customization/extend-features/query-linked-records/page.mdx": "2024-12-05T10:36:32.357Z", - "app/learn/customization/integrate-systems/handle-event/page.mdx": "2024-09-30T08:43:53.135Z", - "app/learn/customization/integrate-systems/page.mdx": "2024-09-12T12:33:29.827Z", - "app/learn/customization/integrate-systems/schedule-task/page.mdx": "2024-09-30T08:43:53.135Z", - "app/learn/customization/integrate-systems/service/page.mdx": "2024-10-16T08:49:50.899Z", - "app/learn/customization/next-steps/page.mdx": "2024-09-12T10:50:04.873Z", + "app/learn/customization/integrate-systems/handle-event/page.mdx": "2024-12-06T14:34:53.355Z", + "app/learn/customization/integrate-systems/page.mdx": "2024-12-06T14:34:53.355Z", + "app/learn/customization/integrate-systems/schedule-task/page.mdx": "2024-12-06T14:34:53.355Z", + "app/learn/customization/integrate-systems/service/page.mdx": "2024-12-06T14:34:53.356Z", + "app/learn/customization/next-steps/page.mdx": "2024-12-06T14:34:53.356Z", "app/learn/customization/page.mdx": "2024-09-12T11:16:18.504Z", "app/learn/more-resources/cheatsheet/page.mdx": "2024-07-11T16:11:26.480Z", "app/learn/architecture/architectural-modules/page.mdx": "2024-09-23T12:51:04.520Z", diff --git a/www/apps/book/sidebar.mjs b/www/apps/book/sidebar.mjs index 382db13feb7cd..b764aa8f2d628 100644 --- a/www/apps/book/sidebar.mjs +++ b/www/apps/book/sidebar.mjs @@ -159,17 +159,17 @@ export const sidebar = numberSidebarItems( children: [ { type: "link", - title: "Integrate with Service", + title: "CMS Module", path: "/learn/customization/integrate-systems/service", }, { type: "link", - title: "Handle Event", + title: "Sync to CMS", path: "/learn/customization/integrate-systems/handle-event", }, { type: "link", - title: "Schedule Task", + title: "Schedule Syncing", path: "/learn/customization/integrate-systems/schedule-task", }, ], From e7e36f39fbb0eb58c364facf456721e753b96940 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Fri, 6 Dec 2024 19:54:46 +0200 Subject: [PATCH 08/13] docs: support detecting broken link cross-projects (#10483) * docs: support detecting broken link cross-projects * remove double separators --- www/apps/api-reference/markdown/admin.mdx | 2 +- .../markdown/client-libraries.mdx | 4 +- www/apps/api-reference/markdown/store.mdx | 2 +- www/apps/api-reference/next.config.mjs | 47 +++ www/apps/api-reference/package.json | 1 + .../book/app/learn/basics/workflows/page.mdx | 2 +- .../query-linked-records/page.mdx | 2 +- www/apps/book/next.config.mjs | 17 +- .../components/forms/page.mdx | 2 +- .../payment/payment-flow/page.mdx | 2 +- .../product/guides/price-with-taxes/page.mdx | 2 +- .../promotion/extend/page.mdx | 14 +- www/apps/resources/app/recipes/b2b/page.mdx | 2 +- .../subscriptions/examples/standard/page.mdx | 2 +- www/apps/resources/generated/sidebar.mjs | 3 - www/apps/resources/next.config.mjs | 16 +- www/apps/resources/sidebar.mjs | 3 - www/apps/resources/utils/get-slugs.mjs | 4 +- .../src/broken-link-checker.ts | 284 ++++++++++++++++-- .../remark-rehype-plugins/src/constants.ts | 1 + .../src/cross-project-links.ts | 123 +++----- .../remark-rehype-plugins/src/types/index.ts | 10 + .../src/utils/component-link-fixer.ts | 57 +--- .../src/utils/cross-project-link-utils.ts | 21 ++ .../src/utils/perform-action-on-literal.ts | 28 ++ www/packages/types/src/build-scripts.ts | 5 + www/packages/types/src/index.ts | 1 + www/yarn.lock | 1 + 28 files changed, 492 insertions(+), 166 deletions(-) create mode 100644 www/packages/remark-rehype-plugins/src/constants.ts create mode 100644 www/packages/remark-rehype-plugins/src/utils/cross-project-link-utils.ts create mode 100644 www/packages/remark-rehype-plugins/src/utils/perform-action-on-literal.ts create mode 100644 www/packages/types/src/build-scripts.ts diff --git a/www/apps/api-reference/markdown/admin.mdx b/www/apps/api-reference/markdown/admin.mdx index 64a1f360fa785..e2eee7359eadd 100644 --- a/www/apps/api-reference/markdown/admin.mdx +++ b/www/apps/api-reference/markdown/admin.mdx @@ -832,7 +832,7 @@ If you click on the workflow, you'll view a reference of that workflow, includin This is useful if you want to extend an API route and pass additional data or perform custom actions. -Refer to [this guide](https://docs.medusajs.com/learn/customization/extend-features/extend-create-product) to find an example of extending an API route. +Refer to [this guide](!docs!/learn/customization/extend-features/extend-create-product) to find an example of extending an API route. Just Getting Started? -Check out the [Medusa v2 Documentation](https://docs.medusajs.com). +Check out the [Medusa v2 Documentation](!docs!). @@ -16,7 +16,7 @@ To use Medusa's JS SDK library, install the following packages in your project ( npm install @medusajs/js-sdk@latest @medusajs/types@latest ``` -Learn more about the JS SDK in [this documentation](https://docs.medusajs.com/resources/js-sdk). +Learn more about the JS SDK in [this documentation](!resources!/js-sdk). ### Download Full Reference diff --git a/www/apps/api-reference/markdown/store.mdx b/www/apps/api-reference/markdown/store.mdx index 04776d0207574..a68dc51cc718f 100644 --- a/www/apps/api-reference/markdown/store.mdx +++ b/www/apps/api-reference/markdown/store.mdx @@ -831,7 +831,7 @@ If you click on the workflow, you'll view a reference of that workflow, includin This is useful if you want to extend an API route and pass additional data or perform custom actions. -Refer to [this guide](https://docs.medusajs.com/learn/customization/extend-features/extend-create-product) to find an example of extending an API route. +Refer to [this guide](!docs!/learn/customization/extend-features/extend-create-product) to find an example of extending an API route. -Find a full list of the registered resources in the Medusa container and their registration key in [this reference](!resources!/resources/medusa-container-resources). You can use these resources in your custom workflows. +Find a full list of the registered resources in the Medusa container and their registration key in [this reference](!resources!/medusa-container-resources). You can use these resources in your custom workflows. \ No newline at end of file diff --git a/www/apps/book/app/learn/customization/extend-features/query-linked-records/page.mdx b/www/apps/book/app/learn/customization/extend-features/query-linked-records/page.mdx index 6bdeb92f1a36c..2449a7e5a54f1 100644 --- a/www/apps/book/app/learn/customization/extend-features/query-linked-records/page.mdx +++ b/www/apps/book/app/learn/customization/extend-features/query-linked-records/page.mdx @@ -6,7 +6,7 @@ export const metadata = { # {metadata.title} -In the previous chapters, you [defined a link](../define-link/page.mdx) between the [custom Brand Module](../../custom-features/module/page.mdx) and Medusa's [Product Module](!resources!/comerce-modules/product), then [extended the create-product flow](../extend-create-product/page.mdx) to link a product to a brand. +In the previous chapters, you [defined a link](../define-link/page.mdx) between the [custom Brand Module](../../custom-features/module/page.mdx) and Medusa's [Product Module](!resources!/commerce-modules/product), then [extended the create-product flow](../extend-create-product/page.mdx) to link a product to a brand. In this chapter, you'll learn how to retrieve a product's brand (and vice-versa) in two ways: Using Medusa's existing API route, or in customizations, such as a custom API route. diff --git a/www/apps/book/next.config.mjs b/www/apps/book/next.config.mjs index 390cead21ebd4..aa49639579e80 100644 --- a/www/apps/book/next.config.mjs +++ b/www/apps/book/next.config.mjs @@ -9,11 +9,27 @@ import { crossProjectLinksPlugin, } from "remark-rehype-plugins" import { sidebar } from "./sidebar.mjs" +import path from "path" const withMDX = mdx({ extension: /\.mdx?$/, options: { rehypePlugins: [ + [ + brokenLinkCheckerPlugin, + { + crossProjects: { + resources: { + projectPath: path.resolve("..", "resources"), + hasGeneratedSlugs: true, + }, + ui: { + projectPath: path.resolve("..", "ui"), + contentPath: "src/content/docs", + }, + }, + }, + ], [ crossProjectLinksPlugin, { @@ -37,7 +53,6 @@ const withMDX = mdx({ process.env.VERCEL_ENV === "production", }, ], - [brokenLinkCheckerPlugin], [localLinksRehypePlugin], [ rehypeMdxCodeProps, diff --git a/www/apps/resources/app/admin-components/components/forms/page.mdx b/www/apps/resources/app/admin-components/components/forms/page.mdx index 5051232fe2f5f..64a24647a5b89 100644 --- a/www/apps/resources/app/admin-components/components/forms/page.mdx +++ b/www/apps/resources/app/admin-components/components/forms/page.mdx @@ -11,7 +11,7 @@ export const metadata = { The Medusa Admin has two types of forms: 1. Create forms, created using the [FocusModal UI component](!ui!/components/focus-modal). -2. Edit or update forms, created using the [Drawer UI component](!ui!/ui/components/drawer). +2. Edit or update forms, created using the [Drawer UI component](!ui!/components/drawer). This guide explains how to create these two form types following the Medusa Admin's conventions. diff --git a/www/apps/resources/app/commerce-modules/payment/payment-flow/page.mdx b/www/apps/resources/app/commerce-modules/payment/payment-flow/page.mdx index 93f5cc3807a77..10742536de320 100644 --- a/www/apps/resources/app/commerce-modules/payment/payment-flow/page.mdx +++ b/www/apps/resources/app/commerce-modules/payment/payment-flow/page.mdx @@ -66,7 +66,7 @@ remoteLink.create({ -Learn more about the remote link in [this documentation](!docs!/advanced-development/module-links/remote-link). +Learn more about the remote link in [this documentation](!docs!/learn/advanced-development/module-links/remote-link). diff --git a/www/apps/resources/app/commerce-modules/product/guides/price-with-taxes/page.mdx b/www/apps/resources/app/commerce-modules/product/guides/price-with-taxes/page.mdx index d4903742cc27c..60c2d9701613d 100644 --- a/www/apps/resources/app/commerce-modules/product/guides/price-with-taxes/page.mdx +++ b/www/apps/resources/app/commerce-modules/product/guides/price-with-taxes/page.mdx @@ -14,7 +14,7 @@ In this document, you'll learn how to calculate a product variant's price with t You'll need the following resources for the taxes calculation: -1. [Query](!docs!/advanced-development/module-links/query) to retrieve the product's variants' prices for a context. Learn more about that in [this guide](../price/page.mdx). +1. [Query](!docs!/learn/advanced-development/module-links/query) to retrieve the product's variants' prices for a context. Learn more about that in [this guide](../price/page.mdx). 2. The Tax Module's main service to get the tax lines for each product. ```ts diff --git a/www/apps/resources/app/commerce-modules/promotion/extend/page.mdx b/www/apps/resources/app/commerce-modules/promotion/extend/page.mdx index 56b7489e75c25..cb7e5f4b00324 100644 --- a/www/apps/resources/app/commerce-modules/promotion/extend/page.mdx +++ b/www/apps/resources/app/commerce-modules/promotion/extend/page.mdx @@ -121,7 +121,7 @@ To do that, you'll consume the [promotionsCreated](/references/medusa-workflows/ -Learn more about workflow hooks in [this guide](!docs!/advanced-development/workflows/workflow-hooks). +Learn more about workflow hooks in [this guide](!docs!/learn/advanced-development/workflows/workflow-hooks). @@ -156,7 +156,7 @@ In the snippet above, you add a validation rule indicating that `custom_name` is -Learn more about additional data validation in [this guide](!docs!/advanced-development/api-routes/additional-data). +Learn more about additional data validation in [this guide](!docs!/learn/advanced-development/api-routes/additional-data). @@ -208,7 +208,7 @@ In the compensation function that undoes the step's actions in case of an error, -Learn more about compensation functions in [this guide](!docs!/advanced-development/workflows/compensation-function). +Learn more about compensation functions in [this guide](!docs!/learn/advanced-development/workflows/compensation-function). @@ -266,9 +266,9 @@ The workflow accepts as an input the created promotion and the `additional_data` In the workflow, you: -1. Use the `transform` utility to get the value of `custom_name` based on whether it's set in `additional_data`. Learn more about why you can't use conditional operators in a workflow without using `transform` in [this guide](!docs!/advanced-development/workflows/conditions#why-if-conditions-arent-allowed-in-workflows). +1. Use the `transform` utility to get the value of `custom_name` based on whether it's set in `additional_data`. Learn more about why you can't use conditional operators in a workflow without using `transform` in [this guide](!docs!/learn/advanced-development/workflows/conditions#why-if-conditions-arent-allowed-in-workflows). 2. Create the `Custom` record using the `createCustomStep`. -3. Use the `when-then` utility to link the promotion to the `Custom` record if it was created. Learn more about why you can't use if-then conditions in a workflow without using `when-then` in [this guide](!docs!/advanced-development/workflows/conditions#why-if-conditions-arent-allowed-in-workflows). +3. Use the `when-then` utility to link the promotion to the `Custom` record if it was created. Learn more about why you can't use if-then conditions in a workflow without using `when-then` in [this guide](!docs!/learn/advanced-development/workflows/conditions#why-if-conditions-arent-allowed-in-workflows). You'll next execute the workflow in the hook handler. @@ -379,7 +379,7 @@ Among the returned `promotion` object, you'll find a `custom` property which hol ### Retrieve using Query -You can also retrieve the `Custom` record linked to a promotion in your code using [Query](!docs!/advanced-development/module-links/query). +You can also retrieve the `Custom` record linked to a promotion in your code using [Query](!docs!/learn/advanced-development/module-links/query). For example: @@ -393,7 +393,7 @@ const { data: [promotion] } = await query.graph({ }) ``` -Learn more about how to use Query in [this guide](!docs!/advanced-development/module-links/query). +Learn more about how to use Query in [this guide](!docs!/learn/advanced-development/module-links/query). --- diff --git a/www/apps/resources/app/recipes/b2b/page.mdx b/www/apps/resources/app/recipes/b2b/page.mdx index 5a47fa580a03c..5b06d915a0d21 100644 --- a/www/apps/resources/app/recipes/b2b/page.mdx +++ b/www/apps/resources/app/recipes/b2b/page.mdx @@ -736,7 +736,7 @@ The Medusa Admin plugin can be extended to add widgets, new pages, and setting p icon: AcademicCapSolid, }, { - href: "!docs!/learn/advanced-development/admin/setting-pages", + href: "!docs!/learn/advanced-development/admin/ui-routes#create-settings-page", title: "Create Admin Setting Page", text: "Learn how to add new page to the Medusa Admin settings.", icon: AcademicCapSolid, diff --git a/www/apps/resources/app/recipes/subscriptions/examples/standard/page.mdx b/www/apps/resources/app/recipes/subscriptions/examples/standard/page.mdx index 7bd662c235c06..d78d629919cbc 100644 --- a/www/apps/resources/app/recipes/subscriptions/examples/standard/page.mdx +++ b/www/apps/resources/app/recipes/subscriptions/examples/standard/page.mdx @@ -2008,7 +2008,7 @@ This loops over the returned subscriptions and executes the `createSubscriptionO ### Further Reads -- [How to Create a Scheduled Job](!docs!/learn/basics/scheeduled-jobs) +- [How to Create a Scheduled Job](!docs!/learn/basics/scheduled-jobs) --- diff --git a/www/apps/resources/generated/sidebar.mjs b/www/apps/resources/generated/sidebar.mjs index c51bf4af4f279..d8899e0f635f8 100644 --- a/www/apps/resources/generated/sidebar.mjs +++ b/www/apps/resources/generated/sidebar.mjs @@ -9118,9 +9118,6 @@ export const generatedSidebar = [ } ] }, - { - "type": "separator" - }, { "loaded": true, "isPathHref": true, diff --git a/www/apps/resources/next.config.mjs b/www/apps/resources/next.config.mjs index 95a2a6dc49c2f..5065b8f3ff228 100644 --- a/www/apps/resources/next.config.mjs +++ b/www/apps/resources/next.config.mjs @@ -7,13 +7,27 @@ import { workflowDiagramLinkFixerPlugin, } from "remark-rehype-plugins" import mdxPluginOptions from "./mdx-options.mjs" +import path from "node:path" const withMDX = mdx({ extension: /\.mdx?$/, options: { rehypePlugins: [ + [ + brokenLinkCheckerPlugin, + { + crossProjects: { + docs: { + projectPath: path.resolve("..", "book"), + }, + ui: { + projectPath: path.resolve("..", "ui"), + contentPath: "src/content/docs", + }, + }, + }, + ], ...mdxPluginOptions.options.rehypePlugins, - [brokenLinkCheckerPlugin], [localLinksRehypePlugin], [typeListLinkFixerPlugin], [ diff --git a/www/apps/resources/sidebar.mjs b/www/apps/resources/sidebar.mjs index 7befd8c476ab9..7e7ec55154da8 100644 --- a/www/apps/resources/sidebar.mjs +++ b/www/apps/resources/sidebar.mjs @@ -2131,9 +2131,6 @@ export const sidebar = sidebarAttachHrefCommonOptions([ }, ], }, - { - type: "separator", - }, { type: "category", title: "General", diff --git a/www/apps/resources/utils/get-slugs.mjs b/www/apps/resources/utils/get-slugs.mjs index b3a6ef066fba5..b3e247b5bb65d 100644 --- a/www/apps/resources/utils/get-slugs.mjs +++ b/www/apps/resources/utils/get-slugs.mjs @@ -7,7 +7,7 @@ const monoRepoPath = path.resolve("..", "..", "..") /** * * @param {string} dir - The directory to search in - * @returns {Promise<{ origSlug: string; newSlug: string }[]>} + * @returns {Promise} */ export default async function getSlugs(options = {}) { let { dir, basePath = path.resolve("app"), baseSlug = basePath } = options @@ -15,7 +15,7 @@ export default async function getSlugs(options = {}) { dir = basePath } /** - * @type {{ origSlug: string; newSlug: string }[]} + * @type {import("types").SlugChange[]} */ const slugs = [] diff --git a/www/packages/remark-rehype-plugins/src/broken-link-checker.ts b/www/packages/remark-rehype-plugins/src/broken-link-checker.ts index b6c074ae4dc17..4d9eb542d0a4f 100644 --- a/www/packages/remark-rehype-plugins/src/broken-link-checker.ts +++ b/www/packages/remark-rehype-plugins/src/broken-link-checker.ts @@ -1,9 +1,253 @@ -import { existsSync } from "fs" +import { existsSync, readdirSync, readFileSync } from "fs" import path from "path" import type { Transformer } from "unified" -import type { UnistNode, UnistTree } from "./types/index.js" +import type { + BrokenLinkCheckerOptions, + UnistNode, + UnistNodeWithData, + UnistTree, +} from "./types/index.js" +import type { VFile } from "vfile" +import { parseCrossProjectLink } from "./utils/cross-project-link-utils.js" +import { SlugChange } from "types" +import getAttribute from "./utils/get-attribute.js" +import { estreeToJs } from "./utils/estree-to-js.js" +import { performActionOnLiteral } from "./utils/perform-action-on-literal.js" +import { MD_LINK_REGEX } from "./constants.js" -export function brokenLinkCheckerPlugin(): Transformer { +function getErrorMessage({ + link, + file, +}: { + link: string + file: VFile +}): string { + return `Broken link found! ${link} linked in ${file.history[0]}` +} + +function checkLocalLinkExists({ + link, + file, + currentPageFilePath, +}: { + link: string + file: VFile + currentPageFilePath: string +}) { + // get absolute path of the URL + const linkedFilePath = path + .resolve(currentPageFilePath, link) + .replace(/#.*$/, "") + // check if the file exists + if (!existsSync(linkedFilePath)) { + throw new Error( + getErrorMessage({ + link, + file, + }) + ) + } +} + +function mdxPageExists(pagePath: string): boolean { + if (!existsSync(pagePath)) { + // for projects that use a convention other than mdx + // check if an mdx file exists with the same name + if (existsSync(`${pagePath}.mdx`)) { + return true + } + return false + } + + if (existsSync(path.join(pagePath, "page.mdx"))) { + return true + } + + // for projects that use a convention other than mdx + // check if an mdx file exists with the same name + return readdirSync(pagePath).some((fileName) => fileName.endsWith(".mdx")) +} + +function componentChecker({ + node, + ...rest +}: { + node: UnistNodeWithData + file: VFile + currentPageFilePath: string + options: BrokenLinkCheckerOptions +}) { + if (!node.name) { + return + } + + let attributeName: string | undefined + + const maybeCheckAttribute = () => { + if (!attributeName) { + return + } + + const attribute = getAttribute(node, attributeName) + + if ( + !attribute || + typeof attribute.value === "string" || + !attribute.value.data?.estree + ) { + return + } + + const itemJsVar = estreeToJs(attribute.value.data.estree) + + if (!itemJsVar) { + return + } + + performActionOnLiteral(itemJsVar, (item) => { + checkLink({ + link: item.original.value as string, + ...rest, + }) + }) + } + + switch (node.name) { + case "Prerequisites": + case "CardList": + attributeName = "items" + break + case "Card": + attributeName = "href" + break + case "WorkflowDiagram": + attributeName = "workflow" + break + case "TypeList": + attributeName = "types" + break + } + + maybeCheckAttribute() +} + +function checkLink({ + link, + file, + currentPageFilePath, + options, +}: { + link: unknown | undefined + file: VFile + currentPageFilePath: string + options: BrokenLinkCheckerOptions +}) { + if (!link || typeof link !== "string") { + return + } + // try to remove hash + const hashIndex = link.lastIndexOf("#") + const likeWithoutHash = hashIndex !== -1 ? link.substring(0, hashIndex) : link + if (likeWithoutHash.match(/page\.mdx?$/)) { + checkLocalLinkExists({ + link: likeWithoutHash, + file, + currentPageFilePath, + }) + return + } + + const parsedLink = parseCrossProjectLink(likeWithoutHash) + + if (!parsedLink || !Object.hasOwn(options.crossProjects, parsedLink.area)) { + if (MD_LINK_REGEX.test(link)) { + // try fixing MDX links + let linkMatches + let tempLink = link + MD_LINK_REGEX.lastIndex = 0 + + while ((linkMatches = MD_LINK_REGEX.exec(tempLink)) !== null) { + if (!linkMatches.groups?.link) { + return + } + + checkLink({ + link: linkMatches.groups.link, + file, + currentPageFilePath, + options, + }) + + tempLink = tempLink.replace(linkMatches.groups.link, "") + // reset regex + MD_LINK_REGEX.lastIndex = 0 + } + } + return + } + + const projectOptions = options.crossProjects[parsedLink.area] + + const isReferenceLink = parsedLink.path.startsWith("/references") + const baseDir = isReferenceLink + ? "references" + : projectOptions.contentPath || "app" + const pagePath = isReferenceLink + ? parsedLink.path.replace(/^\/references/, "") + : parsedLink.path + // check if the file exists + if (mdxPageExists(path.join(projectOptions.projectPath, baseDir, pagePath))) { + return + } + + // file doesn't exist, check if slugs are enabled and generated + const generatedSlugsPath = path.join( + projectOptions.projectPath, + "generated", + "slug-changes.mjs" + ) + if (!projectOptions.hasGeneratedSlugs || !existsSync(generatedSlugsPath)) { + throw new Error( + getErrorMessage({ + link, + file, + }) + ) + } + + // get slugs from file + const generatedSlugContent = readFileSync(generatedSlugsPath, "utf-8") + const slugChanges: SlugChange[] = JSON.parse( + generatedSlugContent.substring(generatedSlugContent.indexOf("[")) + ) + const slugChange = slugChanges.find( + (change) => change.newSlug === parsedLink.path + ) + + if ( + !slugChange || + !mdxPageExists(path.join(projectOptions.projectPath, slugChange.origSlug)) + ) { + throw new Error( + getErrorMessage({ + link, + file, + }) + ) + } +} + +const allowedComponentNames = [ + "Card", + "CardList", + "Prerequisites", + "WorkflowDiagram", + "TypeList", +] + +export function brokenLinkCheckerPlugin( + options: BrokenLinkCheckerOptions +): Transformer { return async (tree, file) => { const { visit } = await import("unist-util-visit") @@ -12,20 +256,26 @@ export function brokenLinkCheckerPlugin(): Transformer { "" ) - visit(tree as UnistTree, "element", (node: UnistNode) => { - if (node.tagName !== "a" || !node.properties?.href?.match(/page\.mdx?/)) { - return + visit( + tree as UnistTree, + ["element", "mdxJsxFlowElement"], + (node: UnistNode) => { + if (node.tagName === "a" && node.properties?.href) { + checkLink({ + link: node.properties.href, + file, + currentPageFilePath, + options, + }) + } else if (node.name && allowedComponentNames.includes(node.name)) { + componentChecker({ + node: node as UnistNodeWithData, + file, + currentPageFilePath, + options, + }) + } } - // get absolute path of the URL - const linkedFilePath = path - .resolve(currentPageFilePath, node.properties.href) - .replace(/#.*$/, "") - // check if the file exists - if (!existsSync(linkedFilePath)) { - throw new Error( - `Broken link found! ${node.properties.href} linked in ${file.history[0]}` - ) - } - }) + ) } } diff --git a/www/packages/remark-rehype-plugins/src/constants.ts b/www/packages/remark-rehype-plugins/src/constants.ts new file mode 100644 index 0000000000000..934cda4618ed3 --- /dev/null +++ b/www/packages/remark-rehype-plugins/src/constants.ts @@ -0,0 +1 @@ +export const MD_LINK_REGEX = /\[(.*?)\]\((?(![a-z]+!|\.).*?)\)/gm diff --git a/www/packages/remark-rehype-plugins/src/cross-project-links.ts b/www/packages/remark-rehype-plugins/src/cross-project-links.ts index 407dda58fcff0..3fe859358c3a3 100644 --- a/www/packages/remark-rehype-plugins/src/cross-project-links.ts +++ b/www/packages/remark-rehype-plugins/src/cross-project-links.ts @@ -1,18 +1,13 @@ -/* eslint-disable no-case-declarations */ import type { Transformer } from "unified" import type { CrossProjectLinksOptions, - ExpressionJsVar, UnistNode, UnistNodeWithData, UnistTree, } from "./types/index.js" import { estreeToJs } from "./utils/estree-to-js.js" import getAttribute from "./utils/get-attribute.js" -import { - isExpressionJsVarLiteral, - isExpressionJsVarObj, -} from "./utils/expression-is-utils.js" +import { performActionOnLiteral } from "./utils/perform-action-on-literal.js" const PROJECT_REGEX = /^!(?[\w-]+)!/ @@ -61,89 +56,65 @@ function componentFixer( return } - const fixProperty = (item: ExpressionJsVar) => { - if (!isExpressionJsVarObj(item)) { + let attributeName: string | undefined + + const maybeCheckAttribute = () => { + if (!attributeName) { return } - Object.entries(item).forEach(([key, value]) => { - if ( - (key !== "href" && key !== "link") || - !isExpressionJsVarLiteral(value) - ) { - return - } + const attribute = getAttribute(node, attributeName) + + if ( + !attribute || + typeof attribute.value === "string" || + !attribute.value.data?.estree + ) { + return + } + + const itemJsVar = estreeToJs(attribute.value.data.estree) + + if (!itemJsVar) { + return + } - value.original.value = matchAndFixLinks( - value.original.value as string, + performActionOnLiteral(itemJsVar, (item) => { + item.original.value = matchAndFixLinks( + item.original.value as string, options ) - value.original.raw = JSON.stringify(value.original.value) + item.original.raw = JSON.stringify(item.original.value) }) } switch (node.name) { case "CardList": - const itemsAttribute = getAttribute(node, "items") - - if ( - !itemsAttribute?.value || - typeof itemsAttribute.value === "string" || - !itemsAttribute.value.data?.estree - ) { - return - } - - const jsVar = estreeToJs(itemsAttribute.value.data.estree) - - if (!jsVar) { - return - } - - if (Array.isArray(jsVar)) { - jsVar.forEach(fixProperty) - } else { - fixProperty(jsVar) - } - return - case "Card": - const hrefAttribute = getAttribute(node, "href") - - if (!hrefAttribute?.value || typeof hrefAttribute.value !== "string") { - return - } - - hrefAttribute.value = matchAndFixLinks(hrefAttribute.value, options) - - return case "Prerequisites": - const prerequisitesItemsAttribute = getAttribute(node, "items") - - if ( - !prerequisitesItemsAttribute?.value || - typeof prerequisitesItemsAttribute.value === "string" || - !prerequisitesItemsAttribute.value.data?.estree - ) { - return - } - - const prerequisitesJsVar = estreeToJs( - prerequisitesItemsAttribute.value.data.estree - ) - - if (!prerequisitesJsVar) { - return - } - - if (Array.isArray(prerequisitesJsVar)) { - prerequisitesJsVar.forEach(fixProperty) - } else { - fixProperty(prerequisitesJsVar) - } - return + attributeName = "items" + break + case "Card": + attributeName = "href" + break + case "WorkflowDiagram": + attributeName = "workflow" + break + case "TypeList": + attributeName = "types" + break } + + maybeCheckAttribute() } +const allowedComponentNames = [ + "Card", + "CardList", + "Prerequisites", + "WorkflowDiagram", + "TypeList", +] + export function crossProjectLinksPlugin( options: CrossProjectLinksOptions ): Transformer { @@ -155,9 +126,7 @@ export function crossProjectLinksPlugin( ["element", "mdxJsxFlowElement"], (node: UnistNode) => { const isComponent = - node.name === "Card" || - node.name === "CardList" || - node.name === "Prerequisites" + node.name && allowedComponentNames.includes(node.name) const isLink = node.tagName === "a" && node.properties?.href if (!isComponent && !isLink) { return diff --git a/www/packages/remark-rehype-plugins/src/types/index.ts b/www/packages/remark-rehype-plugins/src/types/index.ts index 5cffb878ac8ce..faaad3a18c14a 100644 --- a/www/packages/remark-rehype-plugins/src/types/index.ts +++ b/www/packages/remark-rehype-plugins/src/types/index.ts @@ -118,6 +118,16 @@ export declare type CrossProjectLinksOptions = { useBaseUrl?: boolean } +export declare type BrokenLinkCheckerOptions = { + crossProjects: { + [k: string]: { + projectPath: string + contentPath?: string + hasGeneratedSlugs?: boolean + } + } +} + export declare type ComponentLinkFixerLinkType = "md" | "value" export declare type ComponentLinkFixerOptions = { diff --git a/www/packages/remark-rehype-plugins/src/utils/component-link-fixer.ts b/www/packages/remark-rehype-plugins/src/utils/component-link-fixer.ts index fff48ec155ef7..085190c7f8bcc 100644 --- a/www/packages/remark-rehype-plugins/src/utils/component-link-fixer.ts +++ b/www/packages/remark-rehype-plugins/src/utils/component-link-fixer.ts @@ -1,21 +1,13 @@ import path from "path" import { Transformer } from "unified" -import { - ComponentLinkFixerLinkType, - ExpressionJsVar, - UnistNodeWithData, - UnistTree, -} from "../types/index.js" +import { UnistNodeWithData, UnistTree } from "../types/index.js" import { FixLinkOptions, fixLinkUtil } from "../index.js" import getAttribute from "../utils/get-attribute.js" import { estreeToJs } from "../utils/estree-to-js.js" -import { - isExpressionJsVarLiteral, - isExpressionJsVarObj, -} from "../utils/expression-is-utils.js" import { ComponentLinkFixerOptions } from "../types/index.js" +import { performActionOnLiteral } from "./perform-action-on-literal.js" +import { MD_LINK_REGEX } from "../constants.js" -const MD_LINK_REGEX = /\[(.*?)\]\((?(![a-z]+!|\.).*?)\)/gm const VALUE_LINK_REGEX = /^(![a-z]+!|\.)/gm function matchMdLinks( @@ -59,33 +51,6 @@ function matchValueLink( }) } -function traverseJsVar( - item: ExpressionJsVar[] | ExpressionJsVar, - linkOptions: Omit, - checkLinksType: ComponentLinkFixerLinkType -) { - const linkFn = checkLinksType === "md" ? matchMdLinks : matchValueLink - if (Array.isArray(item)) { - item.forEach((item) => traverseJsVar(item, linkOptions, checkLinksType)) - } else if (isExpressionJsVarLiteral(item)) { - item.original.value = linkFn(item.original.value as string, linkOptions) - item.original.raw = JSON.stringify(item.original.value) - } else { - Object.values(item).forEach((value) => { - if (Array.isArray(value) || isExpressionJsVarObj(value)) { - return traverseJsVar(value, linkOptions, checkLinksType) - } - - if (!isExpressionJsVarLiteral(value)) { - return - } - - value.original.value = linkFn(value.original.value as string, linkOptions) - value.original.raw = JSON.stringify(value.original.value) - }) - } -} - export function componentLinkFixer( componentName: string, attributeName: string, @@ -117,12 +82,12 @@ export function componentLinkFixer( return } - const workflowAttribute = getAttribute(node, attributeName) + const attribute = getAttribute(node, attributeName) if ( - !workflowAttribute || - typeof workflowAttribute.value === "string" || - !workflowAttribute.value.data?.estree + !attribute || + typeof attribute.value === "string" || + !attribute.value.data?.estree ) { return } @@ -132,13 +97,17 @@ export function componentLinkFixer( appsPath, } - const itemJsVar = estreeToJs(workflowAttribute.value.data.estree) + const itemJsVar = estreeToJs(attribute.value.data.estree) if (!itemJsVar) { return } - traverseJsVar(itemJsVar, linkOptions, checkLinksType) + const linkFn = checkLinksType === "md" ? matchMdLinks : matchValueLink + performActionOnLiteral(itemJsVar, (item) => { + item.original.value = linkFn(item.original.value as string, linkOptions) + item.original.raw = JSON.stringify(item.original.value) + }) }) } } diff --git a/www/packages/remark-rehype-plugins/src/utils/cross-project-link-utils.ts b/www/packages/remark-rehype-plugins/src/utils/cross-project-link-utils.ts new file mode 100644 index 0000000000000..b75cdc5de1938 --- /dev/null +++ b/www/packages/remark-rehype-plugins/src/utils/cross-project-link-utils.ts @@ -0,0 +1,21 @@ +const PROJECT_REGEX = /^!(?[\w-]+)!/ + +export const parseCrossProjectLink = ( + link: string +): + | { + area: string + path: string + } + | undefined => { + const projectArea = PROJECT_REGEX.exec(link) + + if (!projectArea?.groups?.area) { + return undefined + } + + return { + area: projectArea.groups.area, + path: link.replace(PROJECT_REGEX, ""), + } +} diff --git a/www/packages/remark-rehype-plugins/src/utils/perform-action-on-literal.ts b/www/packages/remark-rehype-plugins/src/utils/perform-action-on-literal.ts new file mode 100644 index 0000000000000..80a47c5fd55ed --- /dev/null +++ b/www/packages/remark-rehype-plugins/src/utils/perform-action-on-literal.ts @@ -0,0 +1,28 @@ +import { ExpressionJsVar, ExpressionJsVarLiteral } from "../types/index.js" +import { + isExpressionJsVarLiteral, + isExpressionJsVarObj, +} from "./expression-is-utils.js" + +export const performActionOnLiteral = ( + item: ExpressionJsVar[] | ExpressionJsVar, + action: (item: ExpressionJsVarLiteral) => void +) => { + if (Array.isArray(item)) { + item.forEach((i) => performActionOnLiteral(i, action)) + } else if (isExpressionJsVarLiteral(item)) { + action(item) + } else { + Object.values(item).forEach((value) => { + if (Array.isArray(value) || isExpressionJsVarObj(value)) { + return performActionOnLiteral(value, action) + } + + if (!isExpressionJsVarLiteral(value)) { + return + } + + action(value) + }) + } +} diff --git a/www/packages/types/src/build-scripts.ts b/www/packages/types/src/build-scripts.ts new file mode 100644 index 0000000000000..2344ca8e06d85 --- /dev/null +++ b/www/packages/types/src/build-scripts.ts @@ -0,0 +1,5 @@ +export type SlugChange = { + origSlug: string + newSlug: string + filePath: string +} diff --git a/www/packages/types/src/index.ts b/www/packages/types/src/index.ts index 8bc8df0ebfe2c..67a85c06d02fe 100644 --- a/www/packages/types/src/index.ts +++ b/www/packages/types/src/index.ts @@ -1,4 +1,5 @@ export * from "./api-testing.js" +export * from "./build-scripts.js" export * from "./config.js" export * from "./general.js" export * from "./menu.js" diff --git a/www/yarn.lock b/www/yarn.lock index fcc0d66ee1f6b..257e5a40daf97 100644 --- a/www/yarn.lock +++ b/www/yarn.lock @@ -6805,6 +6805,7 @@ __metadata: react-transition-group: ^4.4.5 rehype-mdx-code-props: ^3.0.1 rehype-slug: ^6.0.0 + remark-rehype-plugins: "*" slugify: ^1.6.6 swr: ^2.2.0 tailwind: "*" From c247f5bca16ec559eb8e484b262bba1c6d13aa88 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Sat, 7 Dec 2024 00:10:52 +0200 Subject: [PATCH 09/13] docs: fix self hosting deployments not showing (#10486) --- www/apps/resources/app/deployment/page.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/apps/resources/app/deployment/page.mdx b/www/apps/resources/app/deployment/page.mdx index 6a79b69667496..c96aa53478f74 100644 --- a/www/apps/resources/app/deployment/page.mdx +++ b/www/apps/resources/app/deployment/page.mdx @@ -32,7 +32,7 @@ With Medusa Cloud, you maintain full customization control as you deploy your ow To host and maintain Medusa on your own, check out the following guides. - + --- From 55f5ce4690db29c8253f2206d3929b40cd9b723f Mon Sep 17 00:00:00 2001 From: Hirotaka Mizutani <52546+hirotaka@users.noreply.github.com> Date: Sat, 7 Dec 2024 22:52:15 +0900 Subject: [PATCH 10/13] feat: added Japanese translation (#10379) * feat: add Japanese language support * feat: add missing required keys for Japanese translation --- .../admin/dashboard/src/i18n/languages.ts | 8 +- .../dashboard/src/i18n/translations/index.ts | 4 + .../dashboard/src/i18n/translations/ja.json | 2801 +++++++++++++++++ 3 files changed, 2812 insertions(+), 1 deletion(-) create mode 100644 packages/admin/dashboard/src/i18n/translations/ja.json diff --git a/packages/admin/dashboard/src/i18n/languages.ts b/packages/admin/dashboard/src/i18n/languages.ts index b82e3d6976c29..1926d8a24f6f5 100644 --- a/packages/admin/dashboard/src/i18n/languages.ts +++ b/packages/admin/dashboard/src/i18n/languages.ts @@ -1,4 +1,4 @@ -import { de, enUS, es, fr, it, pl, ptBR, th, tr } from "date-fns/locale" +import { de, enUS, es, fr, it, ja, pl, ptBR, th, tr } from "date-fns/locale" import { Language } from "./types" export const languages: Language[] = [ @@ -32,6 +32,12 @@ export const languages: Language[] = [ ltr: true, date_locale: it, }, + { + code: "ja", + display_name: "日本語", + ltr: true, + date_locale: ja, + }, { code: "pl", display_name: "Polski", diff --git a/packages/admin/dashboard/src/i18n/translations/index.ts b/packages/admin/dashboard/src/i18n/translations/index.ts index cca0a445277b7..00ceee957d08e 100644 --- a/packages/admin/dashboard/src/i18n/translations/index.ts +++ b/packages/admin/dashboard/src/i18n/translations/index.ts @@ -3,6 +3,7 @@ import en from "./en.json" import es from "./es.json" import fr from "./fr.json" import it from "./it.json" +import ja from "./ja.json" import pl from "./pl.json" import ptBR from "./ptBR.json" import th from "./th.json" @@ -24,6 +25,9 @@ export default { it: { translation: it, }, + ja: { + translation: ja, + }, pl: { translation: pl, }, diff --git a/packages/admin/dashboard/src/i18n/translations/ja.json b/packages/admin/dashboard/src/i18n/translations/ja.json new file mode 100644 index 0000000000000..b2c3d13f8de80 --- /dev/null +++ b/packages/admin/dashboard/src/i18n/translations/ja.json @@ -0,0 +1,2801 @@ +{ + "$schema": "./$schema.json", + "general": { + "ascending": "昇順", + "descending": "降順", + "add": "追加", + "start": "開始", + "end": "終了", + "open": "開く", + "close": "閉じる", + "apply": "適用", + "range": "範囲", + "search": "検索", + "of": "の", + "results": "結果", + "pages": "ページ", + "next": "次へ", + "prev": "前へ", + "is": "は", + "timeline": "タイムライン", + "success": "成功", + "warning": "警告", + "tip": "ヒント", + "error": "エラー", + "select": "選択", + "selected": "選択済み", + "enabled": "有効", + "disabled": "無効", + "expired": "期限切れ", + "active": "アクティブ", + "revoked": "取り消し済み", + "new": "新規", + "modified": "変更済み", + "added": "追加済み", + "removed": "削除済み", + "admin": "管理者", + "store": "ストア", + "details": "詳細", + "items_one": "{{count}}個のアイテム", + "items_other": "{{count}}個のアイテム", + "countSelected": "{{count}}個選択済み", + "countOfTotalSelected": "{{total}}個中{{count}}個選択済み", + "plusCount": "+ {{count}}", + "plusCountMore": "+ さらに{{count}}個", + "areYouSure": "本当によろしいですか?", + "noRecordsFound": "レコードが見つかりません", + "typeToConfirm": "確認のために{val}と入力してください:", + "noResultsTitle": "結果なし", + "noResultsMessage": "フィルターまたは検索クエリを変更してみてください", + "noSearchResults": "検索結果なし", + "noSearchResultsFor": "<0>'{{query}}'の検索結果はありません", + "noRecordsTitle": "レコードなし", + "noRecordsMessage": "表示するレコードがありません", + "unsavedChangesTitle": "このフォームから離れてもよろしいですか?", + "unsavedChangesDescription": "保存されていない変更があります。このフォームを終了すると、これらの変更は失われます。", + "includesTaxTooltip": "この列の価格は税込みです。", + "excludesTaxTooltip": "この列の価格は税抜きです。", + "noMoreData": "これ以上データはありません" + }, + "json": { + "header": "JSON", + "numberOfKeys_one": "{{count}}個のキー", + "numberOfKeys_other": "{{count}}個のキー", + "drawer": { + "header_one": "JSON <0>· {{count}}個のキー", + "header_other": "JSON <0>· {{count}}個のキー", + "description": "このオブジェクトのJSONデータを表示します。" + } + }, + "metadata": { + "header": "メタデータ", + "numberOfKeys_one": "{{count}}個のキー", + "numberOfKeys_other": "{{count}}個のキー", + "edit": { + "header": "メタデータを編集", + "description": "このオブジェクトのメタデータを編集します。", + "successToast": "メタデータが正常に更新されました。", + "actions": { + "insertRowAbove": "上に行を挿入", + "insertRowBelow": "下に行を挿入", + "deleteRow": "行を削除" + }, + "labels": { + "key": "キー", + "value": "値" + }, + "complexRow": { + "label": "一部の行が無効になっています", + "description": "このオブジェクトには、配列やオブジェクトなどのプリミティブでないメタデータが含まれており、ここでは編集できません。無効になっている行を編集するには、APIを直接使用してください。", + "tooltip": "この行はプリミティブでないデータを含むため無効になっています。" + } + } + }, + "validation": { + "mustBeInt": "値は数値でなければなりません。", + "mustBePositive": "値は正の数値でなければなりません。" + }, + "actions": { + "save": "保存", + "saveAsDraft": "下書きとして保存", + "copy": "コピー", + "copied": "コピーしました", + "duplicate": "複製", + "publish": "公開", + "create": "作成", + "delete": "削除", + "remove": "除去", + "revoke": "無効", + "cancel": "キャンセル", + "forceConfirm": "強制確認", + "continueEdit": "編集を続ける", + "enable": "有効化", + "disable": "無効化", + "undo": "元に戻す", + "complete": "完了", + "viewDetails": "詳細を表示", + "back": "戻る", + "close": "閉じる", + "showMore": "もっと見る", + "continue": "続ける", + "continueWithEmail": "メールで続ける", + "idCopiedToClipboard": "IDをクリップボードにコピーしました", + "addReason": "理由を追加", + "addNote": "メモを追加", + "reset": "リセット", + "confirm": "確認", + "edit": "編集", + "addItems": "項目を追加", + "download": "ダウンロード", + "clear": "クリア", + "clearAll": "すべてクリア", + "apply": "適用", + "add": "追加", + "select": "選択", + "browse": "参照", + "logout": "ログアウト", + "hide": "非表示", + "export": "エクスポート", + "import": "インポート", + "cannotUndo": "この操作は元に戻せません" + }, + "operators": { + "in": "含む" + }, + "app": { + "search": { + "label": "検索", + "title": "検索", + "description": "注文、商品、顧客など、ストア全体を検索します。", + "allAreas": "すべての領域", + "navigation": "ナビゲーション", + "openResult": "結果を開く", + "showMore": "もっと見る", + "placeholder": "何でも検索...", + "noResultsTitle": "結果が見つかりません", + "noResultsMessage": "検索に一致するものが見つかりませんでした。", + "emptySearchTitle": "検索するには入力してください", + "emptySearchMessage": "キーワードまたはフレーズを入力して探索してください。", + "loadMore": "さらに{{count}}件読み込む", + "groups": { + "all": "すべての領域", + "customer": "顧客", + "customerGroup": "顧客グループ", + "product": "商品", + "productVariant": "商品バリエーション", + "inventory": "在庫", + "reservation": "予約", + "category": "カテゴリ", + "collection": "コレクション", + "order": "注文", + "promotion": "プロモーション", + "campaign": "キャンペーン", + "priceList": "価格リスト", + "user": "ユーザー", + "region": "地域", + "taxRegion": "税地域", + "returnReason": "返品理由", + "salesChannel": "販売チャネル", + "productType": "商品タイプ", + "productTag": "商品タグ", + "location": "拠点", + "shippingProfile": "配送プロファイル", + "publishableApiKey": "公開可能なAPIキー", + "secretApiKey": "秘密のAPIキー", + "command": "コマンド", + "navigation": "ナビゲーション" + } + }, + "keyboardShortcuts": { + "pageShortcut": "飛び先", + "settingShortcut": "設定", + "commandShortcut": "コマンド", + "then": "次に", + "navigation": { + "goToOrders": "注文", + "goToProducts": "商品", + "goToCollections": "コレクション", + "goToCategories": "カテゴリ", + "goToCustomers": "顧客", + "goToCustomerGroups": "顧客グループ", + "goToInventory": "在庫", + "goToReservations": "予約", + "goToPriceLists": "価格リスト", + "goToPromotions": "プロモーション", + "goToCampaigns": "キャンペーン" + }, + "settings": { + "goToSettings": "設定", + "goToStore": "ストア", + "goToUsers": "ユーザー", + "goToRegions": "地域", + "goToTaxRegions": "税地域", + "goToSalesChannels": "販売チャネル", + "goToProductTypes": "商品タイプ", + "goToLocations": "拠点", + "goToPublishableApiKeys": "公開可能なAPIキー", + "goToSecretApiKeys": "秘密のAPIキー", + "goToWorkflows": "ワークフロー", + "goToProfile": "プロファイル", + "goToReturnReasons": "返品理由" + } + }, + "menus": { + "user": { + "documentation": "ドキュメント", + "changelog": "変更履歴", + "shortcuts": "ショートカット", + "profileSettings": "プロファイル設定", + "theme": { + "label": "テーマ", + "dark": "ダーク", + "light": "ライト", + "system": "システム" + } + }, + "store": { + "label": "ストア", + "storeSettings": "ストア設定" + }, + "actions": { + "logout": "ログアウト" + } + }, + "nav": { + "accessibility": { + "title": "ナビゲーション", + "description": "ダッシュボードのナビゲーションメニュー。" + }, + "common": { + "extensions": "拡張機能" + }, + "main": { + "store": "ストア", + "storeSettings": "ストア設定" + }, + "settings": { + "header": "設定", + "general": "一般", + "developer": "開発者", + "myAccount": "マイアカウント" + } + } + }, + "dataGrid": { + "columns": { + "view": "表示", + "resetToDefault": "デフォルトにリセット", + "disabled": "表示列の変更は無効になっています。" + }, + "shortcuts": { + "label": "ショートカット", + "commands": { + "undo": "元に戻す", + "redo": "やり直し", + "copy": "コピー", + "paste": "貼り付け", + "edit": "編集", + "delete": "削除", + "clear": "クリア", + "moveUp": "上に移動", + "moveDown": "下に移動", + "moveLeft": "左に移動", + "moveRight": "右に移動", + "moveTop": "一番上に移動", + "moveBottom": "一番下に移動", + "selectDown": "下を選択", + "selectUp": "上を選択", + "selectColumnDown": "下の列を選択", + "selectColumnUp": "上の列を選択", + "focusToolbar": "ツールバーにフォーカス", + "focusCancel": "キャンセルにフォーカス" + } + }, + "errors": { + "fixError": "エラーを修正", + "count_one": "{{count}}個のエラー", + "count_other": "{{count}}個のエラー" + } + }, + "filters": { + "date": { + "today": "今日", + "lastSevenDays": "過去7日間", + "lastThirtyDays": "過去30日間", + "lastNinetyDays": "過去90日間", + "lastTwelveMonths": "過去12ヶ月", + "custom": "カスタム", + "from": "開始日", + "to": "終了日" + }, + "compare": { + "lessThan": "未満", + "greaterThan": "以上", + "exact": "一致", + "range": "範囲", + "lessThanLabel": "{{value}}未満", + "greaterThanLabel": "{{value}}以上", + "andLabel": "かつ" + }, + "addFilter": "フィルター追加" + }, + "errorBoundary": { + "badRequestTitle": "400 - 不正なリクエスト", + "badRequestMessage": "構文が不正なため、サーバーがリクエストを理解できませんでした。", + "notFoundTitle": "404 - このアドレスにページが存在しません", + "notFoundMessage": "URLを確認して再試行するか、検索バーを使用して探しているものを見つけてください。", + "internalServerErrorTitle": "500 - 内部サーバーエラー", + "internalServerErrorMessage": "サーバーで予期せぬエラーが発生しました。後ほど再試行してください。", + "defaultTitle": "エラーが発生しました", + "defaultMessage": "このページの表示中に予期せぬエラーが発生しました。", + "noMatchMessage": "お探しのページは存在しません。", + "backToDashboard": "ダッシュボードに戻る" + }, + "addresses": { + "shippingAddress": { + "header": "配送先住所", + "editHeader": "配送先住所の編集", + "editLabel": "配送先住所", + "label": "配送先住所" + }, + "billingAddress": { + "header": "請求先住所", + "editHeader": "請求先住所の編集", + "editLabel": "請求先住所", + "label": "請求先住所", + "sameAsShipping": "配送先住所と同じ" + }, + "contactHeading": "連絡先", + "locationHeading": "所在地" + }, + "email": { + "editHeader": "Eメールを編集", + "editLabel": "Eメール", + "label": "Eメール" + }, + "transferOwnership": { + "header": "所有権の移転", + "label": "所有権の移転", + "details": { + "order": "注文詳細", + "draft": "下書き詳細" + }, + "currentOwner": { + "label": "現在の所有者", + "hint": "注文の現在の所有者です。" + }, + "newOwner": { + "label": "新しい所有者", + "hint": "注文を移転する新しい所有者です。" + }, + "validation": { + "mustBeDifferent": "新しい所有者は現在の所有者と異なる必要があります。", + "required": "新しい所有者は必須です。" + } + }, + "sales_channels": { + "availableIn": "<1>{{y}}の販売チャネルのうち<0>{{x}}で利用可能" + }, + "products": { + "domain": "商品", + "list": { + "noRecordsMessage": "最初の商品を作成して販売を開始しましょう。" + }, + "edit": { + "header": "商品を編集", + "description": "商品の詳細を編集します。", + "successToast": "商品「{{title}}」が正常に更新されました。" + }, + "create": { + "title": "商品を作成", + "description": "新しい商品を作成します。", + "header": "一般", + "tabs": { + "details": "詳細", + "organize": "整理", + "variants": "バリエーション", + "inventory": "在庫キット" + }, + "errors": { + "variants": "少なくとも1つのバリエーションを選択してください。", + "options": "少なくとも1つのオプションを作成してください。", + "uniqueSku": "SKUは一意である必要があります。" + }, + "inventory": { + "heading": "在庫キット", + "label": "バリエーションの在庫キットに在庫アイテムを追加します。", + "itemPlaceholder": "在庫アイテムを選択", + "quantityPlaceholder": "このキットに必要な数量は?" + }, + "variants": { + "header": "バリエーション", + "subHeadingTitle": "はい、これはバリエーションのある商品です", + "subHeadingDescription": "チェックを外すと、デフォルトのバリエーションを作成します", + "optionTitle": { + "placeholder": "サイズ" + }, + "optionValues": { + "placeholder": "小、中、大" + }, + "productVariants": { + "label": "商品バリエーション", + "hint": "このランキングはストアフロントでのバリエーションの順序に影響します。", + "alert": "バリエーションを作成するにはオプションを追加してください。", + "tip": "チェックをしていないバリエーションは作成されません。後でバリエーションを作成・編集できますが、このリストは商品オプションのバリエーションに適合します。" + }, + "productOptions": { + "label": "商品オプション", + "hint": "色、サイズなど、商品のオプションを定義します。" + } + }, + "successToast": "商品「{{title}}」が正常に作成されました。" + }, + "export": { + "header": "商品リストをエクスポート", + "description": "商品リストをCSVファイルにエクスポートします。", + "success": { + "title": "エクスポートを処理中です", + "description": "データのエクスポートには数分かかる場合があります。完了時にお知らせします。" + }, + "filters": { + "title": "フィルター", + "description": "テーブル概要でフィルターを適用してこのビューを調整します" + }, + "columns": { + "title": "列", + "description": "特定のニーズに合わせてエクスポートするデータをカスタマイズします" + } + }, + "import": { + "header": "商品リストをインポート", + "uploadLabel": "商品をインポート", + "uploadHint": "CSVファイルをドラッグ&ドロップするか、クリックしてアップロードします", + "description": "事前に定義されたフォーマットのCSVファイルを提供して商品をインポートします", + "template": { + "title": "リストの配置方法が不明ですか?", + "description": "正しいフォーマットに従っていることを確認するために、以下のテンプレートをダウンロードしてください。" + }, + "upload": { + "title": "CSVファイルをアップロード", + "description": "インポートにより商品を追加または更新できます。既存の商品を更新するには、既存のハンドルとIDを使用する必要があります。既存のバリエーションを更新するには、既存のIDを使用する必要があります。商品をインポートする前に確認を求められます。", + "preprocessing": "前処理中...", + "productsToCreate": "作成される商品", + "productsToUpdate": "更新される商品" + }, + "success": { + "title": "インポートを処理中です", + "description": "データのインポートには時間がかかる場合があります。完了時にお知らせします。" + } + }, + "deleteWarning": "商品「{{title}}」を削除しようとしています。この操作は元に戻せません。", + "variants": "バリエーション", + "attributes": "属性", + "editAttributes": "属性を編集", + "editOptions": "オプションを編集", + "editPrices": "価格を編集", + "media": { + "label": "メディア", + "editHint": "ストアフロントで商品を展示するためにメディアを追加します。", + "makeThumbnail": "サムネイルを作成", + "uploadImagesLabel": "画像をアップロード", + "uploadImagesHint": "ここに画像をドラッグ&ドロップするか、クリックしてアップロードします。", + "invalidFileType": "「{{name}}」はサポートされていないファイルタイプです。サポートされているファイルタイプは次のとおりです:{{types}}。", + "failedToUpload": "追加されたメディアのアップロードに失敗しました。もう一度お試しください。", + "deleteWarning_one": "{{count}}枚の画像を削除しようとしています。この操作は元に戻せません。", + "deleteWarning_other": "{{count}}枚の画像を削除しようとしています。この操作は元に戻せません。", + "deleteWarningWithThumbnail_one": "サムネイルを含む{{count}}枚の画像を削除しようとしています。この操作は元に戻せません。", + "deleteWarningWithThumbnail_other": "サムネイルを含む{{count}}枚の画像を削除しようとしています。この操作は元に戻せません。", + "thumbnailTooltip": "サムネイル", + "galleryLabel": "ギャラリー", + "downloadImageLabel": "現在の画像をダウンロード", + "deleteImageLabel": "現在の画像を削除", + "emptyState": { + "header": "まだメディアはありません", + "description": "ストアフロントで商品を展示するためにメディアを追加します。", + "action": "メディアを追加" + }, + "successToast": "メディアは正常に更新されました。" + }, + "discountableHint": "チェックを外すと、この商品に割引は適用されません。", + "noSalesChannels": "どの販売チャネルでも利用できません", + "variantCount_one": "{{count}}個のバリエーション", + "variantCount_other": "{{count}}個のバリエーション", + "deleteVariantWarning": "バリエーション「{{title}}」を削除しようとしています。この操作は元に戻せません。", + "productStatus": { + "draft": "下書き", + "published": "公開済み", + "proposed": "提案済み", + "rejected": "却下" + }, + "fields": { + "title": { + "label": "タイトル", + "hint": "商品に短く明確なタイトルを付けてください。<0/>検索エンジンには50〜60文字が推奨されています。" + }, + "subtitle": { + "label": "サブタイトル" + }, + "handle": { + "label": "ハンドル", + "tooltip": "ハンドルはストアフロントで商品を参照するために使用されます。指定しない場合、商品タイトルからハンドルが生成されます。" + }, + "description": { + "label": "説明", + "hint": "商品に短く明確な説明を付けてください。<0/>検索エンジンには120〜160文字が推奨されています。" + }, + "discountable": { + "label": "割引可能", + "hint": "チェックを外すと、この商品に割引は適用されません" + }, + "type": { + "label": "タイプ" + }, + "collection": { + "label": "コレクション" + }, + "categories": { + "label": "カテゴリー" + }, + "tags": { + "label": "タグ" + }, + "sales_channels": { + "label": "販売チャネル", + "hint": "未選択の場合、この商品はデフォルトの販売チャネルでのみ利用可能になります。" + }, + "countryOrigin": { + "label": "原産国" + }, + "material": { + "label": "素材" + }, + "width": { + "label": "幅" + }, + "length": { + "label": "長さ" + }, + "height": { + "label": "高さ" + }, + "weight": { + "label": "重さ" + }, + "options": { + "label": "商品オプション", + "hint": "オプションは商品の色、サイズなどを定義するために使用されます", + "add": "オプションを追加", + "optionTitle": "オプションタイトル", + "optionTitlePlaceholder": "色", + "variations": "バリエーション(カンマ区切り)", + "variantionsPlaceholder": "赤,青,緑" + }, + "variants": { + "label": "商品バリエーション", + "hint": "チェックを外したバリエーションは作成されません。このランキングはフロントエンドでのバリエーションのランク付けに影響します。" + }, + "mid_code": { + "label": "中分類コード" + }, + "hs_code": { + "label": "HSコード" + } + }, + "variant": { + "edit": { + "header": "バリエーションを編集", + "success": "商品バリエーションが正常に編集されました" + }, + "create": { + "header": "バリエーション詳細" + }, + "deleteWarning": "このバリエーションを削除してもよろしいですか?", + "pricesPagination": "1 - {{total}}件中{{current}}件の価格", + "tableItemAvailable": "{{availableCount}}個利用可能", + "tableItem_one": "{{locationCount}}の拠点で{{availableCount}}個利用可能", + "tableItem_other": "{{locationCount}}の拠点で{{availableCount}}個利用可能", + "inventory": { + "notManaged": "管理されていません", + "manageItems": "在庫アイテムを管理", + "notManagedDesc": "このバリエーションの在庫は管理されていません。「在庫を管理」をオンにしてバリエーションの在庫を追跡します。", + "manageKit": "在庫キットを管理", + "navigateToItem": "在庫アイテムに移動", + "actions": { + "inventoryItems": "在庫アイテムに移動", + "inventoryKit": "在庫アイテムを表示" + }, + "inventoryKit": "在庫キット", + "inventoryKitHint": "このバリエーションは複数の在庫アイテムで構成されていますか?", + "validation": { + "itemId": "在庫アイテムを選択してください。", + "quantity": "数量は必須です。正の数値を入力してください。" + }, + "header": "在庫と在庫管理", + "editItemDetails": "アイテム詳細を編集", + "manageInventoryLabel": "在庫を管理", + "manageInventoryHint": "有効にすると、注文や返品が作成されたときに在庫数量を変更します。", + "allowBackordersLabel": "取り寄せを許可", + "allowBackordersHint": "有効にすると、利用可能な数量がない場合でも顧客がバリエーションを購入できます。", + "toast": { + "levelsBatch": "在庫レベルが更新されました。", + "update": "在庫アイテムが正常に更新されました。", + "updateLevel": "在庫レベルが正常に更新されました。", + "itemsManageSuccess": "在庫アイテムが正常に更新されました。" + } + } + }, + "options": { + "header": "オプション", + "edit": { + "header": "オプションを編集", + "successToast": "オプション「{{title}}」が正常に更新されました。" + }, + "create": { + "header": "オプションを作成", + "successToast": "オプション「{{title}}」が正常に作成されました。" + }, + "deleteWarning": "商品オプション「{{title}}」を削除しようとしています。この操作は元に戻せません。" + }, + "organization": { + "header": "整理", + "edit": { + "header": "整理を編集", + "toasts": { + "success": "「{{title}}」の整理が正常に更新されました。" + } + } + }, + "toasts": { + "delete": { + "success": { + "header": "商品が削除されました", + "description": "「{{title}}」が正常に削除されました。" + }, + "error": { + "header": "商品の削除に失敗しました" + } + } + } + }, + "collections": { + "domain": "コレクション", + "subtitle": "商品をコレクションに整理します。", + "createCollection": "コレクションを作成", + "createCollectionHint": "商品を整理するための新しいコレクションを作成します。", + "createSuccess": "コレクションが正常に作成されました。", + "editCollection": "コレクションを編集", + "handleTooltip": "ハンドルはストアフロントでコレクションを参照するために使用されます。指定しない場合、コレクションのタイトルからハンドルが生成されます。", + "deleteWarning": "コレクション「{{title}}」を削除しようとしています。この操作は元に戻せません。", + "removeSingleProductWarning": "商品「{{title}}」をコレクションから削除しようとしています。この操作は元に戻せません。", + "removeProductsWarning_one": "{{count}}個の商品をコレクションから削除しようとしています。この操作は元に戻せません。", + "removeProductsWarning_other": "{{count}}個の商品をコレクションから削除しようとしています。この操作は元に戻せません。", + "products": { + "list": { + "noRecordsMessage": "コレクションに商品がありません。" + }, + "add": { + "successToast_one": "商品がコレクションに正常に追加されました。", + "successToast_other": "商品がコレクションに正常に追加されました。" + }, + "remove": { + "successToast_one": "商品がコレクションから正常に削除されました。", + "successToast_other": "商品がコレクションから正常に削除されました。" + } + } + }, + "categories": { + "domain": "カテゴリー", + "subtitle": "商品をカテゴリーに整理し、それらのカテゴリーのランキングと階層を管理します。", + "create": { + "header": "カテゴリーを作成", + "hint": "商品を整理するための新しいカテゴリーを作成します。", + "tabs": { + "details": "詳細", + "organize": "ランキングを整理" + }, + "successToast": "カテゴリー「{{name}}」が正常に作成されました。" + }, + "edit": { + "header": "カテゴリーを編集", + "description": "カテゴリーを編集して詳細を更新します。", + "successToast": "カテゴリーが正常に更新されました。" + }, + "delete": { + "confirmation": "カテゴリー「{{name}}」を削除しようとしています。この操作は元に戻せません。", + "successToast": "カテゴリー「{{name}}」が正常に削除されました。" + }, + "products": { + "add": { + "disabledTooltip": "この商品は既にこのカテゴリーに含まれています。", + "successToast_one": "{{count}}個の商品をカテゴリーに追加しました。", + "successToast_other": "{{count}}個の商品をカテゴリーに追加しました。" + }, + "remove": { + "confirmation_one": "{{count}}個の商品をカテゴリーから削除しようとしています。この操作は元に戻せません。", + "confirmation_other": "{{count}}個の商品をカテゴリーから削除しようとしています。この操作は元に戻せません。", + "successToast_one": "{{count}}個の商品をカテゴリーから削除しました。", + "successToast_other": "{{count}}個の商品をカテゴリーから削除しました。" + }, + "list": { + "noRecordsMessage": "このカテゴリーに商品はありません。" + } + }, + "organize": { + "header": "整理", + "action": "ランキングを編集" + }, + "fields": { + "visibility": { + "label": "可視性", + "internal": "内部", + "public": "公開" + }, + "status": { + "label": "ステータス", + "active": "アクティブ", + "inactive": "非アクティブ" + }, + "path": { + "label": "パス", + "tooltip": "カテゴリーの完全なパスを表示します。" + }, + "children": { + "label": "子カテゴリー" + }, + "new": { + "label": "新規" + } + } + }, + "inventory": { + "domain": "在庫", + "subtitle": "在庫アイテムを管理する", + "reserved": "予約済み", + "available": "利用可能", + "locationLevels": "拠点", + "associatedVariants": "関連バリエーション", + "manageLocations": "拠点を管理", + "deleteWarning": "在庫アイテムを削除しようとしています。この操作は元に戻せません。", + "editItemDetails": "アイテム詳細を編集", + "create": { + "title": "在庫アイテムを作成", + "details": "詳細", + "availability": "在庫状況", + "locations": "拠点", + "attributes": "属性", + "requiresShipping": "配送が必要", + "requiresShippingHint": "この在庫アイテムは配送が必要ですか?", + "successToast": "在庫アイテムが正常に作成されました。" + }, + "reservation": { + "header": "{{itemName}}の予約", + "editItemDetails": "予約を編集", + "lineItemId": "ラインアイテムID", + "orderID": "注文ID", + "description": "説明", + "location": "拠点", + "inStockAtLocation": "この拠点の在庫", + "availableAtLocation": "この拠点で利用可能", + "reservedAtLocation": "この拠点で予約済み", + "reservedAmount": "予約数量", + "create": "予約を作成", + "itemToReserve": "予約するアイテム", + "quantityPlaceholder": "予約する数量を入力してください", + "descriptionPlaceholder": "どのタイプの予約ですか?", + "successToast": "予約が正常に作成されました。", + "updateSuccessToast": "予約が正常に更新されました。", + "deleteSuccessToast": "予約が正常に削除されました。", + "errors": { + "noAvaliableQuantity": "在庫ロケーションに利用可能な数量がありません。", + "quantityOutOfRange": "最小数量は1、最大数量は{{max}}です" + } + }, + "adjustInventory": { + "errors": { + "stockedQuantity": "在庫数量を予約数量{{quantity}}より少ない数に更新することはできません。" + } + }, + "toast": { + "updateLocations": "拠点が正常に更新されました。", + "updateLevel": "在庫レベルが正常に更新されました。", + "updateItem": "在庫アイテムが正常に更新されました。" + } + }, + "giftCards": { + "domain": "ギフトカード", + "editGiftCard": "ギフトカードを編集", + "createGiftCard": "ギフトカードを作成", + "createGiftCardHint": "ストアで支払い方法として使用できるギフトカードを手動で作成します。", + "selectRegionFirst": "まず地域を選択してください", + "deleteGiftCardWarning": "ギフトカード「{{code}}」を削除しようとしています。この操作は元に戻せません。", + "balanceHigherThanValue": "残高は元の金額を超えることはできません。", + "balanceLowerThanZero": "残高をマイナスにすることはできません。", + "expiryDateHint": "ギフトカードの有効期限に関する法律は国によって異なります。有効期限を設定する前に、現地の法令を確認してください。", + "regionHint": "ギフトカードの地域を変更すると、通貨も変更され、金銭的価値に影響を与える可能性があります。", + "enabledHint": "ギフトカードを有効にするか無効にするかを指定します。", + "balance": "残高", + "currentBalance": "現在の残高", + "initialBalance": "初期残高", + "personalMessage": "個人メッセージ", + "recipient": "受取人" + }, + "customers": { + "domain": "顧客", + "list": { + "noRecordsMessage": "顧客情報がここに表示されます。" + }, + "create": { + "header": "顧客を作成", + "hint": "新しい顧客を作成し、その詳細を管理します。", + "successToast": "顧客「{{email}}」が正常に作成されました。" + }, + "groups": { + "label": "顧客グループ", + "remove": "顧客を「{{name}}」グループから削除してもよろしいですか?", + "removeMany": "顧客を以下の顧客グループから削除してもよろしいですか:{{groups}}?", + "alreadyAddedTooltip": "顧客は既にこの顧客グループに所属しています。", + "list": { + "noRecordsMessage": "この顧客はどのグループにも所属していません。" + }, + "add": { + "success": "顧客を追加しました:{{groups}}", + "list": { + "noRecordsMessage": "まず顧客グループを作成してください。" + } + }, + "removed": { + "success": "顧客を削除しました:{{groups}}", + "list": { + "noRecordsMessage": "まず顧客グループを作成してください。" + } + } + }, + "edit": { + "header": "顧客を編集", + "emailDisabledTooltip": "登録済み顧客のメールアドレスは変更できません。", + "successToast": "顧客「{{email}}」が正常に更新されました。" + }, + "delete": { + "title": "顧客を削除", + "description": "顧客「{{email}}」を削除しようとしています。この操作は元に戻せません。", + "successToast": "顧客「{{email}}」が正常に削除されました。" + }, + "fields": { + "guest": "ゲスト", + "registered": "登録済み", + "groups": "グループ" + }, + "registered": "登録済み", + "guest": "ゲスト", + "hasAccount": "アカウントあり" + }, + "customerGroups": { + "domain": "顧客グループ", + "subtitle": "顧客をグループに整理します。グループごとに異なるプロモーションや価格を設定できます。", + "create": { + "header": "顧客グループを作成", + "hint": "顧客をセグメント化するための新しい顧客グループを作成します。", + "successToast": "顧客グループ「{{name}}」が正常に作成されました。" + }, + "edit": { + "header": "顧客グループを編集", + "successToast": "顧客グループ「{{name}}」が正常に更新されました。" + }, + "delete": { + "title": "顧客グループを削除", + "description": "顧客グループ「{{name}}」を削除しようとしています。この操作は元に戻せません。", + "successToast": "顧客グループ「{{name}}」が正常に削除されました。" + }, + "customers": { + "alreadyAddedTooltip": "この顧客は既にグループに追加されています。", + "add": { + "successToast_one": "顧客がグループに正常に追加されました。", + "successToast_other": "顧客がグループに正常に追加されました。", + "list": { + "noRecordsMessage": "まず顧客を作成してください。" + } + }, + "remove": { + "title_one": "顧客を削除", + "title_other": "顧客を削除", + "description_one": "{{count}}人の顧客を顧客グループから削除しようとしています。この操作は元に戻せません。", + "description_other": "{{count}}人の顧客を顧客グループから削除しようとしています。この操作は元に戻せません。" + }, + "list": { + "noRecordsMessage": "このグループには顧客がいません。" + } + } + }, + "orders": { + "domain": "注文", + "claim": "クレーム", + "exchange": "交換", + "return": "返品", + "cancelWarning": "注文{{id}}をキャンセルしようとしています。この操作は元に戻せません。", + "onDateFromSalesChannel": "{{salesChannel}}から{{date}}", + "list": { + "noRecordsMessage": "あなたの注文がここに表示されます。" + }, + "summary": { + "requestReturn": "返品をリクエスト", + "allocateItems": "アイテムを割り当てる", + "editOrder": "注文を編集", + "editOrderContinue": "注文編集を続ける", + "inventoryKit": "{{count}}個の在庫アイテムで構成", + "itemTotal": "アイテム合計", + "shippingTotal": "配送合計", + "discountTotal": "割引合計", + "taxTotalIncl": "税金合計(税込)", + "itemSubtotal": "アイテム小計", + "shippingSubtotal": "配送小計", + "discountSubtotal": "割引小計", + "taxTotal": "税金合計" + }, + "transfer": { + "title": "所有権の移転", + "requestSuccess": "注文移転リクエストが{{email}}に送信されました。", + "currentOwner": "現在の所有者", + "newOwner": "新しい所有者", + "currentOwnerDescription": "この注文に現在関連付けられている顧客。", + "newOwnerDescription": "この注文を移転する顧客。" + }, + "payment": { + "title": "支払い", + "isReadyToBeCaptured": "支払い<0/>が確定しました。", + "totalPaidByCustomer": "顧客による支払い合計", + "capture": "支払いが確定", + "capture_short": "確定済み", + "refund": "返金", + "markAsPaid": "支払い済みとしてマーク", + "statusLabel": "支払いステータス", + "statusTitle": "支払いステータス", + "status": { + "notPaid": "未払い", + "authorized": "承認済み", + "partiallyAuthorized": "一部承認", + "awaiting": "待機中", + "captured": "確定済み", + "partiallyRefunded": "一部返金", + "partiallyCaptured": "一部支払い済み", + "refunded": "返金済み", + "canceled": "キャンセル済み", + "requiresAction": "対応が必要" + }, + "capturePayment": "{{amount}}の支払いが確定されます。", + "capturePaymentSuccess": "{{amount}}の支払いが正常に確定されました", + "markAsPaidPayment": "{{amount}}の支払いが支払い済みとしてマークされます。", + "markAsPaidPaymentSuccess": "{{amount}}の支払いが正常に支払い済みとしてマークされました", + "createRefund": "返金を作成", + "refundPaymentSuccess": "{{amount}}の返金が成功しました", + "createRefundWrongQuantity": "数量は1から{{number}}の間の数字である必要があります", + "refundAmount": "{{ amount }}を返金", + "paymentLink": "{{ amount }}の支払いリンクをコピー", + "selectPaymentToRefund": "返金する支払いを選択" + }, + "edits": { + "title": "注文を編集", + "confirm": "編集を確認", + "confirmText": "注文編集を確認しようとしています。この操作は元に戻せません。", + "cancel": "編集をキャンセル", + "currentItems": "現在のアイテム", + "currentItemsDescription": "アイテムの数量を調整または削除。", + "addItemsDescription": "注文に新しいアイテムを追加できます。", + "addItems": "アイテムを追加", + "amountPaid": "支払い済み金額", + "newTotal": "新しい合計", + "differenceDue": "差額", + "create": "注文を編集", + "currentTotal": "現在の合計", + "noteHint": "編集の内部メモを追加", + "cancelSuccessToast": "注文編集がキャンセルされました", + "createSuccessToast": "注文編集リクエストが作成されました", + "activeChangeError": "注文にはすでにアクティブな注文変更(返品、クレーム、交換など)があります。注文を編集する前に変更を完了またはキャンセルしてください。", + "panel": { + "title": "注文編集がリクエストされました", + "titlePending": "注文編集が保留中です" + }, + "toast": { + "canceledSuccessfully": "注文編集がキャンセルされました", + "confirmedSuccessfully": "注文編集が確認されました" + }, + "validation": { + "quantityLowerThanFulfillment": "数量を出荷済みの数量以下に設定することはできません" + } + }, + "edit": { + "email": { + "title": "Eメールを編集", + "requestSuccess": "注文のEメールが「{{email}}」に更新されました。" + }, + "shippingAddress": { + "title": "配送先住所を編集", + "requestSuccess": "注文の配送先住所が更新されました。" + }, + "billingAddress": { + "title": "請求先住所を編集", + "requestSuccess": "注文の請求先住所が更新されました。" + } + }, + "returns": { + "create": "返品を作成", + "confirm": "返品を確認", + "confirmText": "返品を確認しようとしています。この操作は元に戻せません。", + "inbound": "入庫", + "outbound": "出庫", + "sendNotification": "通知を送信", + "sendNotificationHint": "顧客に返品について通知します。", + "returnTotal": "返品合計", + "inboundTotal": "入庫合計", + "refundAmount": "返金額", + "outstandingAmount": "未払い金額", + "reason": "理由", + "reasonHint": "顧客がアイテムを返品したい理由を選択してください。", + "note": "メモ", + "noInventoryLevel": "在庫レベルなし", + "noInventoryLevelDesc": "選択した拠点には選択したアイテムの在庫レベルがありません。返品をリクエストできますが、選択した拠点の在庫レベルが作成されるまで受け取ることはできません。", + "noteHint": "何か指定したい場合は自由に入力できます。", + "location": "拠点", + "locationHint": "アイテムを返品する拠点を選択してください。", + "inboundShipping": "返品配送", + "inboundShippingHint": "使用する方法を選択してください。", + "returnableQuantityLabel": "返品可能数量", + "refundableAmountLabel": "返金可能金額", + "returnRequestedInfo": "{{requestedItemsCount}}個のアイテムの返品がリクエストされました", + "returnReceivedInfo": "{{requestedItemsCount}}個のアイテムの返品を受け取りました", + "itemReceived": "アイテムを受け取りました", + "returnRequested": "返品がリクエストされました", + "damagedItemReceived": "破損したアイテムを受け取りました", + "damagedItemsReturned": "{{quantity}}個の破損したアイテムが返品されました", + "activeChangeError": "この注文には進行中のアクティブな注文変更があります。まず変更を完了または破棄してください。", + "cancel": { + "title": "返品をキャンセル", + "description": "返品リクエストをキャンセルしてもよろしいですか?" + }, + "placeholders": { + "noReturnShippingOptions": { + "title": "返品配送オプションが見つかりません", + "hint": "この拠点の返品配送オプションが作成されていません。拠点と配送で作成できます。" + }, + "outboundShippingOptions": { + "title": "出庫配送オプションが見つかりません", + "hint": "この拠点の出庫配送オプションが作成されていません。拠点と配送で作成できます。" + } + }, + "receive": { + "action": "アイテムを受け取る", + "receiveItems": "{{ returnType }} {{ id }}", + "restockAll": "すべてのアイテムを再入庫", + "itemsLabel": "受け取ったアイテム", + "title": "#{{returnId}}のアイテムを受け取る", + "sendNotificationHint": "顧客に返品の受け取りについて通知します。", + "inventoryWarning": "上記の入力に基づいて自動的に在庫レベルを調整することに注意してください。", + "writeOffInputLabel": "アイテムのうち何個が損傷していますか?", + "toast": { + "success": "返品が正常に受け取られました。", + "errorLargeValue": "数量がリクエストされたアイテム数量を超えています。", + "errorNegativeValue": "数量は負の値にはできません。", + "errorLargeDamagedValue": "損傷したアイテムの数量 + 損傷していない受け取ったアイテムの数量が返品の総アイテム数量を超えています。損傷していないアイテムの数量を減らしてください。" + } + }, + "toast": { + "canceledSuccessfully": "返品が正常にキャンセルされました", + "confirmedSuccessfully": "返品が正常に確認されました" + }, + "panel": { + "title": "返品が開始されました", + "description": "完了する必要のある未処理の返品リクエストがあります" + } + }, + "claims": { + "create": "クレームを作成", + "confirm": "クレームを確認", + "confirmText": "クレームを確認しようとしています。この操作は元に戻せません。", + "manage": "クレームを管理", + "outbound": "出庫", + "outboundItemAdded": "クレームを通じて{{itemsCount}}個追加されました", + "outboundTotal": "出庫合計", + "outboundShipping": "出庫配送", + "outboundShippingHint": "使用する方法を選択してください。", + "refundAmount": "見積もり差額", + "activeChangeError": "この注文にはアクティブな注文変更があります。前の変更を完了または破棄してください。", + "actions": { + "cancelClaim": { + "successToast": "クレームが正常にキャンセルされました。" + } + }, + "cancel": { + "title": "クレームをキャンセル", + "description": "クレームをキャンセルしてもよろしいですか?" + }, + "tooltips": { + "onlyReturnShippingOptions": "このリストには返品配送オプションのみが含まれます。" + }, + "toast": { + "canceledSuccessfully": "クレームが正常にキャンセルされました", + "confirmedSuccessfully": "クレームが正常に確認されました" + }, + "panel": { + "title": "クレームが開始されました", + "description": "完了する必要のある未処理のクレームリクエストがあります" + } + }, + "exchanges": { + "create": "交換を作成", + "manage": "交換を管理", + "confirm": "交換を確認", + "confirmText": "交換を確認しようとしています。この操作は元に戻せません。", + "outbound": "出庫", + "outboundItemAdded": "交換を通じて{{itemsCount}}個追加されました", + "outboundTotal": "出庫合計", + "outboundShipping": "出庫配送", + "outboundShippingHint": "使用する方法を選択してください。", + "refundAmount": "見積もり差額", + "activeChangeError": "この注文にはアクティブな注文変更があります。前の変更を完了または破棄してください。", + "actions": { + "cancelExchange": { + "successToast": "交換が正常にキャンセルされました。" + } + }, + "cancel": { + "title": "交換をキャンセル", + "description": "交換をキャンセルしてもよろしいですか?" + }, + "tooltips": { + "onlyReturnShippingOptions": "このリストには返品配送オプションのみが含まれます。" + }, + "toast": { + "canceledSuccessfully": "交換が正常にキャンセルされました", + "confirmedSuccessfully": "交換が正常に確認されました" + }, + "panel": { + "title": "交換が開始されました", + "description": "完了する必要のある未処理の交換リクエストがあります" + } + }, + "reservations": { + "allocatedLabel": "割り当て済み", + "notAllocatedLabel": "未割り当て" + }, + "allocateItems": { + "action": "アイテムを割り当てる", + "title": "注文アイテムを割り当てる", + "locationDescription": "割り当てる拠点を選択してください。", + "itemsToAllocate": "割り当てるアイテム", + "itemsToAllocateDesc": "割り当てたいアイテムの数を選択してください", + "search": "アイテムを検索", + "consistsOf": "{{num}}個の在庫アイテムで構成", + "requires": "バリエーションごとに{{num}}個必要", + "toast": { + "created": "アイテムが正常に割り当てられました" + }, + "error": { + "quantityNotAllocated": "割り当てられていないアイテムがあります。" + } + }, + "shipment": { + "title": "出荷済みとしてマーク", + "trackingNumber": "追跡番号", + "addTracking": "追跡番号を追加", + "sendNotification": "通知を送信", + "sendNotificationHint": "この出荷について顧客に通知します。", + "toastCreated": "出荷が正常に作成されました。" + }, + "fulfillment": { + "cancelWarning": "出荷をキャンセルしようとしています。この操作は元に戻せません。", + "markAsDeliveredWarning": "出荷を配達済みとしてマークしようとしています。この操作は元に戻せません。", + "unfulfilledItems": "未出荷アイテム", + "statusLabel": "出荷ステータス", + "statusTitle": "出荷ステータス", + "fulfillItems": "アイテムを出荷", + "awaitingFulfillmentBadge": "出荷待ち", + "requiresShipping": "配送が必要", + "number": "出荷 #{{number}}", + "itemsToFulfill": "出荷するアイテム", + "create": "出荷を作成", + "available": "利用可能", + "inStock": "在庫あり", + "markAsShipped": "配送済みとしてマーク", + "markAsDelivered": "配達済みとしてマーク", + "itemsToFulfillDesc": "出荷するアイテムと数量を選択してください", + "locationDescription": "アイテムを出荷する拠点を選択してください。", + "sendNotificationHint": "作成された出荷について顧客に通知します。", + "methodDescription": "顧客が選択した配送方法とは異なる方法を選択してください", + "error": { + "wrongQuantity": "出荷可能なアイテムは1つのみです", + "wrongQuantity_other": "数量は1から{{number}}の間の数字である必要があります", + "noItems": "出荷するアイテムがありません。" + }, + "status": { + "notFulfilled": "未出荷", + "partiallyFulfilled": "一部出荷", + "fulfilled": "出荷済み", + "partiallyShipped": "一部配送", + "shipped": "配送済み", + "delivered": "配達済み", + "partiallyDelivered": "一部配達", + "partiallyReturned": "一部返品", + "returned": "返品済み", + "canceled": "キャンセル済み", + "requiresAction": "対応が必要" + }, + "toast": { + "created": "出荷が正常に作成されました", + "canceled": "出荷が正常にキャンセルされました", + "fulfillmentShipped": "すでに配送された出荷をキャンセルすることはできません", + "fulfillmentDelivered": "出荷が正常に配達済みとしてマークされました" + }, + "trackingLabel": "追跡", + "shippingFromLabel": "発送元", + "itemsLabel": "アイテム" + }, + "refund": { + "title": "返金を作成", + "sendNotificationHint": "作成された返金について顧客に通知します。", + "systemPayment": "システム支払い", + "systemPaymentDesc": "1つ以上の支払いがシステム支払いです。このような支払いの取り込みと返金はMedusaでは処理されないことに注意してください。", + "error": { + "amountToLarge": "元の注文金額を超えて返金することはできません。", + "amountNegative": "返金額は正の数でなければなりません。", + "reasonRequired": "返金理由を選択してください。" + } + }, + "customer": { + "contactLabel": "連絡先", + "editEmail": "Eメールを編集", + "transferOwnership": "所有権を移転", + "editBillingAddress": "請求先住所を編集", + "editShippingAddress": "配送先住所を編集" + }, + "activity": { + "header": "アクティビティ", + "showMoreActivities_one": "さらに{{count}}件のアクティビティを表示", + "showMoreActivities_other": "さらに{{count}}件のアクティビティを表示", + "comment": { + "label": "コメント", + "placeholder": "コメントを残す", + "addButtonText": "コメントを追加", + "deleteButtonText": "コメントを削除" + }, + "from": "から", + "to": "まで", + "events": { + "common": { + "toReturn": "返品する", + "toSend": "送信する" + }, + "placed": { + "title": "注文が完了しました", + "fromSalesChannel": "{{salesChannel}}から" + }, + "canceled": { + "title": "注文がキャンセルされました" + }, + "payment": { + "awaiting": "支払い待ち", + "captured": "支払いが確定されました", + "canceled": "支払いがキャンセルされました", + "refunded": "支払いが返金されました" + }, + "fulfillment": { + "created": "アイテムが出荷されました", + "canceled": "出荷がキャンセルされました", + "shipped": "アイテムが配送されました", + "delivered": "アイテムが配達されました", + "items_one": "{{count}}個のアイテム", + "items_other": "{{count}}個のアイテム" + }, + "return": { + "created": "返品#{{returnId}}がリクエストされました", + "canceled": "返品#{{returnId}}がキャンセルされました", + "received": "返品#{{returnId}}を受け取りました", + "items_one": "{{count}}個のアイテムが返品されました", + "items_other": "{{count}}個のアイテムが返品されました" + }, + "note": { + "comment": "コメント", + "byLine": "{{author}}による" + }, + "claim": { + "created": "クレーム#{{claimId}}がリクエストされました", + "canceled": "クレーム#{{claimId}}がキャンセルされました", + "itemsInbound": "{{count}}個のアイテムを返品", + "itemsOutbound": "{{count}}個のアイテムを送信" + }, + "exchange": { + "created": "交換#{{exchangeId}}がリクエストされました", + "canceled": "交換#{{exchangeId}}がキャンセルされました", + "itemsInbound": "{{count}}個のアイテムを返品", + "itemsOutbound": "{{count}}個のアイテムを送信" + }, + "edit": { + "requested": "注文編集#{{editId}}がリクエストされました", + "confirmed": "注文編集#{{editId}}が確認されました" + }, + "transfer": { + "requested": "注文移転#{{transferId}}がリクエストされました", + "confirmed": "注文移転#{{transferId}}が確認されました", + "declined": "注文移転#{{transferId}}が拒否されました" + }, + "update_order": { + "shipping_address": "配送先住所が更新されました", + "billing_address": "請求先住所が更新されました", + "email": "Eメールが更新されました" + } + } + }, + "fields": { + "displayId": "表示ID", + "refundableAmount": "返金可能金額", + "returnableQuantity": "返品可能数量" + } + }, + "draftOrders": { + "domain": "下書き注文", + "deleteWarning": "下書き注文{{id}}を削除しようとしています。この操作は取り消せません。", + "paymentLinkLabel": "支払いリンク", + "cartIdLabel": "カートID", + "markAsPaid": { + "label": "支払い済みとしてマーク", + "warningTitle": "支払い済みとしてマーク", + "warningDescription": "下書き注文を支払い済みとしてマークしようとしています。この操作は取り消せず、後で支払いを受け取ることはできなくなります。" + }, + "status": { + "open": "未完了", + "completed": "完了済み" + }, + "create": { + "createDraftOrder": "下書き注文を作成", + "createDraftOrderHint": "注文が確定する前に詳細を管理するための新しい下書き注文を作成します。", + "chooseRegionHint": "地域を選択", + "existingItemsLabel": "既存の商品", + "existingItemsHint": "下書き注文に既存の商品を追加します。", + "customItemsLabel": "カスタム商品", + "customItemsHint": "下書き注文にカスタム商品を追加します。", + "addExistingItemsAction": "既存の商品を追加", + "addCustomItemAction": "カスタム商品を追加", + "noCustomItemsAddedLabel": "まだカスタム商品が追加されていません", + "noExistingItemsAddedLabel": "まだ既存の商品が追加されていません", + "chooseRegionTooltip": "まず地域を選択してください", + "useExistingCustomerLabel": "既存の顧客を使用", + "addShippingMethodsAction": "配送方法を追加", + "unitPriceOverrideLabel": "単価の上書き", + "shippingOptionLabel": "配送オプション", + "shippingOptionHint": "下書き注文の配送オプションを選択してください。", + "shippingPriceOverrideLabel": "配送料金の上書き", + "shippingPriceOverrideHint": "下書き注文の配送料金を上書きします。", + "sendNotificationLabel": "通知を送信", + "sendNotificationHint": "下書き注文が作成されたときに顧客に通知を送信します。" + }, + "validation": { + "requiredEmailOrCustomer": "Eメールまたは顧客情報が必要です。", + "requiredItems": "少なくとも1つの商品が必要です。", + "invalidEmail": "有効なEメールを入力してください。" + } + }, + "stockLocations": { + "domain": "在庫拠点と配送", + "list": { + "description": "店舗の在庫拠点と配送オプションを管理します。" + }, + "create": { + "header": "在庫拠点を作成", + "hint": "在庫拠点は、商品が保管され出荷される物理的な拠点です。", + "successToast": "拠点「{{name}}」が正常に作成されました。" + }, + "edit": { + "header": "在庫拠点を編集", + "viewInventory": "在庫を表示", + "successToast": "拠点「{{name}}」が正常に更新されました。" + }, + "delete": { + "confirmation": "在庫拠点「{{name}}」を削除しようとしています。この操作は取り消せません。" + }, + "fulfillmentProviders": { + "header": "フルフィルメントプロバイダー", + "shippingOptionsTooltip": "このドロップダウンには、この拠点で有効化されたプロバイダーのみが含まれます。ドロップダウンが無効の場合は、拠点にプロバイダーを追加してください。", + "label": "接続済みフルフィルメントプロバイダー", + "connectedTo": "{{total}}個中{{count}}個のフルフィルメントプロバイダーに接続済み", + "noProviders": "この在庫拠点はフルフィルメントプロバイダーに接続されていません。", + "action": "プロバイダーを接続", + "successToast": "在庫拠点のフルフィルメントプロバイダーが正常に更新されました。" + }, + "fulfillmentSets": { + "pickup": { + "header": "ピックアップ" + }, + "shipping": { + "header": "配送" + }, + "disable": { + "confirmation": "「{{name}}」を無効にしてもよろしいですか?関連するすべてのサービスゾーンと配送オプションが削除され、この操作は取り消せません。", + "pickup": "プックアップが正常に無効化されました。", + "shipping": "配送が正常に無効化されました。" + }, + "enable": { + "pickup": "ピックアップが正常に有効化されました。", + "shipping": "配送が正常に有効化されました。" + } + }, + "sidebar": { + "header": "配送設定", + "shippingProfiles": { + "label": "配送プロファイル", + "description": "配送要件ごとに商品をグループ化" + } + }, + "salesChannels": { + "header": "販売チャネル", + "label": "接続済み販売チャネル", + "connectedTo": "{{total}}個中{{count}}個の販売チャネルに接続済み", + "noChannels": "この拠点は販売チャネルに接続されていません。", + "action": "販売チャネルを接続", + "successToast": "販売チャネルが正常に更新されました。" + }, + "shippingOptions": { + "create": { + "shipping": { + "header": "「{{zone}}」の配送オプションを作成", + "hint": "この拠点からの商品の配送方法を定義する新しい配送オプションを作成します。", + "label": "配送オプション", + "successToast": "配送オプション「{{name}}」が正常に作成されました。" + }, + "returns": { + "header": "「{{zone}}」の返品オプションを作成", + "hint": "この拠点への商品の返品方法を定義する新しい返品オプションを作成します。", + "label": "返品オプション", + "successToast": "返品オプション「{{name}}」が正常に作成されました。" + }, + "tabs": { + "details": "詳細", + "prices": "価格" + }, + "action": "オプションを作成" + }, + "delete": { + "confirmation": "配送オプション「{{name}}」を削除しようとしています。この操作は取り消せません。", + "successToast": "配送オプション「{{name}}」が正常に削除されました。" + }, + "edit": { + "header": "配送オプションを編集", + "action": "オプションを編集", + "successToast": "配送オプション「{{name}}」が正常に更新されました。" + }, + "pricing": { + "action": "価格を編集" + }, + "fields": { + "count": { + "shipping_one": "{{count}}個の配送オプション", + "shipping_other": "{{count}}個の配送オプション", + "returns_one": "{{count}}個の返品オプション", + "returns_other": "{{count}}個の返品オプション" + }, + "priceType": { + "label": "価格タイプ", + "options": { + "fixed": { + "label": "固定", + "hint": "配送オプションの価格は固定で、注文内容に基づいて変更されません。" + }, + "calculated": { + "label": "計算式", + "hint": "配送オプションの価格は、チェックアウト時にフルフィルメントプロバイダーによって計算されます。" + } + } + }, + "enableInStore": { + "label": "ストアで有効化", + "hint": "顧客がチェックアウト時にこのオプションを使用できるかどうか。" + }, + "provider": "フルフィルメントプロバイダー", + "profile": "配送プロファイル" + } + }, + "serviceZones": { + "create": { + "headerPickup": "{{location}}からピックアップのサービスゾーンを作成", + "headerShipping": "{{location}}から配送のサービスゾーンを作成", + "action": "サービスゾーンを作成", + "successToast": "サービスゾーン「{{name}}」が正常に作成されました。" + }, + "edit": { + "header": "サービスゾーンを編集", + "successToast": "サービスゾーン「{{name}}」が正常に更新されました。" + }, + "delete": { + "confirmation": "サービスゾーン「{{name}}」を削除しようとしています。この操作は取り消せません。", + "successToast": "サービスゾーン「{{name}}」が正常に削除されました。" + }, + "manageAreas": { + "header": "{{name}}のエリアを管理", + "action": "エリアを管理", + "label": "エリア", + "hint": "サービスゾーンがカバーする地理的エリアを選択してください。", + "successToast": "「{{name}}」のエリアが正常に更新されました。" + }, + "fields": { + "noRecords": "配送オプションを追加するサービスゾーンがありません。", + "tip": "サービスゾーンは地理的ゾーンまたはエリアの集合です。定義された一連の拠点に利用可能な配送オプションを制限するために使用されます。" + } + } + }, + "shippingProfile": { + "domain": "配送プロファイル", + "subtitle": "類似の配送要件を持つ商品をプロファイルにグループ化します。", + "create": { + "header": "配送プロファイルを作成", + "hint": "類似の配送要件を持つ商品をグループ化するための新しい配送プロファイルを作成します。", + "successToast": "配送プロファイル「{{name}}」が正常に作成されました。" + }, + "delete": { + "title": "配送プロファイルを削除", + "description": "配送プロファイル「{{name}}」を削除しようとしています。この操作は取り消せません。", + "successToast": "配送プロファイル「{{name}}」が正常に削除されました。" + }, + "tooltip": { + "type": "配送プロファイルのタイプを入力してください。例:重い、特大、貨物専用など。" + } + }, + "taxRegions": { + "domain": "税地域", + "list": { + "hint": "顧客が異なる国や地域から購入する際の課金方法を管理します。" + }, + "delete": { + "confirmation": "税地域を削除しようとしています。この操作は取り消せません。", + "successToast": "税地域が正常に削除されました。" + }, + "create": { + "header": "税地域を作成", + "hint": "特定の国の税率を定義するための新しい税地域を作成します。", + "errors": { + "rateIsRequired": "デフォルトの税率を作成する際は税率が必要です。", + "nameIsRequired": "デフォルトの税率を作成する際は名前が必要です。" + }, + "successToast": "税地域が正常に作成されました。" + }, + "province": { + "header": "省", + "create": { + "header": "省の税地域を作成", + "hint": "特定の省の税率を定義するための新しい税地域を作成します。" + } + }, + "state": { + "header": "州", + "create": { + "header": "州の税地域を作成", + "hint": "特定の州の税率を定義するための新しい税地域を作成します。" + } + }, + "stateOrTerritory": { + "header": "州または準州", + "create": { + "header": "州/準州の税地域を作成", + "hint": "特定の州/準州の税率を定義するための新しい税地域を作成します。" + } + }, + "county": { + "header": "郡", + "create": { + "header": "郡の税地域を作成", + "hint": "特定の郡の税率を定義するための新しい税地域を作成します。" + } + }, + "region": { + "header": "地域", + "create": { + "header": "地域の税地域を作成", + "hint": "特定の地域の税率を定義するための新しい税地域を作成します。" + } + }, + "department": { + "header": "部", + "create": { + "header": "部の税地域を作成", + "hint": "特定の部の税率を定義するための新しい税地域を作成します。" + } + }, + "territory": { + "header": "準州", + "create": { + "header": "準州の税地域を作成", + "hint": "特定の準州の税率を定義するための新しい税地域を作成します。" + } + }, + "prefecture": { + "header": "都道府県", + "create": { + "header": "都道府県の税地域を作成", + "hint": "特定の都道府県の税率を定義するための新しい税地域を作成します。" + } + }, + "district": { + "header": "地区", + "create": { + "header": "地区の税地域を作成", + "hint": "特定の地区の税率を定義するための新しい税地域を作成します。" + } + }, + "governorate": { + "header": "行政区", + "create": { + "header": "行政区の税地域を作成", + "hint": "特定の行政区の税率を定義するための新しい税地域を作成します。" + } + }, + "canton": { + "header": "カントン", + "create": { + "header": "カントンの税地域を作成", + "hint": "特定のカントンの税率を定義するための新しい税地域を作成します。" + } + }, + "emirate": { + "header": "首長国", + "create": { + "header": "首長国の税地域を作成", + "hint": "特定の首長国の税率を定義するための新しい税地域を作成します。" + } + }, + "sublevel": { + "header": "サブレベル", + "create": { + "header": "サブレベルの税地域を作成", + "hint": "特定のサブレベルの税率を定義するための新しい税地域を作成します。" + } + }, + "taxOverrides": { + "header": "上書き", + "create": { + "header": "上書きを作成", + "hint": "選択した条件に対してデフォルトの税率を上書きする税率を作成します。" + }, + "edit": { + "header": "上書きを編集", + "hint": "選択した条件に対してデフォルトの税率を上書きする税率を編集します。" + } + }, + "taxRates": { + "create": { + "header": "税率を作成", + "hint": "地域の税率を定義するための新しい税率を作成します。", + "successToast": "税率が正常に作成されました。" + }, + "edit": { + "header": "税率を編集", + "hint": "地域の税率を定義するための税率を編集します。", + "successToast": "税率が正常に更新されました。" + }, + "delete": { + "confirmation": "税率「{{name}}」を削除しようとしています。この操作は取り消せません。", + "successToast": "税率が正常に削除されました。" + } + }, + "fields": { + "isCombinable": { + "label": "組み合わせ可能", + "hint": "この税率を税地域のデフォルト税率と組み合わせることができるかどうか。", + "true": "組み合わせ可能", + "false": "組み合わせ不可" + }, + "defaultTaxRate": { + "label": "デフォルト税率", + "tooltip": "この地域のデフォルト税率。例えば、国や地域の標準VAT税率など。", + "action": "デフォルト税率を作成" + }, + "taxRate": "税率", + "taxCode": "税コード", + "targets": { + "label": "対象", + "hint": "この税率が適用される対象を選択してください。", + "options": { + "product": "商品", + "productCollection": "商品コレクション", + "productTag": "商品タグ", + "productType": "商品タイプ", + "customerGroup": "顧客グループ" + }, + "operators": { + "in": "に含まれる", + "on": "に対して", + "and": "および" + }, + "placeholders": { + "product": "商品を検索", + "productCollection": "商品コレクションを検索", + "productTag": "商品タグを検索", + "productType": "商品タイプを検索", + "customerGroup": "顧客グループを検索" + }, + "tags": { + "product": "商品", + "productCollection": "商品コレクション", + "productTag": "商品タグ", + "productType": "商品タイプ", + "customerGroup": "顧客グループ" + }, + "modal": { + "header": "対象を追加" + }, + "values_one": "{{count}}個の値", + "values_other": "{{count}}個の値", + "numberOfTargets_one": "{{count}}個の対象", + "numberOfTargets_other": "{{count}}個の対象", + "additionalValues_one": "さらに{{count}}個の値", + "additionalValues_other": "さらに{{count}}個の値", + "action": "対象を追加" + }, + "sublevels": { + "labels": { + "province": "省", + "state": "州", + "region": "地域", + "stateOrTerritory": "州/準州", + "department": "部", + "county": "郡", + "territory": "準州", + "prefecture": "都道府県", + "district": "地区", + "governorate": "行政区", + "emirate": "首長国", + "canton": "カントン", + "sublevel": "サブレベルコード" + }, + "placeholders": { + "province": "省を選択", + "state": "州を選択", + "region": "地域を選択", + "stateOrTerritory": "州/準州を選択", + "department": "行政区を選択", + "county": "郡を選択", + "territory": "準州を選択", + "prefecture": "都道府県を選択", + "district": "地区を選択", + "governorate": "行政区を選択", + "emirate": "首長国を選択", + "canton": "カントンを選択" + }, + "tooltips": { + "sublevel": "サブレベルの税地域のISO 3166-2コードを入力してください。", + "notPartOfCountry": "{{province}}は{{country}}の一部ではないようです。これが正しいかどうか確認してください。" + }, + "alert": { + "header": "この税地域ではサブレベル地域が無効になっています", + "description": "この地域ではデフォルトでサブレベル地域が無効になっています。有効にすると、州、県、準州などのサブレベル地域を作成できます。", + "action": "サブレベル地域を有効にする" + } + }, + "noDefaultRate": { + "label": "デフォルト税率なし", + "tooltip": "この税地域にはデフォルトの税率がありません。国のVATなど標準税率がある場合は、この地域に追加してください。" + } + } + }, + "promotions": { + "domain": "プロモーション", + "sections": { + "details": "プロモーションの詳細" + }, + "tabs": { + "template": "タイプ", + "details": "詳細", + "campaign": "キャンペーン" + }, + "fields": { + "type": "タイプ", + "value_type": "値のタイプ", + "value": "値", + "campaign": "キャンペーン", + "method": "方法", + "allocation": "配分", + "addCondition": "条件を追加", + "clearAll": "すべてクリア", + "amount": { + "tooltip": "金額を設定するには通貨コードを選択してください" + }, + "conditions": { + "rules": { + "title": "誰がこのコードを使用できますか?", + "description": "どの顧客がプロモーションコードを使用できるか?何も変更しなければ、すべての顧客が使用できます。" + }, + "target-rules": { + "title": "プロモーションはどの商品に適用されますか?", + "description": "プロモーションは、次の条件に一致する商品に適用されます。" + }, + "buy-rules": { + "title": "プロモーションを有効にするためにカートに必要なものは?", + "description": "これらの条件が一致した場合、ターゲット商品に対してプロモーションが有効になります。" + } + } + }, + "tooltips": { + "campaignType": "支出予算を設定するには、プロモーションで通貨コードを選択する必要があります。" + }, + "errors": { + "requiredField": "必須項目です", + "promotionTabError": "進む前にプロモーションタブのエラーを修正してください" + }, + "toasts": { + "promotionCreateSuccess": "プロモーション「{{code}}」が正常に作成されました。" + }, + "create": {}, + "edit": { + "title": "プロモーションの詳細を編集", + "rules": { + "title": "使用条件を編集" + }, + "target-rules": { + "title": "商品条件を編集" + }, + "buy-rules": { + "title": "購入条件を編集" + } + }, + "campaign": { + "header": "キャンペーン", + "edit": { + "header": "キャンペーンを編集", + "successToast": "プロモーションのキャンペーンが正常に更新されました。" + }, + "actions": { + "goToCampaign": "キャンペーンに移動" + } + }, + "campaign_currency": { + "tooltip": "これはプロモーションの通貨です。詳細タブから変更してください。" + }, + "form": { + "required": "必須", + "and": "と", + "selectAttribute": "属性を選択", + "campaign": { + "existing": { + "title": "既存のキャンペーン", + "description": "既存のキャンペーンにプロモーションを追加します。", + "placeholder": { + "title": "既存のキャンペーンはありません", + "desc": "複数のプロモーションを追跡し、予算制限を設定するために作成できます。" + } + }, + "new": { + "title": "新しいキャンペーン", + "description": "このプロモーションのために新しいキャンペーンを作成します。" + }, + "none": { + "title": "キャンペーンなし", + "description": "プロモーションをキャンペーンに関連付けずに進めます。" + } + }, + "status": { + "title": "ステータス" + }, + "method": { + "label": "方法", + "code": { + "title": "プロモーションコード", + "description": "顧客はチェックアウト時にこのコードを入力する必要があります。" + }, + "automatic": { + "title": "自動", + "description": "顧客はチェックアウト時にこのプロモーションを見ることができます。" + } + }, + "max_quantity": { + "title": "最大数量", + "description": "このプロモーションが適用される商品の最大数量。" + }, + "type": { + "standard": { + "title": "標準", + "description": "標準的なプロモーション" + }, + "buyget": { + "title": "購入して取得", + "description": "Xを購入してYを取得するプロモーション" + } + }, + "allocation": { + "each": { + "title": "各々", + "description": "各アイテムに値を適用します。" + }, + "across": { + "title": "全体", + "description": "アイテム全体に値を適用します。" + } + }, + "code": { + "title": "コード", + "description": "顧客がチェックアウト時に入力するコードです。" + }, + "value": { + "title": "プロモーション値" + }, + "value_type": { + "fixed": { + "title": "プロモーション値", + "description": "割引される金額。例:100" + }, + "percentage": { + "title": "プロモーション値", + "description": "金額から割引されるパーセンテージ。例:8%" + } + } + }, + "deleteWarning": "プロモーション「{{code}}」を削除しようとしています。この操作は取り消せません。", + "createPromotionTitle": "プロモーションを作成", + "type": "プロモーションタイプ", + "conditions": { + "add": "条件を追加", + "list": { + "noRecordsMessage": "プロモーションが適用されるアイテムを制限する条件を追加してください。" + } + } + }, + "campaigns": { + "domain": "キャンペーン", + "details": "キャンペーン詳細", + "status": { + "active": "アクティブ", + "expired": "期限切れ", + "scheduled": "スケジュール済み" + }, + "delete": { + "title": "本当ですか?", + "description": "キャンペーン「{{name}}」を削除しようとしています。この操作は取り消せません。", + "successToast": "キャンペーン「{{name}}」が正常に作成されました。" + }, + "edit": { + "header": "キャンペーンを編集", + "description": "キャンペーンの詳細を編集します。", + "successToast": "キャンペーン「{{name}}」が正常に更新されました。" + }, + "configuration": { + "header": "設定", + "edit": { + "header": "キャンペーン設定を編集", + "description": "キャンペーンの設定を編集します。", + "successToast": "キャンペーン設定が正常に更新されました。" + } + }, + "create": { + "title": "キャンペーンを作成", + "description": "プロモーションキャンペーンを作成します。", + "hint": "プロモーションキャンペーンを作成します。", + "header": "キャンペーンを作成", + "successToast": "キャンペーン「{{name}}」が正常に作成されました。" + }, + "fields": { + "name": "名前", + "identifier": "識別子", + "start_date": "開始日", + "end_date": "終了日", + "total_spend": "予算支出", + "total_used": "使用済み予算", + "budget_limit": "予算制限", + "campaign_id": { + "hint": "プロモーションと同じ通貨コードのキャンペーンのみがこのリストに表示されます。" + } + }, + "budget": { + "create": { + "hint": "キャンペーンの予算を作成します。", + "header": "キャンペーン予算" + }, + "details": "キャンペーン予算", + "fields": { + "type": "タイプ", + "currency": "通貨", + "limit": "制限", + "used": "使用済み" + }, + "type": { + "spend": { + "title": "支出", + "description": "すべてのプロモーション使用の合計割引額に制限を設定します。" + }, + "usage": { + "title": "使用", + "description": "プロモーションが使用できる回数に制限を設定します。" + } + }, + "edit": { + "header": "キャンペーン予算を編集" + } + }, + "promotions": { + "remove": { + "title": "キャンペーンからプロモーションを削除", + "description": "キャンペーンから{{count}}個のプロモーションを削除しようとしています。この操作は取り消せません。" + }, + "alreadyAdded": "このプロモーションはすでにキャンペーンに追加されています。", + "alreadyAddedDiffCampaign": "このプロモーションは別のキャンペーン({{name}})にすでに追加されています。", + "currencyMismatch": "プロモーションとキャンペーンの通貨が一致しません。", + "toast": { + "success": "{{count}}個のプロモーションが正常にキャンペーンに追加されました。" + }, + "add": { + "list": { + "noRecordsMessage": "最初にプロモーションを作成してください。" + } + }, + "list": { + "noRecordsMessage": "このキャンペーンにはプロモーションがありません。" + } + }, + "deleteCampaignWarning": "キャンペーン「{{name}}」を削除しようとしています。この操作は取り消せません。", + "totalSpend": "<0>{{amount}} <1>{{currency}}" + }, + "priceLists": { + "domain": "価格リスト", + "subtitle": "特定の条件に対して販売価格を作成または上書きします。", + "delete": { + "confirmation": "価格リスト「{{title}}」を削除しようとしています。この操作は取り消せません。", + "successToast": "価格リスト「{{title}}」が正常に削除されました。" + }, + "create": { + "header": "価格リストを作成", + "subheader": "商品の価格を管理するための新しい価格リストを作成します。", + "tabs": { + "details": "詳細", + "products": "商品", + "prices": "価格" + }, + "successToast": "価格リスト「{{title}}」が正常に作成されました。", + "products": { + "list": { + "noRecordsMessage": "最初に商品を作成してください。" + } + } + }, + "edit": { + "header": "価格リストを編集", + "successToast": "価格リスト「{{title}}」が正常に更新されました。" + }, + "configuration": { + "header": "設定", + "edit": { + "header": "価格リストの設定を編集", + "description": "価格リストの設定を編集します。", + "successToast": "価格リストの設定が正常に更新されました。" + } + }, + "products": { + "header": "商品", + "actions": { + "addProducts": "商品を追加", + "editPrices": "価格を編集" + }, + "delete": { + "confirmation_one": "{{count}}個の商品に対する価格をこの価格リストから削除しようとしています。この操作は取り消せません。", + "confirmation_other": "{{count}}個の商品に対する価格をこの価格リストから削除しようとしています。この操作は取り消せません。", + "successToast_one": "{{count}}個の商品に対する価格が正常に削除されました。", + "successToast_other": "{{count}}個の商品に対する価格が正常に削除されました。" + }, + "add": { + "successToast": "価格が正常に価格リストに追加されました。" + }, + "edit": { + "successToast": "価格が正常に更新されました。" + } + }, + "fields": { + "priceOverrides": { + "label": "価格の上書き", + "header": "価格の上書き" + }, + "status": { + "label": "ステータス", + "options": { + "active": "アクティブ", + "draft": "ドラフト", + "expired": "期限切れ", + "scheduled": "スケジュール済み" + } + }, + "type": { + "label": "タイプ", + "hint": "作成したい価格リストのタイプを選択します。", + "options": { + "sale": { + "label": "セール", + "description": "セール価格は商品の一時的な価格変更です。" + }, + "override": { + "label": "上書き", + "description": "上書きは通常、顧客特有の価格を作成するために使用されます。" + } + } + }, + "startsAt": { + "label": "価格リストには開始日がありますか?", + "hint": "将来有効になるように、価格リストをスケジュールします。" + }, + "endsAt": { + "label": "価格リストには終了日がありますか?", + "hint": "将来無効になるように、価格リストをスケジュールします。" + }, + "customerAvailability": { + "header": "顧客グループを選択", + "label": "顧客の可用性", + "hint": "この価格リストが適用される顧客グループを選択します。", + "placeholder": "顧客グループを検索", + "attribute": "顧客グループ" + } + } + }, + "profile": { + "domain": "プロフィール", + "manageYourProfileDetails": "プロフィールの詳細を管理します。", + "fields": { + "languageLabel": "言語", + "usageInsightsLabel": "使用状況のインサイト" + }, + "edit": { + "header": "プロフィールを編集", + "languageHint": "管理ダッシュボードで使用する言語です。これにより、ストアの言語は変更されません。", + "languagePlaceholder": "言語を選択", + "usageInsightsHint": "使用状況のインサイトを共有し、Medusaの改善にご協力ください。収集内容やその使用方法については、<0>ドキュメントをご覧ください。" + }, + "toast": { + "edit": "プロフィールの変更が保存されました" + } + }, + "users": { + "domain": "ユーザー", + "editUser": "ユーザーを編集", + "inviteUser": "ユーザーを招待", + "inviteUserHint": "新しいユーザーをストアに招待します。", + "sendInvite": "招待を送信", + "pendingInvites": "保留中の招待", + "deleteInviteWarning": "{{email}}への招待を削除しようとしています 。この操作は取り消せません。", + "resendInvite": "招待を再送信", + "copyInviteLink": "招待リンクをコピー", + "expiredOnDate": "{{date}}に期限切れ", + "validFromUntil": "<0>{{from}} - <1>{{until}} の間有効", + "acceptedOnDate": "{{date}}に承認されました", + "inviteStatus": { + "accepted": "承認済み", + "pending": "保留中", + "expired": "期限切れ" + }, + "roles": { + "admin": "管理者", + "developer": "開発者", + "member": "メンバー" + }, + "deleteUserWarning": "{{name}}を削除しようとしています。この操作は取り消せません。", + "invite": "招待" + }, + "store": { + "domain": "ストア", + "manageYourStoresDetails": "ストアの詳細を管理します。", + "editStore": "ストアを編集", + "defaultCurrency": "デフォルト通貨", + "defaultRegion": "デフォルト地域", + "swapLinkTemplate": "リンクテンプレートを交換", + "paymentLinkTemplate": "支払いリンクテンプレート", + "inviteLinkTemplate": "招待リンクテンプレート", + "currencies": "通貨", + "addCurrencies": "通貨を追加", + "enableTaxInclusivePricing": "税込価格を有効にする", + "disableTaxInclusivePricing": "税込価格を無効にする", + "removeCurrencyWarning_one": "{{count}}個の通貨をストアから削除しようとしています。進む前に、その通貨を使用しているすべての価格が削除されていることを確認してください。", + "removeCurrencyWarning_other": "{{count}}個の通貨をストアから削除しようとしています。進む前に、それらの通貨を使用しているすべての価格が削除されていることを確認してください。", + "currencyAlreadyAdded": "この通貨はすでにストアに追加されています。", + "edit": { + "header": "ストアを編集" + }, + "toast": { + "update": "ストアが正常に更新されました。", + "currenciesUpdated": "通貨が正常に更新されました。", + "currenciesRemoved": "ストアから通貨が正常に削除されました。", + "updatedTaxInclusivitySuccessfully": "税込価格が正常に更新されました。" + } + }, + "regions": { + "domain": "地域", + "subtitle": "地域は、商品を販売するエリアです。複数の国をカバーでき、異なる税率、プロバイダー、通貨を持ちます。", + "createRegion": "地域を作成", + "createRegionHint": "一連の国の税率とプロバイダーを管理します。", + "addCountries": "国を追加", + "editRegion": "地域を編集", + "countriesHint": "この地域に含まれる国を追加します。", + "deleteRegionWarning": "地域「{{name}}」を削除しようとしています。この操作は取り消せません。", + "removeCountriesWarning_one": "{{count}}か国を地域から削除しようとしています。この操作は取り消せません。", + "removeCountriesWarning_other": "{{count}}か国を地域から削除しようとしています。この操作は取り消せません。", + "removeCountryWarning": "国「{{name}}」を地域から削除しようとしています。この操作は取り消せません。", + "automaticTaxesHint": "有効にすると、税金は配送先住所に基づいてチェックアウト時にのみ計算されます。", + "taxInclusiveHint": "有効にすると、この地域の価格は税込みになります。", + "providersHint": "この地域で利用可能な決済プロバイダーを追加します。", + "shippingOptions": "配送オプション", + "deleteShippingOptionWarning": "配送オプション「{{name}}」を削除しようとしています。この操作は取り消せません。", + "return": "返品", + "outbound": "発送", + "priceType": "価格タイプ", + "flatRate": "定額", + "calculated": "計算式", + "list": { + "noRecordsMessage": "販売地域の地域を作成してください。" + }, + "toast": { + "delete": "地域が正常に削除されました", + "edit": "地域の編集が保存されました", + "create": "地域が正常に作成されました", + "countries": "地域の国が正常に更新されました" + }, + "shippingOption": { + "createShippingOption": "配送オプションを作成", + "createShippingOptionHint": "地域の新しい配送オプションを作成します。", + "editShippingOption": "配送オプションを編集", + "fulfillmentMethod": "出荷方法", + "type": { + "outbound": "発送", + "outboundHint": "顧客に商品を送る配送オプションを作成する場合に使用します。", + "return": "返品", + "returnHint": "顧客が商品を返品するための配送オプションを作成する場合に使用します。" + }, + "priceType": { + "label": "価格タイプ", + "flatRate": "定額", + "calculated": "計算式" + }, + "availability": { + "adminOnly": "管理者のみ", + "adminOnlyHint": "有効にすると、配送オプションは管理ダッシュボードでのみ利用可能になり、ストアフロントでは利用できません。" + }, + "taxInclusiveHint": "有効にすると、配送オプションの価格は税込みになります。", + "requirements": { + "label": "要件", + "hint": "配送オプションの要件を指定します。" + } + } + }, + "taxes": { + "domain": "税地域", + "domainDescription": "税地域を管理する", + "countries": { + "taxCountriesHint": "税設定は表示されている国に適用されます。" + }, + "settings": { + "editTaxSettings": "税設定を編集", + "taxProviderLabel": "税プロバイダー", + "systemTaxProviderLabel": "システム税プロバイダー", + "calculateTaxesAutomaticallyLabel": "税金を自動計算する", + "calculateTaxesAutomaticallyHint": "有効にすると、税率が自動的に計算され、カートに適用されます。無効の場合、税金はチェックアウト時に手動で計算する必要があります。サードパーティの税プロバイダーを使用する場合は、手動での税金計算が推奨されます。", + "applyTaxesOnGiftCardsLabel": "ギフトカードに税金を適用する", + "applyTaxesOnGiftCardsHint": "有効にすると、チェックアウト時にギフトカードに税金が適用されます。一部の国では、税法により購入時にギフトカードへの課税が必要とされています。", + "defaultTaxRateLabel": "デフォルト税率", + "defaultTaxCodeLabel": "デフォルト税コード" + }, + "defaultRate": { + "sectionTitle": "デフォルト税率" + }, + "taxRate": { + "sectionTitle": "税率", + "createTaxRate": "税率を作成", + "createTaxRateHint": "地域の新しい税率を作成します。", + "deleteRateDescription": "税率「{{name}}」を削除しようとしています。この操作は取り消せません。", + "editTaxRate": "税率を編集", + "editRateAction": "税率を編集", + "editOverridesAction": "上書きを編集", + "editOverridesTitle": "税率の上書きを編集", + "editOverridesHint": "税率の上書きを指定します。", + "deleteTaxRateWarning": "税率「{{name}}」を削除しようとしています。この操作は取り消せません。", + "productOverridesLabel": "商品の上書き", + "productOverridesHint": "税率の商品上書きを指定します。", + "addProductOverridesAction": "商品の上書きを追加", + "productTypeOverridesLabel": "商品タイプの上書き", + "productTypeOverridesHint": "税率の商品タイプ上書きを指定します。", + "addProductTypeOverridesAction": "商品タイプの上書きを追加", + "shippingOptionOverridesLabel": "配送オプションの上書き", + "shippingOptionOverridesHint": "税率の配送オプション上書きを指定します。", + "addShippingOptionOverridesAction": "配送オプションの上書きを追加", + "productOverridesHeader": "商品", + "productTypeOverridesHeader": "商品タイプ", + "shippingOptionOverridesHeader": "配送オプション" + } + }, + "locations": { + "domain": "拠点", + "editLocation": "拠点を編集", + "addSalesChannels": "販売チャネルを追加", + "noLocationsFound": "拠点が見つかりません", + "selectLocations": "商品を在庫する拠点を選択してください。", + "deleteLocationWarning": "拠点「{{name}}」を削除しようとしています。この操作は取り消せません。", + "removeSalesChannelsWarning_one": "{{count}}個の販売チャネルを拠点から削除しようとしています。", + "removeSalesChannelsWarning_other": "{{count}}個の販売チャネルを拠点から削除しようとしています。", + "toast": { + "create": "拠点が正常に作成されました", + "update": "拠点が正常に更新されました", + "removeChannel": "販売チャネルが正常に削除されました" + } + }, + "reservations": { + "domain": "予約", + "subtitle": "在庫アイテムの予約数量を管理します。", + "deleteWarning": "予約を削除しようとしています。この操作は取り消せません。" + }, + "salesChannels": { + "domain": "販売チャネル", + "subtitle": "商品を販売するオンラインおよびオフラインのチャネルを管理します。", + "createSalesChannel": "販売チャネルを作成", + "createSalesChannelHint": "商品を販売するための新しい販売チャネルを作成します。", + "enabledHint": "販売チャネルが有効かどうかを指定します。", + "removeProductsWarning_one": "{{sales_channel}}から{{count}}個の商品を削除しようとしています。", + "removeProductsWarning_other": "{{sales_channel}}から{{count}}個の商品を削除しようとしています。", + "addProducts": "商品を追加", + "editSalesChannel": "販売チャネルを編集", + "productAlreadyAdded": "この商品はすでに販売チャネルに追加されています。", + "deleteSalesChannelWarning": "販売チャネル{{name}}を削除しようとしています。この操作は取り消せません。", + "toast": { + "create": "販売チャネルが正常に作成されました", + "update": "販売チャネルが正常に更新されました", + "delete": "販売チャネルが正常に削除されました" + }, + "tooltip": { + "cannotDeleteDefault": "デフォルトの販売チャネルは削除できません" + }, + "products": { + "list": { + "noRecordsMessage": "この販売チャネルには商品がありません。" + }, + "add": { + "list": { + "noRecordsMessage": "最初に商品を作成してください。" + } + } + } + }, + "apiKeyManagement": { + "domain": { + "publishable": "公開可能なAPIキー", + "secret": "秘密のAPIキー" + }, + "subtitle": { + "publishable": "ストアフロントで使用されるAPIキーを管理し、特定の販売チャネルへのリクエストの範囲を制限します。", + "secret": "管理者アプリケーションで管理者ユーザーを認証するために使用されるAPIキーを管理します。" + }, + "status": { + "active": "アクティブ", + "revoked": "無効" + }, + "type": { + "publishable": "公開可能", + "secret": "秘密" + }, + "create": { + "createPublishableHeader": "公開可能なAPIキーを作成", + "createPublishableHint": "特定の販売チャネルへのリクエストの範囲を制限するための新しい公開可能なAPIキーを作成します。", + "createSecretHeader": "秘密のAPIキーを作成", + "createSecretHint": "認証された管理者ユーザーとしてMedusa APIにアクセスするための新しい秘密のAPIキーを作成します。", + "secretKeyCreatedHeader": "秘密のキーが作成されました", + "secretKeyCreatedHint": "新しい秘密のキーが生成されました。今すぐコピーして安全に保管してください。これが表示される唯一の機会です。", + "copySecretTokenSuccess": "秘密のキーがクリップボードにコピーされました。", + "copySecretTokenFailure": "秘密のキーをクリップボードにコピーできませんでした。", + "successToast": "APIキーが正常に作成されました。" + }, + "edit": { + "header": "APIキーを編集", + "description": "APIキーのタイトルを編集します。", + "successToast": "APIキー「{{title}}」が正常に更新されました。" + }, + "salesChannels": { + "title": "販売チャネルを追加", + "description": "APIキーが制限される販売チャネルを追加します。", + "successToast_one": "{{count}}個の販売チャネルがAPIキーに正常に追加されました。", + "successToast_other": "{{count}}個の販売チャネルがAPIキーに正常に追加されました。", + "alreadyAddedTooltip": "この販売チャネルはすでにAPIキーに追加されています。", + "list": { + "noRecordsMessage": "公開可能なAPIキーの範囲内に販売チャネルがありません。" + } + }, + "delete": { + "warning": "APIキー「{{title}}」を削除しようとしています。この操作は取り消せません。", + "successToast": "APIキー「{{title}}」が正常に削除されました。" + }, + "revoke": { + "warning": "APIキー「{{title}}」を無効化しようとしています。この操作は取り消せません。", + "successToast": "APIキー「{{title}}」が正常に無効化されました。" + }, + "addSalesChannels": { + "list": { + "noRecordsMessage": "最初に販売チャネルを作成してください。" + } + }, + "removeSalesChannel": { + "warning": "販売チャネル「{{name}}」をAPIキーから削除しようとしています。この操作は取り消せません。", + "warningBatch_one": "{{count}}個の販売チャネルをAPIキーから削除しようとしています。この操作は取り消せません。", + "warningBatch_other": "{{count}}個の販売チャネルをAPIキーから削除しようとしています。この操作は取り消せません。", + "successToast": "販売チャネルがAPIキーから正常に削除されました。", + "successToastBatch_one": "{{count}}個の販売チャネルがAPIキーから正常に削除されました。", + "successToastBatch_other": "{{count}}個の販売チャネルがAPIキーから正常に削除されました。" + }, + "actions": { + "revoke": "APIキーを無効化", + "copy": "APIキーをコピー", + "copySuccessToast": "APIキーがクリップボードにコピーされました。" + }, + "table": { + "lastUsedAtHeader": "最終使用日時", + "createdAtHeader": "無効化日時" + }, + "fields": { + "lastUsedAtLabel": "最終使用日時", + "revokedByLabel": "無効化者", + "revokedAtLabel": "無効化日時", + "createdByLabel": "作成者" + } + }, + "returnReasons": { + "domain": "返品理由", + "subtitle": "返品された商品の理由を管理します。", + "calloutHint": "返品を分類するための理由を管理します。", + "editReason": "返品理由を編集", + "create": { + "header": "返品理由を追加", + "subtitle": "最も一般的な返品理由を指定します。", + "hint": "返品を分類するための新しい返品理由を作成します。", + "successToast": "返品理由「{{label}}」が正常に作成されました。" + }, + "edit": { + "header": "返品理由を編集", + "subtitle": "返品理由の値を編集します。", + "successToast": "返品理由「{{label}}」が正常に更新されました。" + }, + "delete": { + "confirmation": "返品理由「{{label}}」を削除しようとしています。この操作は取り消せません。", + "successToast": "返品理由「{{label}}」が正常に削除されました。" + }, + "fields": { + "value": { + "label": "値", + "placeholder": "wrong_size", + "tooltip": "値は返品理由の一意の識別子である必要があります。" + }, + "label": { "label": "ラベル", "placeholder": "サイズが合わない" }, + "description": { + "label": "説明", + "placeholder": "顧客が間違ったサイズを受け取った" + } + } + }, + "login": { + "forgotPassword": "パスワードをお忘れですか? - <0>リセット", + "title": "Medusaへようこそ", + "hint": "アカウントエリアにアクセスするにはサインインしてください" + }, + "invite": { + "title": "Medusaへようこそ", + "hint": "以下でアカウントを作成してください", + "backToLogin": "ログインに戻る", + "createAccount": "アカウントを作成", + "alreadyHaveAccount": "すでにアカウントをお持ちですか? - <0>ログイン", + "emailTooltip": "Eメールは変更できません。別のEメールを使用したい場合は、新しい招待を送信する必要があります。", + "invalidInvite": "招待が無効であるか、期限が切れています。", + "successTitle": "アカウントが登録されました", + "successHint": "Medusa Adminをすぐに始めましょう。", + "successAction": "Medusa Adminを開始", + "invalidTokenTitle": "招待トークンが無効です", + "invalidTokenHint": "新しい招待リンクをリクエストしてみてください。", + "passwordMismatch": "パスワードが一致しません", + "toast": { + "accepted": "招待が正常に受け入れられました" + } + }, + "resetPassword": { + "title": "パスワードをリセット", + "hint": "以下にEメールを入力してください。パスワードのリセット方法についての説明をお送りします。", + "email": "Eメール", + "sendResetInstructions": "リセット手順を送信", + "backToLogin": "<0>ログインに戻る", + "newPasswordHint": "以下に新しいパスワードを選択してください。", + "invalidTokenTitle": "リセットトークンが無効です", + "invalidTokenHint": "新しいリセットリンクをリクエストしてみてください。", + "expiredTokenTitle": "リセットトークンの有効期限が切れています", + "goToResetPassword": "パスワードリセットページへ", + "resetPassword": "パスワードをリセット", + "newPassword": "新しいパスワード", + "repeatNewPassword": "新しいパスワードを再入力", + "tokenExpiresIn": "トークンの有効期限は残り<0>{{time}}分です", + "successfulRequestTitle": "Eメールを正常に送信しました", + "successfulRequest": "パスワードをリセットするためのEメールを送信しました。数分経っても受信していない場合は、迷惑フォルダをご確認ください。", + "successfulResetTitle": "パスワードのリセットに成功しました", + "successfulReset": "ログインページからログインしてください。", + "passwordMismatch": "パスワードが一致しません", + "invalidLinkTitle": "リセットリンクが無効です", + "invalidLinkHint": "もう一度パスワードのリセットを試みてください。" + }, + "workflowExecutions": { + "domain": "ワークフロー", + "subtitle": "Medusaアプリケーションでのワークフロー実行を表示し、追跡します。", + "transactionIdLabel": "トランザクションID", + "workflowIdLabel": "ワークフローID", + "progressLabel": "進捗", + "stepsCompletedLabel_one": "{{count}}ステップ中{{completed}}ステップ完了", + "stepsCompletedLabel_other": "{{count}}ステップ中{{completed}}ステップ完了", + "list": { + "noRecordsMessage": "まだワークフローが実行されていません。" + }, + "history": { + "sectionTitle": "履歴", + "runningState": "実行中...", + "awaitingState": "待機中", + "failedState": "失敗", + "skippedState": "スキップ", + "skippedFailureState": "スキップ(失敗)", + "definitionLabel": "定義", + "outputLabel": "出力", + "compensateInputLabel": "補償処理入力", + "revertedLabel": "復元済み", + "errorLabel": "エラー" + }, + "state": { + "done": "完了", + "failed": "失敗", + "reverted": "復元済み", + "invoking": "呼び出し中", + "compensating": "補償処理中", + "notStarted": "未開始" + }, + "transaction": { + "state": { + "waitingToCompensate": "補償処理待ち" + } + }, + "step": { + "state": { + "skipped": "スキップ", + "skippedFailure": "スキップ(失敗)", + "dormant": "休止中", + "timeout": "タイムアウト" + } + } + }, + "productTypes": { + "domain": "商品タイプ", + "subtitle": "商品をタイプ別に整理します。", + "create": { + "header": "商品タイプを作成", + "hint": "商品を分類するための新しい商品タイプを作成します。", + "successToast": "商品タイプ「{{value}}」が正常に作成されました。" + }, + "edit": { + "header": "商品タイプを編集", + "successToast": "商品タイプ「{{value}}」が正常に更新されました。" + }, + "delete": { + "confirmation": "商品タイプ「{{value}}」を削除しようとしています。この操作は取り消せません。", + "successToast": "商品タイプ「{{value}}」が正常に削除されました。" + }, + "fields": { + "value": "値" + } + }, + "productTags": { + "domain": "商品タグ", + "create": { + "header": "商品タグを作成", + "subtitle": "商品を分類するための新しい商品タグを作成します。", + "successToast": "商品タグ「{{value}}」が正常に作成されました。" + }, + "edit": { + "header": "商品タグを編集", + "subtitle": "商品タグの値を編集します。", + "successToast": "商品タグ「{{value}}」が正常に更新されました。" + }, + "delete": { + "confirmation": "商品タグ「{{value}}」を削除しようとしています。この操作は取り消せません。", + "successToast": "商品タグ「{{value}}」が正常に削除されました。" + }, + "fields": { + "value": "値" + } + }, + "notifications": { + "domain": "通知", + "emptyState": { + "title": "通知はありません", + "description": "現在通知はありませんが、通知が届くとここに表示されます。" + }, + "accessibility": { + "description": "Medusaの活動に関する通知がここに表示されます。" + } + }, + "errors": { + "serverError": "サーバーエラー - 後でもう一度お試しください。", + "invalidCredentials": "Eメールまたはパスワードが間違っています" + }, + "statuses": { + "scheduled": "スケジュール済み", + "expired": "期限切れ", + "active": "アクティブ", + "enabled": "有効", + "disabled": "無効" + }, + "labels": { + "productVariant": "商品バリエーション", + "prices": "価格", + "available": "利用可能", + "inStock": "在庫あり", + "added": "追加済み", + "removed": "削除済み", + "from": "から", + "to": "へ" + }, + "fields": { + "amount": "金額", + "refundAmount": "返金額", + "name": "名前", + "default": "デフォルト", + "lastName": "姓", + "firstName": "名", + "title": "タイトル", + "customTitle": "カスタムタイトル", + "manageInventory": "在庫管理", + "inventoryKit": "在庫キットあり", + "inventoryItems": "在庫アイテム", + "inventoryItem": "在庫アイテム", + "requiredQuantity": "必要数量", + "description": "説明", + "email": "Eメール", + "password": "パスワード", + "repeatPassword": "パスワードを再入力", + "confirmPassword": "パスワードを確認", + "newPassword": "新しいパスワード", + "repeatNewPassword": "新しいパスワードを再入力", + "categories": "カテゴリー", + "shippingMethod": "配送方法", + "configurations": "設定", + "conditions": "条件", + "category": "カテゴリー", + "collection": "コレクション", + "discountable": "割引可能", + "handle": "ハンドル", + "subtitle": "サブタイトル", + "by": "作成者", + "item": "アイテム", + "qty": "数量", + "limit": "制限", + "tags": "タグ", + "type": "タイプ", + "reason": "理由", + "none": "なし", + "all": "すべて", + "search": "検索", + "percentage": "割合", + "sales_channels": "販売チャネル", + "customer_groups": "顧客グループ", + "product_tags": "商品タグ", + "product_types": "商品タイプ", + "product_collections": "商品コレクション", + "status": "ステータス", + "code": "コード", + "value": "値", + "disabled": "無効", + "dynamic": "動的", + "normal": "通常", + "years": "年", + "months": "月", + "days": "日", + "hours": "時間", + "minutes": "分", + "totalRedemptions": "総償還数", + "countries": "国", + "paymentProviders": "決済プロバイダー", + "refundReason": "返金理由", + "fulfillmentProviders": "フルフィルメントプロバイダー", + "fulfillmentProvider": "フルフィルメントプロバイダー", + "providers": "プロバイダー", + "availability": "利用可能性", + "inventory": "在庫", + "optional": "任意", + "note": "メモ", + "automaticTaxes": "自動税計算", + "taxInclusivePricing": "税込価格", + "currency": "通貨", + "address": "住所", + "address2": "アパート、部屋番号など", + "city": "市区町村", + "postalCode": "郵便番号", + "country": "国", + "state": "州", + "province": "都道府県", + "company": "会社", + "phone": "電話番号", + "metadata": "メタデータ", + "selectCountry": "国を選択", + "products": "商品", + "variants": "バリエーション", + "orders": "注文", + "account": "アカウント", + "total": "注文合計", + "paidTotal": "支払済み合計", + "totalExclTax": "税抜合計", + "subtotal": "小計", + "shipping": "配送", + "outboundShipping": "発送配送", + "returnShipping": "返品配送", + "tax": "税金", + "created": "作成日", + "key": "キー", + "customer": "顧客", + "date": "日付", + "order": "注文", + "fulfillment": "フルフィルメント", + "provider": "プロバイダー", + "payment": "支払い", + "items": "アイテム", + "salesChannel": "販売チャネル", + "region": "地域", + "discount": "割引", + "role": "役割", + "sent": "送信済み", + "salesChannels": "販売チャネル", + "product": "商品", + "createdAt": "作成日時", + "updatedAt": "更新日時", + "revokedAt": "無効化日時", + "true": "True", + "false": "False", + "giftCard": "ギフトカード", + "tag": "タグ", + "dateIssued": "発行日", + "issuedDate": "発行日", + "expiryDate": "有効期限", + "price": "価格", + "priceTemplate": "価格 {{regionOrCurrency}}", + "height": "高さ", + "width": "幅", + "length": "長さ", + "weight": "重量", + "midCode": "MIDコード", + "hsCode": "HSコード", + "ean": "EAN", + "upc": "UPC", + "inventoryQuantity": "在庫数量", + "barcode": "バーコード", + "countryOfOrigin": "原産国", + "material": "素材", + "thumbnail": "サムネイル", + "sku": "SKU", + "managedInventory": "管理在庫", + "allowBackorder": "バックオーダー許可", + "inStock": "在庫あり", + "location": "拠点", + "quantity": "数量", + "variant": "バリエーション", + "id": "ID", + "parent": "親", + "minSubtotal": "最小小計", + "maxSubtotal": "最大小計", + "shippingProfile": "配送プロファイル", + "summary": "概要", + "details": "詳細", + "label": "ラベル", + "rate": "料率", + "requiresShipping": "配送必要", + "unitPrice": "単価", + "startDate": "開始日", + "endDate": "終了日", + "draft": "下書き", + "values": "値" + }, + "dateTime": { + "years_one": "年", + "years_other": "年", + "months_one": "月", + "months_other": "月", + "weeks_one": "週", + "weeks_other": "週", + "days_one": "日", + "days_other": "日", + "hours_one": "時", + "hours_other": "時", + "minutes_one": "分", + "minutes_other": "分", + "seconds_one": "秒", + "seconds_other": "秒" + } +} From 864f53011b892e1ed0abee2e241b662eccef7e6d Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Sun, 8 Dec 2024 12:51:13 +0100 Subject: [PATCH 11/13] fix(dashboard,types): Fix TS errors (#10457) **What** - Fixes TS erros in dashboard project - Updates incorrect HTTP Invite types - Fixes incorrectly formatted dates in dashboard --- .changeset/stale-eyes-float.md | 6 ++ .../common/action-menu/action-menu.tsx | 20 ++-- .../common/button-menu/button-menu.tsx | 96 ------------------- .../components/common/button-menu/index.ts | 1 - .../src/components/common/date/index.ts | 11 --- .../forms/address-form/address-form.tsx | 28 ++++-- .../forms/metadata-form/metadata-form.tsx | 3 +- .../created-at-cell/created-at-cell.tsx | 22 ++--- .../order/customer-cell/customer-cell.tsx | 2 +- .../payment-status-cell.tsx | 3 +- .../sales-channel-cell/sales-channel-cell.tsx | 2 +- .../region/countries-cell/countries-cell.tsx | 20 ++-- .../dashboard-extension-provider/types.ts | 32 ------- .../admin/dashboard/src/hooks/api/auth.tsx | 9 +- .../admin/dashboard/src/hooks/api/claims.tsx | 5 +- .../dashboard/src/hooks/api/collections.tsx | 8 +- .../admin/dashboard/src/hooks/api/invites.tsx | 26 ++--- .../dashboard/src/hooks/api/price-lists.tsx | 2 +- .../table/columns/use-order-table-columns.tsx | 2 +- packages/admin/dashboard/src/index.ts | 1 - .../order-edit-items-section.tsx | 19 ++-- .../order-summary-section.tsx | 5 +- .../return-info-popover.tsx | 15 ++- .../src/routes/tax-regions/common/hooks.ts | 12 +-- .../edit-user-form/edit-user-form.tsx | 4 +- .../invite-user-form/invite-user-form.tsx | 8 +- .../users/user-metadata/user-metadata.tsx | 4 +- .../types/src/http/invite/admin/entities.ts | 6 +- 28 files changed, 120 insertions(+), 252 deletions(-) create mode 100644 .changeset/stale-eyes-float.md delete mode 100644 packages/admin/dashboard/src/components/common/button-menu/button-menu.tsx delete mode 100644 packages/admin/dashboard/src/components/common/button-menu/index.ts delete mode 100644 packages/admin/dashboard/src/components/common/date/index.ts delete mode 100644 packages/admin/dashboard/src/extensions/dashboard-extension-provider/types.ts delete mode 100644 packages/admin/dashboard/src/index.ts diff --git a/.changeset/stale-eyes-float.md b/.changeset/stale-eyes-float.md new file mode 100644 index 0000000000000..2d6c828aff7d8 --- /dev/null +++ b/.changeset/stale-eyes-float.md @@ -0,0 +1,6 @@ +--- +"@medusajs/dashboard": patch +"@medusajs/types": patch +--- + +fix(dashboard,types): Fix TS errors diff --git a/packages/admin/dashboard/src/components/common/action-menu/action-menu.tsx b/packages/admin/dashboard/src/components/common/action-menu/action-menu.tsx index 2ef6ca956d889..cd918763c2591 100644 --- a/packages/admin/dashboard/src/components/common/action-menu/action-menu.tsx +++ b/packages/admin/dashboard/src/components/common/action-menu/action-menu.tsx @@ -1,7 +1,7 @@ import { DropdownMenu, IconButton, clx } from "@medusajs/ui" import { EllipsisHorizontal } from "@medusajs/icons" -import { ReactNode } from "react" +import { PropsWithChildren, ReactNode } from "react" import { Link } from "react-router-dom" import { ConditionalTooltip } from "../conditional-tooltip" @@ -28,18 +28,20 @@ export type ActionGroup = { actions: Action[] } -type ActionMenuProps = { +type ActionMenuProps = PropsWithChildren<{ groups: ActionGroup[] -} +}> + +export const ActionMenu = ({ groups, children }: ActionMenuProps) => { + const inner = children ?? ( + + + + ) -export const ActionMenu = ({ groups }: ActionMenuProps) => { return ( - - - - - + {inner} {groups.map((group, index) => { if (!group.actions.length) { diff --git a/packages/admin/dashboard/src/components/common/button-menu/button-menu.tsx b/packages/admin/dashboard/src/components/common/button-menu/button-menu.tsx deleted file mode 100644 index 54ba5a476ec9c..0000000000000 --- a/packages/admin/dashboard/src/components/common/button-menu/button-menu.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { DropdownMenu, clx } from "@medusajs/ui" - -import { PropsWithChildren, ReactNode } from "react" -import { Link } from "react-router-dom" - -type Action = { - icon: ReactNode - label: string - disabled?: boolean -} & ( - | { - to: string - onClick?: never - } - | { - onClick: () => void - to?: never - } -) - -type ActionGroup = { - actions: Action[] -} - -type ActionMenuProps = { - groups: ActionGroup[] -} - -export const ButtonMenu = ({ - groups, - children, -}: PropsWithChildren) => { - return ( - - {children} - - {groups.map((group, index) => { - if (!group.actions.length) { - return null - } - - const isLast = index === groups.length - 1 - - return ( - - {group.actions.map((action, index) => { - if (action.onClick) { - return ( - { - e.stopPropagation() - action.onClick() - }} - className={clx( - "[&_svg]:text-ui-fg-subtle flex items-center gap-x-2", - { - "[&_svg]:text-ui-fg-disabled": action.disabled, - } - )} - > - {action.icon} - {action.label} - - ) - } - - return ( -
- - e.stopPropagation()}> - {action.icon} - {action.label} - - -
- ) - })} - {!isLast && } -
- ) - })} -
-
- ) -} diff --git a/packages/admin/dashboard/src/components/common/button-menu/index.ts b/packages/admin/dashboard/src/components/common/button-menu/index.ts deleted file mode 100644 index a3a827d6173da..0000000000000 --- a/packages/admin/dashboard/src/components/common/button-menu/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./action-menu" diff --git a/packages/admin/dashboard/src/components/common/date/index.ts b/packages/admin/dashboard/src/components/common/date/index.ts deleted file mode 100644 index 98cb13cafc947..0000000000000 --- a/packages/admin/dashboard/src/components/common/date/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import format from "date-fns/format" - -export function formatDate(date: string | Date) { - const value = new Date(date) - value.setMinutes(value.getMinutes() - value.getTimezoneOffset()) - - const hour12 = Intl.DateTimeFormat().resolvedOptions().hour12 - const timestampFormat = hour12 ? "dd MMM yyyy hh:MM a" : "dd MMM yyyy HH:MM" - - return format(value, timestampFormat) -} diff --git a/packages/admin/dashboard/src/components/forms/address-form/address-form.tsx b/packages/admin/dashboard/src/components/forms/address-form/address-form.tsx index 5485882289143..2dd74890e9bd7 100644 --- a/packages/admin/dashboard/src/components/forms/address-form/address-form.tsx +++ b/packages/admin/dashboard/src/components/forms/address-form/address-form.tsx @@ -2,11 +2,11 @@ import { Heading, Input, Select, clx } from "@medusajs/ui" import { useTranslation } from "react-i18next" import { z } from "zod" +import { HttpTypes } from "@medusajs/types" import { Control } from "react-hook-form" import { AddressSchema } from "../../../lib/schemas" import { Form } from "../../common/form" import { CountrySelect } from "../../inputs/country-select" -import { HttpTypes } from "@medusajs/types" type AddressFieldValues = z.infer @@ -187,14 +187,24 @@ export const AddressForm = ({ - {countries.map((country) => ( - - {country.display_name} - - ))} + {countries.map((country) => { + /** + * If a country does not have an ISO 2 code, it is not + * a valid country and should not be selectable. + */ + if (!country.iso_2) { + return null + } + + return ( + + {country.display_name} + + ) + })} ) : ( diff --git a/packages/admin/dashboard/src/components/forms/metadata-form/metadata-form.tsx b/packages/admin/dashboard/src/components/forms/metadata-form/metadata-form.tsx index c59374f055a13..19bee56d37339 100644 --- a/packages/admin/dashboard/src/components/forms/metadata-form/metadata-form.tsx +++ b/packages/admin/dashboard/src/components/forms/metadata-form/metadata-form.tsx @@ -18,7 +18,7 @@ import { Trash, } from "@medusajs/icons" import { FetchError } from "@medusajs/js-sdk" -import { ComponentPropsWithoutRef, forwardRef, useRef } from "react" +import { ComponentPropsWithoutRef, forwardRef } from "react" import { ConditionalTooltip } from "../../common/conditional-tooltip" import { Form } from "../../common/form" import { InlineTip } from "../../common/inline-tip" @@ -78,7 +78,6 @@ const InnerForm = ({ const { t } = useTranslation() const { handleSuccess } = useRouteModal() - const deletedOriginalRows = useRef([]) const hasUneditableRows = getHasUneditableRows(metadata) const form = useForm>({ diff --git a/packages/admin/dashboard/src/components/table/table-cells/common/created-at-cell/created-at-cell.tsx b/packages/admin/dashboard/src/components/table/table-cells/common/created-at-cell/created-at-cell.tsx index 50b480961b5b4..3b8309055c4d6 100644 --- a/packages/admin/dashboard/src/components/table/table-cells/common/created-at-cell/created-at-cell.tsx +++ b/packages/admin/dashboard/src/components/table/table-cells/common/created-at-cell/created-at-cell.tsx @@ -1,6 +1,6 @@ import { Tooltip } from "@medusajs/ui" -import format from "date-fns/format" import { useTranslation } from "react-i18next" +import { useDate } from "../../../../../hooks/use-date" import { PlaceholderCell } from "../placeholder-cell" type DateCellProps = { @@ -8,28 +8,26 @@ type DateCellProps = { } export const CreatedAtCell = ({ date }: DateCellProps) => { + const { getFullDate } = useDate() + if (!date) { return } - const value = new Date(date) - value.setMinutes(value.getMinutes() - value.getTimezoneOffset()) - - const hour12 = Intl.DateTimeFormat().resolvedOptions().hour12 - const timestampFormat = hour12 ? "dd MMM yyyy hh:MM a" : "dd MMM yyyy HH:MM" - return (
{`${format( - value, - timestampFormat - )}`} + {`${getFullDate({ + date, + includeTime: true, + })}`} } > - {format(value, "dd MMM yyyy")} + + {getFullDate({ date, includeTime: true })} +
) diff --git a/packages/admin/dashboard/src/components/table/table-cells/order/customer-cell/customer-cell.tsx b/packages/admin/dashboard/src/components/table/table-cells/order/customer-cell/customer-cell.tsx index 050aa744b3369..39d5045ac3767 100644 --- a/packages/admin/dashboard/src/components/table/table-cells/order/customer-cell/customer-cell.tsx +++ b/packages/admin/dashboard/src/components/table/table-cells/order/customer-cell/customer-cell.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next" export const CustomerCell = ({ customer, }: { - customer: HttpTypes.AdminCustomer | null + customer?: HttpTypes.AdminCustomer | null }) => { if (!customer) { return - diff --git a/packages/admin/dashboard/src/components/table/table-cells/order/payment-status-cell/payment-status-cell.tsx b/packages/admin/dashboard/src/components/table/table-cells/order/payment-status-cell/payment-status-cell.tsx index 0aacd0ba09e33..01382e098a3e4 100644 --- a/packages/admin/dashboard/src/components/table/table-cells/order/payment-status-cell/payment-status-cell.tsx +++ b/packages/admin/dashboard/src/components/table/table-cells/order/payment-status-cell/payment-status-cell.tsx @@ -1,9 +1,10 @@ +import { HttpTypes } from "@medusajs/types" import { useTranslation } from "react-i18next" import { getOrderPaymentStatus } from "../../../../../lib/order-helpers" import { StatusCell } from "../../common/status-cell" type PaymentStatusCellProps = { - status: PaymentStatus + status: HttpTypes.AdminOrder["payment_status"] } export const PaymentStatusCell = ({ status }: PaymentStatusCellProps) => { diff --git a/packages/admin/dashboard/src/components/table/table-cells/order/sales-channel-cell/sales-channel-cell.tsx b/packages/admin/dashboard/src/components/table/table-cells/order/sales-channel-cell/sales-channel-cell.tsx index c451cb12b27b9..50a3e0f35575e 100644 --- a/packages/admin/dashboard/src/components/table/table-cells/order/sales-channel-cell/sales-channel-cell.tsx +++ b/packages/admin/dashboard/src/components/table/table-cells/order/sales-channel-cell/sales-channel-cell.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next" export const SalesChannelCell = ({ channel, }: { - channel: HttpTypes.AdminSalesChannel | null + channel?: HttpTypes.AdminSalesChannel | null }) => { if (!channel) { return - diff --git a/packages/admin/dashboard/src/components/table/table-cells/region/countries-cell/countries-cell.tsx b/packages/admin/dashboard/src/components/table/table-cells/region/countries-cell/countries-cell.tsx index 22727be4850a8..085f18de819a5 100644 --- a/packages/admin/dashboard/src/components/table/table-cells/region/countries-cell/countries-cell.tsx +++ b/packages/admin/dashboard/src/components/table/table-cells/region/countries-cell/countries-cell.tsx @@ -1,4 +1,4 @@ -import { RegionCountryDTO } from "@medusajs/types" +import { HttpTypes } from "@medusajs/types" import { useTranslation } from "react-i18next" import { countries as COUNTRIES } from "../../../../../lib/data/countries" @@ -6,24 +6,24 @@ import { ListSummary } from "../../../../common/list-summary" import { PlaceholderCell } from "../../common/placeholder-cell" type CountriesCellProps = { - countries?: RegionCountryDTO[] | null + countries?: HttpTypes.AdminRegionCountry[] | null } export const CountriesCell = ({ countries }: CountriesCellProps) => { - const { t } = useTranslation() - if (!countries || countries.length === 0) { return } + const list = countries + .map( + (country) => + COUNTRIES.find((c) => c.iso_2 === country.iso_2)?.display_name + ) + .filter(Boolean) as string[] + return (
- - COUNTRIES.find((c) => c.iso_2 === country.iso_2)!.display_name - )} - /> +
) } diff --git a/packages/admin/dashboard/src/extensions/dashboard-extension-provider/types.ts b/packages/admin/dashboard/src/extensions/dashboard-extension-provider/types.ts deleted file mode 100644 index bb4fc6a93baad..0000000000000 --- a/packages/admin/dashboard/src/extensions/dashboard-extension-provider/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { InjectionZone } from "@medusajs/admin-shared" -import { ComponentType } from "react" -import { LoaderFunction } from "react-router-dom" -import { CustomFieldConfiguration } from "../../extensions/custom-field-registry/types" - -export type RouteExtension = { - Component: ComponentType - loader?: LoaderFunction - path: string -} - -export type MenuItemExtension = { - label: string - path: string - icon?: ComponentType -} - -export type WidgetExtension = { - Component: ComponentType - zone: InjectionZone[] -} - -export type RoutingExtensionConfig = { - routes: RouteExtension[] - menuItems: MenuItemExtension[] -} - -export type DashboardExtensionConfig = { - customFields?: CustomFieldConfiguration - menuItems?: MenuItemExtension[] - widgets?: WidgetExtension[] -} diff --git a/packages/admin/dashboard/src/hooks/api/auth.tsx b/packages/admin/dashboard/src/hooks/api/auth.tsx index 6d053e87d0887..170bfc5b70a19 100644 --- a/packages/admin/dashboard/src/hooks/api/auth.tsx +++ b/packages/admin/dashboard/src/hooks/api/auth.tsx @@ -1,11 +1,14 @@ -import { UseMutationOptions, useMutation } from "@tanstack/react-query" import { FetchError } from "@medusajs/js-sdk" -import { sdk } from "../../lib/client" import { HttpTypes } from "@medusajs/types" +import { UseMutationOptions, useMutation } from "@tanstack/react-query" +import { sdk } from "../../lib/client" export const useSignInWithEmailPass = ( options?: UseMutationOptions< - string, + | string + | { + location: string + }, FetchError, HttpTypes.AdminSignUpWithEmailPassword > diff --git a/packages/admin/dashboard/src/hooks/api/claims.tsx b/packages/admin/dashboard/src/hooks/api/claims.tsx index a191f459d108a..4fb5bbebcc924 100644 --- a/packages/admin/dashboard/src/hooks/api/claims.tsx +++ b/packages/admin/dashboard/src/hooks/api/claims.tsx @@ -208,14 +208,13 @@ export const useAddClaimInboundItems = ( id: string, orderId: string, options?: UseMutationOptions< - HttpTypes.AdminClaimResponse, + HttpTypes.AdminClaimReturnPreviewResponse, FetchError, HttpTypes.AdminAddClaimInboundItems > ) => { return useMutation({ - mutationFn: (payload: HttpTypes.AdminAddClaimInboundItems) => - sdk.admin.claim.addInboundItems(id, payload), + mutationFn: (payload) => sdk.admin.claim.addInboundItems(id, payload), onSuccess: (data: any, variables: any, context: any) => { queryClient.invalidateQueries({ queryKey: ordersQueryKeys.details(), diff --git a/packages/admin/dashboard/src/hooks/api/collections.tsx b/packages/admin/dashboard/src/hooks/api/collections.tsx index 9a6fbded6d65a..49e8aee34836c 100644 --- a/packages/admin/dashboard/src/hooks/api/collections.tsx +++ b/packages/admin/dashboard/src/hooks/api/collections.tsx @@ -37,7 +37,7 @@ export const useCollection = ( } export const useCollections = ( - query?: FindParams & HttpTypes.AdminCollectionFilters, + query?: FindParams & HttpTypes.AdminCollectionListParams, options?: Omit< UseQueryOptions< PaginatedResponse<{ collections: HttpTypes.AdminCollection[] }>, @@ -60,7 +60,7 @@ export const useCollections = ( export const useUpdateCollection = ( id: string, options?: UseMutationOptions< - { collection: HttpTypes.AdminCollection }, + HttpTypes.AdminCollectionResponse, FetchError, HttpTypes.AdminUpdateCollection > @@ -82,7 +82,7 @@ export const useUpdateCollection = ( export const useUpdateCollectionProducts = ( id: string, options?: UseMutationOptions< - { collection: HttpTypes.AdminCollection }, + HttpTypes.AdminCollectionResponse, FetchError, HttpTypes.AdminUpdateCollectionProducts > @@ -110,7 +110,7 @@ export const useUpdateCollectionProducts = ( export const useCreateCollection = ( options?: UseMutationOptions< - { collection: HttpTypes.AdminCollection }, + HttpTypes.AdminCollectionResponse, FetchError, HttpTypes.AdminCreateCollection > diff --git a/packages/admin/dashboard/src/hooks/api/invites.tsx b/packages/admin/dashboard/src/hooks/api/invites.tsx index 17a5f4e135ed6..3edfd86eb72a8 100644 --- a/packages/admin/dashboard/src/hooks/api/invites.tsx +++ b/packages/admin/dashboard/src/hooks/api/invites.tsx @@ -1,8 +1,5 @@ -import { - AdminInviteResponse, - HttpTypes, - PaginatedResponse, -} from "@medusajs/types" +import { FetchError } from "@medusajs/js-sdk" +import { HttpTypes } from "@medusajs/types" import { QueryKey, UseMutationOptions, @@ -13,7 +10,6 @@ import { import { sdk } from "../../lib/client" import { queryClient } from "../../lib/query-client" import { queryKeysFactory } from "../../lib/query-key-factory" -import { FetchError } from "@medusajs/js-sdk" const INVITES_QUERY_KEY = "invites" as const const invitesQueryKeys = queryKeysFactory(INVITES_QUERY_KEY) @@ -22,9 +18,9 @@ export const useInvite = ( id: string, options?: Omit< UseQueryOptions< - { invite: HttpTypes.AdminInviteResponse }, + HttpTypes.AdminInviteResponse, FetchError, - { invite: HttpTypes.AdminInviteResponse }, + HttpTypes.AdminInviteResponse, QueryKey >, "queryFn" | "queryKey" @@ -43,9 +39,9 @@ export const useInvites = ( query?: Record, options?: Omit< UseQueryOptions< - PaginatedResponse<{ invites: HttpTypes.AdminInviteResponse[] }>, + HttpTypes.AdminInviteListResponse, FetchError, - PaginatedResponse<{ invites: HttpTypes.AdminInviteResponse[] }>, + HttpTypes.AdminInviteListResponse, QueryKey >, "queryFn" | "queryKey" @@ -62,7 +58,7 @@ export const useInvites = ( export const useCreateInvite = ( options?: UseMutationOptions< - { invite: AdminInviteResponse }, + HttpTypes.AdminInviteResponse, FetchError, HttpTypes.AdminCreateInvite > @@ -79,11 +75,7 @@ export const useCreateInvite = ( export const useResendInvite = ( id: string, - options?: UseMutationOptions< - { invite: AdminInviteResponse }, - FetchError, - void - > + options?: UseMutationOptions ) => { return useMutation({ mutationFn: () => sdk.admin.invite.resend(id), @@ -118,7 +110,7 @@ export const useDeleteInvite = ( export const useAcceptInvite = ( inviteToken: string, options?: UseMutationOptions< - { user: HttpTypes.AdminUserResponse }, + HttpTypes.AdminAcceptInviteResponse, FetchError, HttpTypes.AdminAcceptInvite & { auth_token: string } > diff --git a/packages/admin/dashboard/src/hooks/api/price-lists.tsx b/packages/admin/dashboard/src/hooks/api/price-lists.tsx index 7c3fd1d43d039..1a4e2ac4fc166 100644 --- a/packages/admin/dashboard/src/hooks/api/price-lists.tsx +++ b/packages/admin/dashboard/src/hooks/api/price-lists.tsx @@ -128,7 +128,7 @@ export const useBatchPriceListPrices = ( id: string, query?: HttpTypes.AdminPriceListParams, options?: UseMutationOptions< - HttpTypes.AdminPriceListResponse, + HttpTypes.AdminPriceListBatchResponse, FetchError, HttpTypes.AdminBatchPriceListPrice > diff --git a/packages/admin/dashboard/src/hooks/table/columns/use-order-table-columns.tsx b/packages/admin/dashboard/src/hooks/table/columns/use-order-table-columns.tsx index e28ced6cab6e4..f26abbc35b8f2 100644 --- a/packages/admin/dashboard/src/hooks/table/columns/use-order-table-columns.tsx +++ b/packages/admin/dashboard/src/hooks/table/columns/use-order-table-columns.tsx @@ -1,3 +1,4 @@ +import { HttpTypes } from "@medusajs/types" import { ColumnDef, ColumnDefBase, @@ -33,7 +34,6 @@ import { TotalCell, TotalHeader, } from "../../../components/table/table-cells/order/total-cell" -import { HttpTypes } from "@medusajs/types" // We have to use any here, as the type of Order is so complex that it lags the TS server const columnHelper = createColumnHelper() diff --git a/packages/admin/dashboard/src/index.ts b/packages/admin/dashboard/src/index.ts deleted file mode 100644 index 18000e720893e..0000000000000 --- a/packages/admin/dashboard/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./render" diff --git a/packages/admin/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/order-edit-items-section.tsx b/packages/admin/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/order-edit-items-section.tsx index 1f212308174bb..e2a253475d67c 100644 --- a/packages/admin/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/order-edit-items-section.tsx +++ b/packages/admin/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/order-edit-items-section.tsx @@ -1,15 +1,15 @@ -import { useMemo, useState } from "react" import { AdminOrder, AdminOrderPreview } from "@medusajs/types" import { Button, Heading, Input, toast } from "@medusajs/ui" +import { useMemo, useState } from "react" import { useTranslation } from "react-i18next" import { RouteFocusModal, StackedFocusModal, useStackedModal, } from "../../../../../components/modals" +import { useAddOrderEditItems } from "../../../../../hooks/api/order-edits" import { AddOrderEditItemsTable } from "../add-order-edit-items-table" import { OrderEditItem } from "./order-edit-item" -import { useAddOrderEditItems } from "../../../../../hooks/api/order-edits" type ExchangeInboundSectionProps = { order: AdminOrder @@ -39,16 +39,19 @@ export const OrderEditItemsSection = ({ * CALLBACKS */ const onItemsSelected = async () => { - try { - await addItems({ + await addItems( + { items: addedVariants.map((i) => ({ variant_id: i, quantity: 1, })), - }) - } catch (e) { - toast.error(e.message) - } + }, + { + onError: (e) => { + toast.error(e.message) + }, + } + ) setIsOpen("inbound-items", false) } diff --git a/packages/admin/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx b/packages/admin/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx index 06432bcd3dbd7..c48a14611fbc0 100644 --- a/packages/admin/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx +++ b/packages/admin/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx @@ -38,7 +38,6 @@ import { import { AdminReservation } from "@medusajs/types/src/http" import { AdminPaymentCollection } from "../../../../../../../../core/types/dist/http/payment/admin/entities" import { ActionMenu } from "../../../../../components/common/action-menu" -import { ButtonMenu } from "../../../../../components/common/button-menu/button-menu" import { Thumbnail } from "../../../../../components/common/thumbnail" import { useClaims } from "../../../../../hooks/api/claims" import { useExchanges } from "../../../../../hooks/api/exchanges" @@ -193,7 +192,7 @@ export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => { {t("orders.returns.receive.action")} ) : ( - { @@ -225,7 +224,7 @@ export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => { - + ))} {showAllocateButton && ( diff --git a/packages/admin/dashboard/src/routes/orders/order-detail/components/order-summary-section/return-info-popover.tsx b/packages/admin/dashboard/src/routes/orders/order-detail/components/order-summary-section/return-info-popover.tsx index c73b4107bbf0e..92dc670ceb69f 100644 --- a/packages/admin/dashboard/src/routes/orders/order-detail/components/order-summary-section/return-info-popover.tsx +++ b/packages/admin/dashboard/src/routes/orders/order-detail/components/order-summary-section/return-info-popover.tsx @@ -3,7 +3,7 @@ import { AdminReturn } from "@medusajs/types" import { Badge, Popover, Text } from "@medusajs/ui" import { useState } from "react" import { useTranslation } from "react-i18next" -import { formatDate } from "../../../../../components/common/date" +import { useDate } from "../../../../../hooks/use-date" type ReturnInfoPopoverProps = { orderReturn: AdminReturn @@ -13,6 +13,8 @@ function ReturnInfoPopover({ orderReturn }: ReturnInfoPopoverProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) + const { getFullDate } = useDate() + const handleMouseEnter = () => { setOpen(true) } @@ -44,7 +46,7 @@ function ReturnInfoPopover({ orderReturn }: ReturnInfoPopoverProps) { onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} autoFocus={false} - className="focus-visible:outline-none align-sub" + className="align-sub focus-visible:outline-none" > @@ -52,7 +54,7 @@ function ReturnInfoPopover({ orderReturn }: ReturnInfoPopoverProps) {
@@ -64,7 +66,7 @@ function ReturnInfoPopover({ orderReturn }: ReturnInfoPopoverProps) { {t(`orders.returns.returnRequested`)} {" · "} - {formatDate(orderReturn.requested_at)} + {getFullDate({ date: orderReturn.requested_at, includeTime: true })} @@ -73,7 +75,10 @@ function ReturnInfoPopover({ orderReturn }: ReturnInfoPopoverProps) { {" · "} {orderReturn.received_at - ? formatDate(orderReturn.received_at) + ? getFullDate({ + date: orderReturn.received_at, + includeTime: true, + }) : "-"}
diff --git a/packages/admin/dashboard/src/routes/tax-regions/common/hooks.ts b/packages/admin/dashboard/src/routes/tax-regions/common/hooks.ts index 0f519a09041ce..1caaa514f8b36 100644 --- a/packages/admin/dashboard/src/routes/tax-regions/common/hooks.ts +++ b/packages/admin/dashboard/src/routes/tax-regions/common/hooks.ts @@ -32,20 +32,12 @@ export const useDeleteTaxRegionAction = ({ await mutateAsync(undefined, { onSuccess: () => { - toast.success(t("general.success"), { - description: t("taxRegions.delete.successToast"), - dismissable: true, - dismissLabel: t("actions.close"), - }) + toast.success(t("taxRegions.delete.successToast")) navigate(to, { replace: true }) }, onError: (e) => { - toast.error(t("general.error"), { - description: e.message, - dismissable: true, - dismissLabel: t("actions.close"), - }) + toast.error(e.message) }, }) } diff --git a/packages/admin/dashboard/src/routes/users/user-edit/components/edit-user-form/edit-user-form.tsx b/packages/admin/dashboard/src/routes/users/user-edit/components/edit-user-form/edit-user-form.tsx index 7ee591bc17bd1..d448fe6e29900 100644 --- a/packages/admin/dashboard/src/routes/users/user-edit/components/edit-user-form/edit-user-form.tsx +++ b/packages/admin/dashboard/src/routes/users/user-edit/components/edit-user-form/edit-user-form.tsx @@ -4,14 +4,14 @@ import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" import * as zod from "zod" -import { UserDTO } from "@medusajs/types" +import { HttpTypes } from "@medusajs/types" import { Form } from "../../../../../components/common/form" import { RouteDrawer, useRouteModal } from "../../../../../components/modals" import { KeyboundForm } from "../../../../../components/utilities/keybound-form" import { useUpdateUser } from "../../../../../hooks/api/users" type EditUserFormProps = { - user: UserDTO + user: HttpTypes.AdminUser } const EditUserFormSchema = zod.object({ diff --git a/packages/admin/dashboard/src/routes/users/user-invite/components/invite-user-form/invite-user-form.tsx b/packages/admin/dashboard/src/routes/users/user-invite/components/invite-user-form/invite-user-form.tsx index 61622ec318b8a..136d5b420f6b9 100644 --- a/packages/admin/dashboard/src/routes/users/user-invite/components/invite-user-form/invite-user-form.tsx +++ b/packages/admin/dashboard/src/routes/users/user-invite/components/invite-user-form/invite-user-form.tsx @@ -1,6 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod" import { ArrowPath, Link, Trash } from "@medusajs/icons" -import { InviteDTO } from "@medusajs/types" +import { HttpTypes } from "@medusajs/types" import { Alert, Button, @@ -70,7 +70,7 @@ export const InviteUserForm = () => { const columns = useColumns() const { table } = useDataTable({ - data: (invites ?? []) as InviteDTO[], + data: invites ?? [], columns, count, enablePagination: true, @@ -185,7 +185,7 @@ export const InviteUserForm = () => { ) } -const InviteActions = ({ invite }: { invite: InviteDTO }) => { +const InviteActions = ({ invite }: { invite: HttpTypes.AdminInvite }) => { const { mutateAsync: revokeAsync } = useDeleteInvite(invite.id) const { mutateAsync: resendAsync } = useResendInvite(invite.id) @@ -253,7 +253,7 @@ const InviteActions = ({ invite }: { invite: InviteDTO }) => { ) } -const columnHelper = createColumnHelper() +const columnHelper = createColumnHelper() const useColumns = () => { const { t } = useTranslation() diff --git a/packages/admin/dashboard/src/routes/users/user-metadata/user-metadata.tsx b/packages/admin/dashboard/src/routes/users/user-metadata/user-metadata.tsx index 6c64330f1ea13..e88f49e798898 100644 --- a/packages/admin/dashboard/src/routes/users/user-metadata/user-metadata.tsx +++ b/packages/admin/dashboard/src/routes/users/user-metadata/user-metadata.tsx @@ -7,8 +7,8 @@ import { useUpdateUser, useUser } from "../../../hooks/api" export const UserMetadata = () => { const { id } = useParams() - const { user, isPending, isError, error } = useUser(id) - const { mutateAsync, isPending: isMutating } = useUpdateUser(id) + const { user, isPending, isError, error } = useUser(id!) + const { mutateAsync, isPending: isMutating } = useUpdateUser(id!) if (isError) { throw error diff --git a/packages/core/types/src/http/invite/admin/entities.ts b/packages/core/types/src/http/invite/admin/entities.ts index 10dc03dfcc1e0..35cbba36b29a1 100644 --- a/packages/core/types/src/http/invite/admin/entities.ts +++ b/packages/core/types/src/http/invite/admin/entities.ts @@ -18,7 +18,7 @@ export interface AdminInvite { /** * The date the invite expires. */ - expires_at?: Date + expires_at: string /** * Key-value pairs of custom data. */ @@ -26,9 +26,9 @@ export interface AdminInvite { /** * The date that the invite was created. */ - created_at?: Date + created_at: string /** * The date that the invite was updated. */ - updated_at?: Date + updated_at: string } From f95c4e240c4a5ca0fb88a09636c3d8a2266de279 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Sun, 8 Dec 2024 13:13:31 +0100 Subject: [PATCH 12/13] fix(promotion, core-flows): updating cart with removed promotion removes adjustments (#10489) --- .changeset/empty-trees-walk.md | 6 ++ .../http/__tests__/cart/store/cart.spec.ts | 46 ++++++++++++++ .../src/cart/workflows/refresh-cart-items.ts | 15 +++-- .../promotion-module/compute-actions.spec.ts | 62 ------------------- .../src/services/promotion-module.ts | 9 +-- 5 files changed, 62 insertions(+), 76 deletions(-) create mode 100644 .changeset/empty-trees-walk.md diff --git a/.changeset/empty-trees-walk.md b/.changeset/empty-trees-walk.md new file mode 100644 index 0000000000000..3345800c5cee6 --- /dev/null +++ b/.changeset/empty-trees-walk.md @@ -0,0 +1,6 @@ +--- +"@medusajs/promotion": patch +"@medusajs/core-flows": patch +--- + +fix(promotion, core-flows): updating cart with removed promotion removes adjustments diff --git a/integration-tests/http/__tests__/cart/store/cart.spec.ts b/integration-tests/http/__tests__/cart/store/cart.spec.ts index c82ebaaaff5b5..0a5d36472ba00 100644 --- a/integration-tests/http/__tests__/cart/store/cart.spec.ts +++ b/integration-tests/http/__tests__/cart/store/cart.spec.ts @@ -1344,6 +1344,52 @@ medusaIntegrationTestRunner({ }) ) }) + + it("should remove promotion adjustments when promotion is deleted", async () => { + let cartBeforeRemovingPromotion = ( + await api.get(`/store/carts/${cart.id}`, storeHeaders) + ).data.cart + + expect(cartBeforeRemovingPromotion).toEqual( + expect.objectContaining({ + id: cart.id, + items: expect.arrayContaining([ + expect.objectContaining({ + adjustments: [ + { + id: expect.any(String), + code: "PROMOTION_APPLIED", + promotion_id: promotion.id, + amount: 100, + }, + ], + }), + ]), + }) + ) + + await api.delete(`/admin/promotions/${promotion.id}`, adminHeaders) + + let response = await api.post( + `/store/carts/${cart.id}`, + { + email: "test@test.com", + }, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + items: expect.arrayContaining([ + expect.objectContaining({ + adjustments: [], + }), + ]), + }) + ) + }) }) describe("POST /store/carts/:id/customer", () => { diff --git a/packages/core/core-flows/src/cart/workflows/refresh-cart-items.ts b/packages/core/core-flows/src/cart/workflows/refresh-cart-items.ts index 6a90feb1eca9c..93c4eadbe02a8 100644 --- a/packages/core/core-flows/src/cart/workflows/refresh-cart-items.ts +++ b/packages/core/core-flows/src/cart/workflows/refresh-cart-items.ts @@ -106,13 +106,16 @@ export const refreshCartItemsWorkflow = createWorkflow( input: { cart_id: cart.id }, }) - const cartPromoCodes = transform({ cart, input }, ({ cart, input }) => { - if (isDefined(input.promo_codes)) { - return input.promo_codes - } else { - return cart.promotions.map((p) => p.code) + const cartPromoCodes = transform( + { refetchedCart, input }, + ({ refetchedCart, input }) => { + if (isDefined(input.promo_codes)) { + return input.promo_codes + } else { + return refetchedCart.promotions.map((p) => p?.code).filter(Boolean) + } } - }) + ) updateCartPromotionsWorkflow.runAsStep({ input: { diff --git a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts index 63f57081d3738..d839968794146 100644 --- a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts +++ b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts @@ -57,68 +57,6 @@ moduleIntegrationTestRunner({ expect(response).toEqual([]) }) - - it("should throw error when code in items adjustment does not exist", async () => { - await createDefaultPromotion(service, {}) - - const error = await service - .computeActions(["PROMOTION_TEST"], { - items: [ - { - id: "item_cotton_tshirt", - quantity: 1, - subtotal: 100, - adjustments: [ - { - id: "test-adjustment", - code: "DOES_NOT_EXIST", - }, - ], - }, - { - id: "item_cotton_sweater", - quantity: 5, - subtotal: 750, - }, - ], - }) - .catch((e) => e) - - expect(error.message).toContain( - "Applied Promotion for code (DOES_NOT_EXIST) not found" - ) - }) - - it("should throw error when code in shipping adjustment does not exist", async () => { - await createDefaultPromotion(service, {}) - - const error = await service - .computeActions(["PROMOTION_TEST"], { - items: [ - { - id: "item_cotton_tshirt", - quantity: 1, - subtotal: 100, - }, - { - id: "item_cotton_sweater", - quantity: 5, - subtotal: 750, - adjustments: [ - { - id: "test-adjustment", - code: "DOES_NOT_EXIST", - }, - ], - }, - ], - }) - .catch((e) => e) - - expect(error.message).toContain( - "Applied Promotion for code (DOES_NOT_EXIST) not found" - ) - }) }) describe("when promotion is for items and allocation is each", () => { diff --git a/packages/modules/promotion/src/services/promotion-module.ts b/packages/modules/promotion/src/services/promotion-module.ts index 5d3afdf86a50c..8ca859f3080cd 100644 --- a/packages/modules/promotion/src/services/promotion-module.ts +++ b/packages/modules/promotion/src/services/promotion-module.ts @@ -397,6 +397,7 @@ export default class PromotionModuleService ], }, { + take: null, relations: [ "application_method", "application_method.target_rules", @@ -421,19 +422,11 @@ export default class PromotionModuleService const appliedCodes = [...appliedShippingCodes, ...appliedItemCodes] for (const appliedCode of appliedCodes) { - const promotion = existingPromotionsMap.get(appliedCode) const adjustments = codeAdjustmentMap.get(appliedCode) || [] const action = appliedShippingCodes.includes(appliedCode) ? ComputedActions.REMOVE_SHIPPING_METHOD_ADJUSTMENT : ComputedActions.REMOVE_ITEM_ADJUSTMENT - if (!promotion) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Applied Promotion for code (${appliedCode}) not found` - ) - } - adjustments.forEach((adjustment) => computedActions.push({ action, From 9e797dc3d2d14fec9b878ab8ca38d8337a778b4a Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Sun, 8 Dec 2024 14:06:50 +0100 Subject: [PATCH 13/13] feat(core-flows, types): update shipping methods upon cart ops (#10382) * feat(core-flows,framework,medusa): list shipping options pass in cart as pricing context * chore: add test for shipping options returning free shipping * feat(core-flows, types): update shipping methods upon cart ops * chore: fix specs * chore: fix bugs + specs * Update update-shipping-methods.ts Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> * Update mutations.ts Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> * chore: undo refresh changes * chore: merge with latest * chore: address PR comments * chore: fix conflicts * chore: fix specs * chore: address reviews --------- Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> --- .../http/__tests__/cart/store/cart.spec.ts | 411 ++++++++ .../admin/shipping-option.spec.ts | 20 +- .../store/shipping-option.spec.ts | 131 ++- .../cart/store/cart.workflows.spec.ts | 931 +++++++----------- .../__tests__/cart/store/carts.spec.ts | 312 ------ .../core/core-flows/src/cart/steps/index.ts | 2 - .../steps/refresh-cart-shipping-methods.ts | 73 -- .../steps/remove-shipping-method-from-cart.ts | 6 +- .../src/cart/steps/update-shipping-methods.ts | 43 + .../steps/validate-shipping-options-price.ts | 37 + .../core/core-flows/src/cart/utils/fields.ts | 1 + .../workflows/add-shipping-method-to-cart.ts | 32 +- .../src/cart/workflows/add-to-cart.ts | 54 +- .../list-shipping-options-for-cart.ts | 121 +-- .../src/cart/workflows/refresh-cart-items.ts | 7 +- .../refresh-cart-shipping-methods.ts | 125 +++ .../workflows/update-line-item-in-cart.ts | 47 +- packages/core/types/src/cart/mutations.ts | 5 + packages/core/types/src/cart/service.ts | 41 + .../api/admin/shipping-options/validators.ts | 2 +- .../shipping-option.spec.ts | 2 +- 21 files changed, 1164 insertions(+), 1239 deletions(-) delete mode 100644 packages/core/core-flows/src/cart/steps/refresh-cart-shipping-methods.ts create mode 100644 packages/core/core-flows/src/cart/steps/update-shipping-methods.ts create mode 100644 packages/core/core-flows/src/cart/steps/validate-shipping-options-price.ts create mode 100644 packages/core/core-flows/src/cart/workflows/refresh-cart-shipping-methods.ts diff --git a/integration-tests/http/__tests__/cart/store/cart.spec.ts b/integration-tests/http/__tests__/cart/store/cart.spec.ts index 0a5d36472ba00..6005a7f6d5de1 100644 --- a/integration-tests/http/__tests__/cart/store/cart.spec.ts +++ b/integration-tests/http/__tests__/cart/store/cart.spec.ts @@ -302,7 +302,107 @@ medusaIntegrationTestRunner({ }) describe("POST /store/carts/:id/line-items", () => { + let shippingOption + beforeEach(async () => { + const stockLocation = ( + await api.post( + `/admin/stock-locations`, + { name: "test location" }, + adminHeaders + ) + ).data.stock_location + + await api.post( + `/admin/stock-locations/${stockLocation.id}/sales-channels`, + { add: [salesChannel.id] }, + adminHeaders + ) + + const shippingProfile = ( + await api.post( + `/admin/shipping-profiles`, + { name: `test-${stockLocation.id}`, type: "default" }, + adminHeaders + ) + ).data.shipping_profile + + const fulfillmentSets = ( + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`, + { + name: `Test-${shippingProfile.id}`, + type: "test-type", + }, + adminHeaders + ) + ).data.stock_location.fulfillment_sets + + const fulfillmentSet = ( + await api.post( + `/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`, + { + name: `Test-${shippingProfile.id}`, + geo_zones: [ + { type: "country", country_code: "it" }, + { type: "country", country_code: "us" }, + ], + }, + adminHeaders + ) + ).data.fulfillment_set + + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-providers`, + { add: ["manual_test-provider"] }, + adminHeaders + ) + + shippingOption = ( + await api.post( + `/admin/shipping-options`, + { + name: `Shipping`, + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: "manual_test-provider", + price_type: "flat", + type: { + label: "Test type", + description: "Test description", + code: "test-code", + }, + prices: [ + { currency_code: "usd", amount: 1000 }, + { + currency_code: "usd", + amount: 0, + rules: [ + { + attribute: "item_total", + operator: "gt", + value: 5000, + }, + ], + }, + ], + rules: [ + { + attribute: "enabled_in_store", + value: '"true"', + operator: "eq", + }, + { + attribute: "is_return", + value: "false", + operator: "eq", + }, + ], + }, + adminHeaders + ) + ).data.shipping_option + cart = ( await api.post( `/store/carts`, @@ -362,6 +462,101 @@ medusaIntegrationTestRunner({ ) }) + describe("with custom shipping options prices", () => { + beforeEach(async () => { + cart = ( + await api.post( + `/store/carts`, + { + currency_code: "usd", + sales_channel_id: salesChannel.id, + region_id: region.id, + items: [{ variant_id: product.variants[0].id, quantity: 1 }], + promo_codes: [promotion.code], + }, + storeHeadersWithCustomer + ) + ).data.cart + }) + + it("should update shipping method amount when cart totals change", async () => { + let response = await api.post( + `/store/carts/${cart.id}/shipping-methods`, + { option_id: shippingOption.id }, + storeHeaders + ) + + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + shipping_methods: expect.arrayContaining([ + expect.objectContaining({ + shipping_option_id: shippingOption.id, + amount: 1000, + is_tax_inclusive: true, + }), + ]), + }) + ) + + response = await api.post( + `/store/carts/${cart.id}/line-items`, + { + variant_id: product.variants[0].id, + quantity: 100, + }, + storeHeaders + ) + + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + shipping_methods: expect.arrayContaining([ + expect.objectContaining({ + shipping_option_id: shippingOption.id, + amount: 0, + is_tax_inclusive: true, + }), + ]), + }) + ) + }) + + it("should remove shipping methods when they are no longer valid for the cart", async () => { + let response = await api.post( + `/store/carts/${cart.id}/shipping-methods`, + { option_id: shippingOption.id }, + storeHeaders + ) + + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + shipping_methods: expect.arrayContaining([ + expect.objectContaining({ + shipping_option_id: shippingOption.id, + amount: 1000, + is_tax_inclusive: true, + }), + ]), + }) + ) + + response = await api.post( + `/store/carts/${cart.id}`, + { region_id: noAutomaticRegion.id }, + storeHeaders + ) + + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + shipping_methods: expect.arrayContaining([]), + }) + ) + }) + }) + it("should add item to cart with tax lines multiple times", async () => { let response = await api.post( `/store/carts/${cart.id}/line-items`, @@ -1529,6 +1724,222 @@ medusaIntegrationTestRunner({ ) }) }) + + describe("POST /store/carts/:id/shipping-methods", () => { + let shippingOption + + beforeEach(async () => { + const stockLocation = ( + await api.post( + `/admin/stock-locations`, + { name: "test location" }, + adminHeaders + ) + ).data.stock_location + + await api.post( + `/admin/stock-locations/${stockLocation.id}/sales-channels`, + { add: [salesChannel.id] }, + adminHeaders + ) + + const shippingProfile = ( + await api.post( + `/admin/shipping-profiles`, + { name: `test-${stockLocation.id}`, type: "default" }, + adminHeaders + ) + ).data.shipping_profile + + const fulfillmentSets = ( + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`, + { + name: `Test-${shippingProfile.id}`, + type: "test-type", + }, + adminHeaders + ) + ).data.stock_location.fulfillment_sets + + const fulfillmentSet = ( + await api.post( + `/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`, + { + name: `Test-${shippingProfile.id}`, + geo_zones: [ + { type: "country", country_code: "it" }, + { type: "country", country_code: "us" }, + ], + }, + adminHeaders + ) + ).data.fulfillment_set + + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-providers`, + { add: ["manual_test-provider"] }, + adminHeaders + ) + + shippingOption = ( + await api.post( + `/admin/shipping-options`, + { + name: `Test shipping option ${fulfillmentSet.id}`, + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: "manual_test-provider", + price_type: "flat", + type: { + label: "Test type", + description: "Test description", + code: "test-code", + }, + prices: [ + { currency_code: "usd", amount: 1000 }, + { + currency_code: "usd", + amount: 500, + rules: [ + { + attribute: "item_total", + operator: "gt", + value: 3000, + }, + ], + }, + ], + rules: [ + { + attribute: "enabled_in_store", + value: '"true"', + operator: "eq", + }, + { + attribute: "is_return", + value: "false", + operator: "eq", + }, + ], + }, + adminHeaders + ) + ).data.shipping_option + + cart = ( + await api.post( + `/store/carts?fields=+total`, + { + currency_code: "usd", + sales_channel_id: salesChannel.id, + region_id: region.id, + items: [{ variant_id: product.variants[0].id, quantity: 1 }], + }, + storeHeadersWithCustomer + ) + ).data.cart + }) + + it("should add shipping method to cart", async () => { + let response = await api.post( + `/store/carts/${cart.id}/shipping-methods`, + { option_id: shippingOption.id }, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + shipping_methods: expect.arrayContaining([ + expect.objectContaining({ + shipping_option_id: shippingOption.id, + amount: 1000, + is_tax_inclusive: true, + }), + ]), + }) + ) + + // Total is over the amount 3000 to enable the second pricing rule + const cart2 = ( + await api.post( + `/store/carts?fields=+total`, + { + currency_code: "usd", + sales_channel_id: salesChannel.id, + region_id: region.id, + items: [{ variant_id: product.variants[0].id, quantity: 5 }], + }, + storeHeadersWithCustomer + ) + ).data.cart + + response = await api.post( + `/store/carts/${cart2.id}/shipping-methods`, + { option_id: shippingOption.id }, + storeHeaders + ) + + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: cart2.id, + shipping_methods: expect.arrayContaining([ + expect.objectContaining({ + shipping_option_id: shippingOption.id, + amount: 500, + is_tax_inclusive: true, + }), + ]), + }) + ) + }) + + it("should throw when prices are not setup for shipping option", async () => { + cart = ( + await api.post( + `/store/carts?fields=+total`, + { + currency_code: "eur", + sales_channel_id: salesChannel.id, + region_id: region.id, + items: [{ variant_id: product.variants[0].id, quantity: 5 }], + }, + storeHeadersWithCustomer + ) + ).data.cart + + let { response } = await api + .post( + `/store/carts/${cart.id}/shipping-methods`, + { option_id: shippingOption.id }, + storeHeaders + ) + .catch((e) => e) + + expect(response.data).toEqual({ + type: "invalid_data", + message: `Shipping options with IDs ${shippingOption.id} do not have a price`, + }) + }) + + it("should throw when shipping option id is not found", async () => { + let { response } = await api + .post( + `/store/carts/${cart.id}/shipping-methods`, + { option_id: "does-not-exist" }, + storeHeaders + ) + .catch((e) => e) + + expect(response.status).toEqual(400) + expect(response.data).toEqual({ + type: "invalid_data", + message: "Shipping Options are invalid for cart.", + }) + }) + }) }) }, }) diff --git a/integration-tests/http/__tests__/shipping-option/admin/shipping-option.spec.ts b/integration-tests/http/__tests__/shipping-option/admin/shipping-option.spec.ts index 19644d9e8de89..8907c3563ec1a 100644 --- a/integration-tests/http/__tests__/shipping-option/admin/shipping-option.spec.ts +++ b/integration-tests/http/__tests__/shipping-option/admin/shipping-option.spec.ts @@ -152,12 +152,12 @@ medusaIntegrationTestRunner({ amount: 500, rules: [ { - attribute: "total", + attribute: "item_total", operator: "gte", value: 100, }, { - attribute: "total", + attribute: "item_total", operator: "lte", value: 200, }, @@ -220,12 +220,12 @@ medusaIntegrationTestRunner({ rules_count: 2, price_rules: expect.arrayContaining([ expect.objectContaining({ - attribute: "total", + attribute: "item_total", operator: "gte", value: "100", }), expect.objectContaining({ - attribute: "total", + attribute: "item_total", operator: "lte", value: "200", }), @@ -329,7 +329,7 @@ medusaIntegrationTestRunner({ amount: 500, rules: [ { - attribute: "total", + attribute: "item_total", operator: "gt", value: 200, }, @@ -380,7 +380,7 @@ medusaIntegrationTestRunner({ rules_count: 2, price_rules: expect.arrayContaining([ expect.objectContaining({ - attribute: "total", + attribute: "item_total", operator: "gt", value: "200", }), @@ -460,7 +460,7 @@ medusaIntegrationTestRunner({ amount: 500, rules: [ { - attribute: "total", + attribute: "item_total", operator: "not_whitelisted", value: 100, }, @@ -498,7 +498,7 @@ medusaIntegrationTestRunner({ amount: 500, rules: [ { - attribute: "total", + attribute: "item_total", operator: "gt", value: "string", }, @@ -628,7 +628,7 @@ medusaIntegrationTestRunner({ amount: 5, rules: [ { - attribute: "total", + attribute: "item_total", operator: "gt", value: 200, }, @@ -704,7 +704,7 @@ medusaIntegrationTestRunner({ amount: 5, price_rules: [ expect.objectContaining({ - attribute: "total", + attribute: "item_total", operator: "gt", value: "200", }), diff --git a/integration-tests/http/__tests__/shipping-option/store/shipping-option.spec.ts b/integration-tests/http/__tests__/shipping-option/store/shipping-option.spec.ts index 5c6e49a39417a..9261db2b2d224 100644 --- a/integration-tests/http/__tests__/shipping-option/store/shipping-option.spec.ts +++ b/integration-tests/http/__tests__/shipping-option/store/shipping-option.spec.ts @@ -1,9 +1,4 @@ import { medusaIntegrationTestRunner } from "@medusajs/test-utils" -import { - IFulfillmentModuleService, - IRegionModuleService, -} from "@medusajs/types" -import { ContainerRegistrationKeys, Modules } from "@medusajs/utils" import { createAdminUser, generatePublishableKey, @@ -20,9 +15,6 @@ medusaIntegrationTestRunner({ testSuite: ({ dbConnection, getContainer, api }) => { describe("Store: Shipping Option API", () => { let appContainer - let fulfillmentModule: IFulfillmentModuleService - let regionService: IRegionModuleService - let salesChannel let region let regionTwo @@ -36,8 +28,6 @@ medusaIntegrationTestRunner({ beforeAll(async () => { appContainer = getContainer() - fulfillmentModule = appContainer.resolve(Modules.FULFILLMENT) - regionService = appContainer.resolve(Modules.REGION) }) beforeEach(async () => { @@ -45,31 +35,27 @@ medusaIntegrationTestRunner({ storeHeaders = generateStoreHeaders({ publishableKey }) await createAdminUser(dbConnection, adminHeaders, appContainer) - const remoteLinkService = appContainer.resolve( - ContainerRegistrationKeys.REMOTE_LINK - ) - region = await regionService.createRegions({ - name: "Test region", - countries: ["US"], - currency_code: "usd", - }) - - regionTwo = await regionService.createRegions({ - name: "Test region two", - countries: ["DK"], - currency_code: "dkk", - }) + region = ( + await api.post( + "/admin/regions", + { name: "US", currency_code: "usd", countries: ["US"] }, + adminHeaders + ) + ).data.region - await api.post( - "/admin/price-preferences", - { - attribute: "region_id", - value: regionTwo.id, - is_tax_inclusive: true, - }, - adminHeaders - ) + regionTwo = ( + await api.post( + "/admin/regions", + { + name: "Test region two", + currency_code: "dkk", + countries: ["DK"], + is_tax_inclusive: true, + }, + adminHeaders + ) + ).data.region salesChannel = ( await api.post( @@ -116,22 +102,39 @@ medusaIntegrationTestRunner({ stockLocation = ( await api.post( `/admin/stock-locations`, - { - name: "test location", - }, + { name: "test location" }, adminHeaders ) ).data.stock_location - shippingProfile = await fulfillmentModule.createShippingProfiles({ - name: "Test", - type: "default", - }) + await api.post( + `/admin/stock-locations/${stockLocation.id}/sales-channels`, + { add: [salesChannel.id] }, + adminHeaders + ) - fulfillmentSet = await fulfillmentModule.createFulfillmentSets({ - name: "Test", - type: "test-type", - service_zones: [ + shippingProfile = ( + await api.post( + `/admin/shipping-profiles`, + { name: "Test", type: "default" }, + adminHeaders + ) + ).data.shipping_profile + + const fulfillmentSets = ( + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`, + { + name: "Test", + type: "test-type", + }, + adminHeaders + ) + ).data.stock_location.fulfillment_sets + + fulfillmentSet = ( + await api.post( + `/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`, { name: "Test", geo_zones: [ @@ -139,27 +142,9 @@ medusaIntegrationTestRunner({ { type: "country", country_code: "dk" }, ], }, - ], - }) - - await remoteLinkService.create([ - { - [Modules.SALES_CHANNEL]: { - sales_channel_id: salesChannel.id, - }, - [Modules.STOCK_LOCATION]: { - stock_location_id: stockLocation.id, - }, - }, - { - [Modules.STOCK_LOCATION]: { - stock_location_id: stockLocation.id, - }, - [Modules.FULFILLMENT]: { - fulfillment_set_id: fulfillmentSet.id, - }, - }, - ]) + adminHeaders + ) + ).data.fulfillment_set await api.post( `/admin/stock-locations/${stockLocation.id}/fulfillment-providers`, @@ -196,7 +181,7 @@ medusaIntegrationTestRunner({ rules: [ { operator: "gt", - attribute: "total", + attribute: "item_total", value: 2000, }, ], @@ -246,8 +231,11 @@ medusaIntegrationTestRunner({ expect.objectContaining({ id: shippingOption.id, name: "Test shipping option", - amount: 1100, price_type: "flat", + amount: 1100, + calculated_price: expect.objectContaining({ + calculated_amount: 1100, + }), }) ) @@ -272,8 +260,12 @@ medusaIntegrationTestRunner({ id: shippingOption.id, name: "Test shipping option", amount: 500, - price_type: "flat", is_tax_inclusive: true, + calculated_price: expect.objectContaining({ + calculated_amount: 500, + is_calculated_price_tax_inclusive: true, + }), + price_type: "flat", }) ) }) @@ -313,6 +305,9 @@ medusaIntegrationTestRunner({ name: "Test shipping option", // Free shipping due to cart total being greater than 2000 amount: 0, + calculated_price: expect.objectContaining({ + calculated_amount: 0, + }), price_type: "flat", }) ) diff --git a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts index 78f66698d01bc..f9a3ebb560e4b 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -34,8 +34,11 @@ import { import { adminHeaders, createAdminUser, + generatePublishableKey, + generateStoreHeaders, } from "../../../../helpers/create-admin-user" import { seedStorefrontDefaults } from "../../../../helpers/seed-storefront-defaults" +import { createAuthenticatedCustomer } from "../../../helpers/create-authenticated-customer" jest.setTimeout(200000) @@ -56,9 +59,10 @@ medusaIntegrationTestRunner({ let stockLocationModule: IStockLocationService let inventoryModule: IInventoryService let fulfillmentModule: IFulfillmentModuleService - let remoteLink, remoteQuery - + let remoteLink, remoteQuery, storeHeaders + let salesChannel let defaultRegion + let customer, storeHeadersWithCustomer beforeAll(async () => { appContainer = getContainer() @@ -69,9 +73,9 @@ medusaIntegrationTestRunner({ productModule = appContainer.resolve(Modules.PRODUCT) pricingModule = appContainer.resolve(Modules.PRICING) paymentModule = appContainer.resolve(Modules.PAYMENT) + fulfillmentModule = appContainer.resolve(Modules.FULFILLMENT) inventoryModule = appContainer.resolve(Modules.INVENTORY) stockLocationModule = appContainer.resolve(Modules.STOCK_LOCATION) - fulfillmentModule = appContainer.resolve(Modules.FULFILLMENT) remoteLink = appContainer.resolve(ContainerRegistrationKeys.REMOTE_LINK) remoteQuery = appContainer.resolve( ContainerRegistrationKeys.REMOTE_QUERY @@ -79,11 +83,35 @@ medusaIntegrationTestRunner({ }) beforeEach(async () => { + const publishableKey = await generatePublishableKey(appContainer) + storeHeaders = generateStoreHeaders({ publishableKey }) await createAdminUser(dbConnection, adminHeaders, appContainer) + const result = await createAuthenticatedCustomer(api, storeHeaders, { + first_name: "tony", + last_name: "stark", + email: "tony@test-industries.com", + }) + + customer = result.customer + storeHeadersWithCustomer = { + headers: { + ...storeHeaders.headers, + authorization: `Bearer ${result.jwt}`, + }, + } + const { region } = await seedStorefrontDefaults(appContainer, "dkk") defaultRegion = region + + salesChannel = ( + await api.post( + "/admin/sales-channels", + { name: "test sales channel", description: "channel" }, + adminHeaders + ) + ).data.sales_channel }) describe("CreateCartWorkflow", () => { @@ -896,123 +924,6 @@ medusaIntegrationTestRunner({ }) describe("updateLineItemInCartWorkflow", () => { - it("should update item in cart", async () => { - const salesChannel = await scModuleService.createSalesChannels({ - name: "Webshop", - }) - - const location = await stockLocationModule.createStockLocations({ - name: "Warehouse", - }) - - const [product] = await productModule.createProducts([ - { - title: "Test product", - variants: [ - { - title: "Test variant", - }, - ], - }, - ]) - - const inventoryItem = await inventoryModule.createInventoryItems({ - sku: "inv-1234", - }) - - await inventoryModule.createInventoryLevels([ - { - inventory_item_id: inventoryItem.id, - location_id: location.id, - stocked_quantity: 2, - reserved_quantity: 0, - }, - ]) - - const priceSet = await pricingModule.createPriceSets({ - prices: [ - { - amount: 3000, - currency_code: "usd", - }, - ], - }) - - await remoteLink.create([ - { - [Modules.PRODUCT]: { - variant_id: product.variants[0].id, - }, - [Modules.PRICING]: { - price_set_id: priceSet.id, - }, - }, - { - [Modules.SALES_CHANNEL]: { - sales_channel_id: salesChannel.id, - }, - [Modules.STOCK_LOCATION]: { - stock_location_id: location.id, - }, - }, - { - [Modules.PRODUCT]: { - variant_id: product.variants[0].id, - }, - [Modules.INVENTORY]: { - inventory_item_id: inventoryItem.id, - }, - }, - ]) - - let cart = await cartModuleService.createCarts({ - currency_code: "usd", - sales_channel_id: salesChannel.id, - items: [ - { - variant_id: product.variants[0].id, - quantity: 1, - unit_price: 5000, - title: "Test item", - }, - ], - }) - - cart = await cartModuleService.retrieveCart(cart.id, { - select: ["id", "region_id", "currency_code"], - relations: ["items", "items.variant_id", "items.metadata"], - }) - - const item = cart.items?.[0]! - - const { errors } = await updateLineItemInCartWorkflow( - appContainer - ).run({ - input: { - cart, - item, - update: { - metadata: { - foo: "bar", - }, - quantity: 2, - }, - }, - throwOnError: false, - }) - - const updatedItem = await cartModuleService.retrieveLineItem(item.id) - - expect(updatedItem).toEqual( - expect.objectContaining({ - id: item.id, - unit_price: 3000, - quantity: 2, - title: "Test item", - }) - ) - }) - describe("compensation", () => { it("should revert line item update to original state", async () => { expect.assertions(2) @@ -1544,45 +1455,23 @@ medusaIntegrationTestRunner({ let shippingProfile let fulfillmentSet let priceSet + let region + let stockLocation beforeEach(async () => { - cart = await cartModuleService.createCarts({ - currency_code: "usd", - shipping_address: { - country_code: "us", - province: "ny", - }, - }) - - shippingProfile = await fulfillmentModule.createShippingProfiles({ - name: "Test", - type: "default", - }) - - fulfillmentSet = await fulfillmentModule.createFulfillmentSets({ - name: "Test", - type: "test-type", - service_zones: [ + region = ( + await api.post( + "/admin/regions", { - name: "Test", - geo_zones: [{ type: "country", country_code: "us" }], + name: "test-region", + currency_code: "usd", + countries: ["us"], }, - ], - }) - - priceSet = await pricingModule.createPriceSets({ - prices: [{ amount: 3000, currency_code: "usd" }], - }) - - await pricingModule.createPricePreferences({ - attribute: "currency_code", - value: "usd", - is_tax_inclusive: true, - }) - }) + adminHeaders + ) + ).data.region - it("should add shipping method to cart", async () => { - const stockLocation = ( + stockLocation = ( await api.post( `/admin/stock-locations`, { name: "test location" }, @@ -1590,16 +1479,41 @@ medusaIntegrationTestRunner({ ) ).data.stock_location - await remoteLink.create([ - { - [Modules.STOCK_LOCATION]: { - stock_location_id: stockLocation.id, + await api.post( + `/admin/stock-locations/${stockLocation.id}/sales-channels`, + { add: [salesChannel.id] }, + adminHeaders + ) + + shippingProfile = ( + await api.post( + `/admin/shipping-profiles`, + { name: "Test", type: "default" }, + adminHeaders + ) + ).data.shipping_profile + + const fulfillmentSets = ( + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`, + { + name: "Test", + type: "test-type", }, - [Modules.FULFILLMENT]: { - fulfillment_set_id: fulfillmentSet.id, + adminHeaders + ) + ).data.stock_location.fulfillment_sets + + fulfillmentSet = ( + await api.post( + `/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`, + { + name: "Test", + geo_zones: [{ type: "country", country_code: "us" }], }, - }, - ]) + adminHeaders + ) + ).data.fulfillment_set await api.post( `/admin/stock-locations/${stockLocation.id}/fulfillment-providers`, @@ -1607,37 +1521,61 @@ medusaIntegrationTestRunner({ adminHeaders ) - const shippingOption = await fulfillmentModule.createShippingOptions({ - name: "Test shipping option", - service_zone_id: fulfillmentSet.service_zones[0].id, - shipping_profile_id: shippingProfile.id, - provider_id: "manual_test-provider", - price_type: "flat", - type: { - label: "Test type", - description: "Test description", - code: "test-code", - }, - rules: [ - { - operator: RuleOperator.EQ, - attribute: "is_return", - value: "false", - }, + cart = ( + await api.post( + `/store/carts`, { - operator: RuleOperator.EQ, - attribute: "enabled_in_store", - value: "true", + currency_code: "usd", + region_id: region.id, + sales_channel_id: salesChannel.id, }, - ], - }) + storeHeaders + ) + ).data.cart - await remoteLink.create([ + await api.post( + "/admin/price-preferences", { - [Modules.FULFILLMENT]: { shipping_option_id: shippingOption.id }, - [Modules.PRICING]: { price_set_id: priceSet.id }, + attribute: "currency_code", + value: "usd", + is_tax_inclusive: true, }, - ]) + adminHeaders + ) + }) + + it("should add shipping method to cart", async () => { + const shippingOption = ( + await api.post( + `/admin/shipping-options`, + { + name: "Test shipping option", + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: "manual_test-provider", + price_type: "flat", + type: { + label: "Test type", + description: "Test description", + code: "test-code", + }, + prices: [{ amount: 3000, currency_code: "usd" }], + rules: [ + { + operator: RuleOperator.EQ, + attribute: "is_return", + value: "false", + }, + { + operator: RuleOperator.EQ, + attribute: "enabled_in_store", + value: "true", + }, + ], + }, + adminHeaders + ) + ).data.shipping_option await addShippingMethodToCartWorkflow(appContainer).run({ input: { @@ -1646,9 +1584,8 @@ medusaIntegrationTestRunner({ }, }) - cart = await cartModuleService.retrieveCart(cart.id, { - relations: ["shipping_methods"], - }) + cart = (await api.get(`/store/carts/${cart.id}`, storeHeaders)).data + .cart expect(cart).toEqual( expect.objectContaining({ @@ -1658,7 +1595,6 @@ medusaIntegrationTestRunner({ expect.objectContaining({ amount: 3000, is_tax_inclusive: true, - name: "Test shipping option", }), ], }) @@ -1666,40 +1602,37 @@ medusaIntegrationTestRunner({ }) it("should throw error when shipping option is not valid", async () => { - const shippingOption = await fulfillmentModule.createShippingOptions({ - name: "Test shipping option", - service_zone_id: fulfillmentSet.service_zones[0].id, - shipping_profile_id: shippingProfile.id, - provider_id: "manual_test-provider", - price_type: "flat", - type: { - label: "Test type", - description: "Test description", - code: "test-code", - }, - rules: [ + const shippingOption = ( + await api.post( + `/admin/shipping-options`, { - operator: RuleOperator.EQ, - attribute: "shipping_address.city", - value: "sf", + name: "Test shipping option", + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: "manual_test-provider", + price_type: "flat", + type: { + label: "Test type", + description: "Test description", + code: "test-code", + }, + rules: [ + { + operator: RuleOperator.EQ, + attribute: "shipping_address.city", + value: "sf", + }, + ], + prices: [{ amount: 3000, currency_code: "usd" }], }, - ], - }) - - await remoteLink.create([ - { - [Modules.FULFILLMENT]: { shipping_option_id: shippingOption.id }, - [Modules.PRICING]: { price_set_id: priceSet.id }, - }, - ]) + adminHeaders + ) + ).data.shipping_option const { errors } = await addShippingMethodToCartWorkflow( appContainer ).run({ - input: { - options: [{ id: shippingOption.id }], - cart_id: cart.id, - }, + input: { options: [{ id: shippingOption.id }], cart_id: cart.id }, throwOnError: false, }) @@ -1737,97 +1670,62 @@ medusaIntegrationTestRunner({ }) it("should add shipping method with custom data", async () => { - const stockLocation = ( + const shippingOption = ( await api.post( - `/admin/stock-locations`, - { name: "test location" }, + `/admin/shipping-options`, + { + name: "Test shipping option", + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: "manual_test-provider", + price_type: "flat", + type: { + label: "Test type", + description: "Test description", + code: "test-code", + }, + rules: [ + { + operator: RuleOperator.EQ, + attribute: "is_return", + value: "false", + }, + { + operator: RuleOperator.EQ, + attribute: "enabled_in_store", + value: "true", + }, + ], + prices: [{ amount: 3000, currency_code: "usd" }], + }, adminHeaders ) - ).data.stock_location - - await remoteLink.create([ - { - [Modules.STOCK_LOCATION]: { - stock_location_id: stockLocation.id, - }, - [Modules.FULFILLMENT]: { - fulfillment_set_id: fulfillmentSet.id, - }, - }, - ]) - - await api.post( - `/admin/stock-locations/${stockLocation.id}/fulfillment-providers`, - { add: ["manual_test-provider"] }, - adminHeaders - ) + ).data.shipping_option - const shippingOption = await fulfillmentModule.createShippingOptions({ - name: "Test shipping option", - service_zone_id: fulfillmentSet.service_zones[0].id, - shipping_profile_id: shippingProfile.id, - provider_id: "manual_test-provider", - price_type: "flat", - type: { - label: "Test type", - description: "Test description", - code: "test-code", + await addShippingMethodToCartWorkflow(appContainer).run({ + input: { + options: [{ id: shippingOption.id, data: { test: "test" } }], + cart_id: cart.id, }, - rules: [ - { - operator: RuleOperator.EQ, - attribute: "is_return", - value: "false", - }, - { - operator: RuleOperator.EQ, - attribute: "enabled_in_store", - value: "true", - }, - ], }) - await remoteLink.create([ - { - [Modules.FULFILLMENT]: { shipping_option_id: shippingOption.id }, - [Modules.PRICING]: { price_set_id: priceSet.id }, - }, - ]) - - await addShippingMethodToCartWorkflow(appContainer).run({ - input: { - options: [{ id: shippingOption.id, data: { test: "test" } }], - cart_id: cart.id, - }, - }) - - cart = await cartModuleService.retrieveCart(cart.id, { - select: ["id"], - relations: ["shipping_methods"], - }) + cart = ( + await api.get( + `/store/carts/${cart.id}?fields=+shipping_methods.data`, + storeHeaders + ) + ).data.cart expect(cart).toEqual( expect.objectContaining({ id: cart.id, shipping_methods: [ - { - id: expect.any(String), - cart_id: cart.id, - description: null, + expect.objectContaining({ amount: 3000, - raw_amount: { - value: "3000", - precision: 20, - }, - metadata: null, is_tax_inclusive: true, - name: "Test shipping option", data: { test: "test" }, shipping_option_id: shippingOption.id, - deleted_at: null, - updated_at: expect.any(Date), - created_at: expect.any(Date), - }, + }), ], }) ) @@ -1835,123 +1733,146 @@ medusaIntegrationTestRunner({ }) describe("listShippingOptionsForCartWorkflow", () => { + let cart + let shippingProfile + let fulfillmentSet let region + let stockLocation beforeEach(async () => { - region = await regionModuleService.createRegions({ - name: "US", - currency_code: "usd", - }) - }) + region = ( + await api.post( + "/admin/regions", + { + name: "test-region", + currency_code: "usd", + countries: ["us"], + }, + adminHeaders + ) + ).data.region - it("should list shipping options for cart", async () => { - const salesChannel = await scModuleService.createSalesChannels({ - name: "Webshop", - }) + stockLocation = ( + await api.post( + `/admin/stock-locations`, + { name: "test location" }, + adminHeaders + ) + ).data.stock_location - const location = await stockLocationModule.createStockLocations({ - name: "Europe", - }) + await api.post( + `/admin/stock-locations/${stockLocation.id}/sales-channels`, + { add: [salesChannel.id] }, + adminHeaders + ) - let cart = await cartModuleService.createCarts({ - currency_code: "usd", - region_id: region.id, - sales_channel_id: salesChannel.id, - shipping_address: { - city: "CPH", - province: "Sjaelland", - country_code: "dk", - }, - }) + shippingProfile = ( + await api.post( + `/admin/shipping-profiles`, + { name: "Test", type: "default" }, + adminHeaders + ) + ).data.shipping_profile - const shippingProfile = - await fulfillmentModule.createShippingProfiles({ - name: "Test", - type: "default", - }) + const fulfillmentSets = ( + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`, + { + name: "Test", + type: "test-type", + }, + adminHeaders + ) + ).data.stock_location.fulfillment_sets - const fulfillmentSet = await fulfillmentModule.createFulfillmentSets({ - name: "Test", - type: "test-type", - service_zones: [ + fulfillmentSet = ( + await api.post( + `/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`, { name: "Test", - geo_zones: [ - { - type: "country", - country_code: "dk", - }, - ], + geo_zones: [{ type: "country", country_code: "us" }], }, - ], - }) + adminHeaders + ) + ).data.fulfillment_set - const shippingOption = await fulfillmentModule.createShippingOptions({ - name: "Test shipping option", - service_zone_id: fulfillmentSet.service_zones[0].id, - shipping_profile_id: shippingProfile.id, - provider_id: "manual_test-provider", - price_type: "flat", - type: { - label: "Test type", - description: "Test description", - code: "test-code", - }, - }) + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-providers`, + { add: ["manual_test-provider"] }, + adminHeaders + ) - const priceSet = await pricingModule.createPriceSets({ - prices: [ + cart = ( + await api.post( + `/store/carts`, { - amount: 3000, currency_code: "usd", - }, - ], - }) - - await remoteLink.create([ - { - [Modules.SALES_CHANNEL]: { + region_id: region.id, sales_channel_id: salesChannel.id, }, - [Modules.STOCK_LOCATION]: { - stock_location_id: location.id, - }, - }, + storeHeaders + ) + ).data.cart + + await api.post( + "/admin/price-preferences", { - [Modules.STOCK_LOCATION]: { - stock_location_id: location.id, - }, - [Modules.FULFILLMENT]: { - fulfillment_set_id: fulfillmentSet.id, - }, + attribute: "currency_code", + value: "usd", + is_tax_inclusive: true, }, - { - [Modules.FULFILLMENT]: { - shipping_option_id: shippingOption.id, - }, - [Modules.PRICING]: { - price_set_id: priceSet.id, + adminHeaders + ) + }) + + it("should list shipping options for cart", async () => { + const shippingOption = ( + await api.post( + `/admin/shipping-options`, + { + name: "Test shipping option", + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: "manual_test-provider", + price_type: "flat", + type: { + label: "Test type", + description: "Test description", + code: "test-code", + }, + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + rules: [ + { + operator: RuleOperator.EQ, + attribute: "is_return", + value: "false", + }, + { + operator: RuleOperator.EQ, + attribute: "enabled_in_store", + value: "true", + }, + ], }, - }, - ]) + adminHeaders + ) + ).data.shipping_option - cart = await cartModuleService.retrieveCart(cart.id, { - select: ["id"], - relations: ["shipping_address"], - }) + cart = (await api.get(`/store/carts/${cart.id}`, storeHeaders)).data + .cart const { result } = await listShippingOptionsForCartWorkflow( appContainer - ).run({ - input: { - cart_id: cart.id, - }, - }) + ).run({ input: { cart_id: cart.id } }) expect(result).toEqual([ expect.objectContaining({ amount: 3000, - name: "Test shipping option", id: shippingOption.id, }), ]) @@ -1962,43 +1883,15 @@ medusaIntegrationTestRunner({ name: "Webshop", }) - const location = await stockLocationModule.createStockLocations({ - name: "Europe", - }) - let cart = await cartModuleService.createCarts({ currency_code: "usd", region_id: region.id, sales_channel_id: salesChannel.id, shipping_address: { - city: "CPH", - province: "Sjaelland", - country_code: "dk", + country_code: "us", }, }) - const shippingProfile = - await fulfillmentModule.createShippingProfiles({ - name: "Test", - type: "default", - }) - - const fulfillmentSet = await fulfillmentModule.createFulfillmentSets({ - name: "Test", - type: "test-type", - service_zones: [ - { - name: "Test", - geo_zones: [ - { - type: "country", - country_code: "dk", - }, - ], - }, - ], - }) - const shippingOption = await fulfillmentModule.createShippingOptions([ { name: "Return shipping option", @@ -2057,12 +1950,12 @@ medusaIntegrationTestRunner({ sales_channel_id: salesChannel.id, }, [Modules.STOCK_LOCATION]: { - stock_location_id: location.id, + stock_location_id: stockLocation.id, }, }, { [Modules.STOCK_LOCATION]: { - stock_location_id: location.id, + stock_location_id: stockLocation.id, }, [Modules.FULFILLMENT]: { fulfillment_set_id: fulfillmentSet.id, @@ -2116,88 +2009,49 @@ medusaIntegrationTestRunner({ name: "Webshop", }) - const location = await stockLocationModule.createStockLocations({ - name: "Europe", - }) - - let cart = await cartModuleService.createCarts({ - currency_code: "usd", - region_id: region.id, - sales_channel_id: salesChannel.id, - shipping_address: { - city: "CPH", - province: "Sjaelland", - country_code: "dk", - }, - }) - - const shippingProfile = - await fulfillmentModule.createShippingProfiles({ - name: "Test", - type: "default", - }) - - const fulfillmentSet = await fulfillmentModule.createFulfillmentSets({ - name: "Test", - type: "test-type", - service_zones: [ - { - name: "Test", - geo_zones: [ - { - type: "country", - country_code: "us", - }, - ], - }, - ], - }) - - const shippingOption = await fulfillmentModule.createShippingOptions({ - name: "Test shipping option", - service_zone_id: fulfillmentSet.service_zones[0].id, - shipping_profile_id: shippingProfile.id, - provider_id: "manual_test-provider", - price_type: "flat", - type: { - label: "Test type", - description: "Test description", - code: "test-code", - }, - }) - - const priceSet = await pricingModule.createPriceSets({ - prices: [ - { - amount: 3000, - currency_code: "usd", - }, - ], - }) + await api.post( + `/store/carts/${cart.id}`, + { sales_channel_id: salesChannel.id }, + storeHeaders + ) - await remoteLink.create([ - { - [Modules.STOCK_LOCATION]: { - stock_location_id: location.id, - }, - [Modules.FULFILLMENT]: { - fulfillment_set_id: fulfillmentSet.id, - }, - }, + await api.post( + `/admin/shipping-options`, { - [Modules.FULFILLMENT]: { - shipping_option_id: shippingOption.id, - }, - [Modules.PRICING]: { - price_set_id: priceSet.id, + name: "Test shipping option", + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: "manual_test-provider", + price_type: "flat", + type: { + label: "Test type", + description: "Test description", + code: "test-code", }, + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + rules: [ + { + operator: RuleOperator.EQ, + attribute: "is_return", + value: "false", + }, + { + operator: RuleOperator.EQ, + attribute: "enabled_in_store", + value: "true", + }, + ], }, - ]) + adminHeaders + ) - cart = await cartModuleService.retrieveCart(cart.id, { - select: ["id"], - relations: ["shipping_address"], - }) + cart = (await api.get(`/store/carts/${cart.id}`, storeHeaders)).data + .cart const { result } = await listShippingOptionsForCartWorkflow( appContainer @@ -2207,99 +2061,6 @@ medusaIntegrationTestRunner({ expect(result).toEqual([]) }) - - it("should throw when shipping options are missing prices", async () => { - const salesChannel = await scModuleService.createSalesChannels({ - name: "Webshop", - }) - - const location = await stockLocationModule.createStockLocations({ - name: "Europe", - }) - - let cart = await cartModuleService.createCarts({ - currency_code: "usd", - region_id: region.id, - sales_channel_id: salesChannel.id, - shipping_address: { - city: "CPH", - province: "Sjaelland", - country_code: "dk", - }, - }) - - const shippingProfile = - await fulfillmentModule.createShippingProfiles({ - name: "Test", - type: "default", - }) - - const fulfillmentSet = await fulfillmentModule.createFulfillmentSets({ - name: "Test", - type: "test-type", - service_zones: [ - { - name: "Test", - geo_zones: [ - { - type: "country", - country_code: "dk", - }, - ], - }, - ], - }) - - const shippingOption = await fulfillmentModule.createShippingOptions({ - name: "Test shipping option", - service_zone_id: fulfillmentSet.service_zones[0].id, - shipping_profile_id: shippingProfile.id, - provider_id: "manual_test-provider", - price_type: "flat", - type: { - label: "Test type", - description: "Test description", - code: "test-code", - }, - }) - - await remoteLink.create([ - { - [Modules.SALES_CHANNEL]: { - sales_channel_id: salesChannel.id, - }, - [Modules.STOCK_LOCATION]: { - stock_location_id: location.id, - }, - }, - { - [Modules.STOCK_LOCATION]: { - stock_location_id: location.id, - }, - [Modules.FULFILLMENT]: { - fulfillment_set_id: fulfillmentSet.id, - }, - }, - ]) - - cart = await cartModuleService.retrieveCart(cart.id, { - select: ["id"], - relations: ["shipping_address"], - }) - - const { errors } = await listShippingOptionsForCartWorkflow( - appContainer - ).run({ - input: { cart_id: cart.id }, - throwOnError: false, - }) - - expect(errors).toEqual([ - expect.objectContaining({ - message: `Shipping options with IDs ${shippingOption.id} do not have a price`, - }), - ]) - }) }) describe("updateTaxLinesWorkflow", () => { diff --git a/integration-tests/modules/__tests__/cart/store/carts.spec.ts b/integration-tests/modules/__tests__/cart/store/carts.spec.ts index 42f3d21e228c7..b268a1ff80d47 100644 --- a/integration-tests/modules/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/carts.spec.ts @@ -20,7 +20,6 @@ import { Modules, ProductStatus, PromotionType, - RuleOperator, } from "@medusajs/utils" import { createAdminUser, @@ -715,221 +714,6 @@ medusaIntegrationTestRunner({ }) }) - it("should add item to cart", async () => { - const customer = await customerModule.createCustomers({ - email: "tony@stark-industries.com", - }) - - const salesChannel = await scModule.createSalesChannels({ - name: "Webshop", - }) - - const [productWithSpecialTax] = await productModule.createProducts([ - { - // This product ID is setup in the tax structure fixture (setupTaxStructure) - id: "product_id_1", - title: "Test product", - variants: [{ title: "Test variant", manage_inventory: false }], - } as any, - ]) - - const [productWithDefaultTax] = await productModule.createProducts([ - { - title: "Test product default tax", - variants: [ - { title: "Test variant default tax", manage_inventory: false }, - ], - }, - ]) - - await api.post( - "/admin/price-preferences", - { - attribute: "currency_code", - value: "usd", - is_tax_inclusive: true, - }, - adminHeaders - ) - - const cart = await cartModule.createCarts({ - currency_code: "usd", - customer_id: customer.id, - sales_channel_id: salesChannel.id, - region_id: region.id, - shipping_address: { - customer_id: customer.id, - address_1: "test address 1", - address_2: "test address 2", - city: "SF", - country_code: "US", - province: "CA", - postal_code: "94016", - }, - items: [ - { - id: "item-1", - unit_price: 2000, - quantity: 1, - title: "Test item", - product_id: "prod_mat", - } as any, - ], - }) - - const appliedPromotion = await promotionModule.createPromotions({ - code: "PROMOTION_APPLIED", - type: PromotionType.STANDARD, - application_method: { - type: "fixed", - target_type: "items", - allocation: "across", - value: 300, - apply_to_quantity: 2, - currency_code: "usd", - target_rules: [ - { - attribute: "product_id", - operator: "in", - values: ["prod_mat", productWithSpecialTax.id], - }, - ], - }, - }) - - const [lineItemAdjustment] = await cartModule.addLineItemAdjustments([ - { - code: appliedPromotion.code!, - amount: 300, - item_id: "item-1", - promotion_id: appliedPromotion.id, - }, - ]) - - const [priceSet, priceSetDefaultTax] = - await pricingModule.createPriceSets([ - { - prices: [{ amount: 3000, currency_code: "usd" }], - }, - { - prices: [{ amount: 2000, currency_code: "usd" }], - }, - ]) - - await remoteLink.create([ - { - [Modules.PRODUCT]: { - variant_id: productWithSpecialTax.variants[0].id, - }, - [Modules.PRICING]: { price_set_id: priceSet.id }, - }, - { - [Modules.PRODUCT]: { - variant_id: productWithDefaultTax.variants[0].id, - }, - [Modules.PRICING]: { price_set_id: priceSetDefaultTax.id }, - }, - { - [Modules.CART]: { cart_id: cart.id }, - [Modules.PROMOTION]: { promotion_id: appliedPromotion.id }, - }, - ]) - - let response = await api.post( - `/store/carts/${cart.id}/line-items`, - { - variant_id: productWithSpecialTax.variants[0].id, - quantity: 1, - }, - storeHeaders - ) - - expect(response.status).toEqual(200) - - expect(response.data.cart).toEqual( - expect.objectContaining({ - id: cart.id, - currency_code: "usd", - items: expect.arrayContaining([ - expect.objectContaining({ - unit_price: 3000, - is_tax_inclusive: true, - quantity: 1, - title: "Test variant", - tax_lines: [ - expect.objectContaining({ - description: "CA Reduced Rate for Products", - code: "CAREDUCE_PROD", - rate: 3, - provider_id: "system", - }), - ], - adjustments: [ - expect.objectContaining({ - code: "PROMOTION_APPLIED", - amount: 177.86561264822134, - }), - ], - }), - expect.objectContaining({ - unit_price: 2000, - is_tax_inclusive: false, - quantity: 1, - title: "Test item", - tax_lines: [ - expect.objectContaining({ - code: "CADEFAULT", - description: "CA Default Rate", - provider_id: "system", - rate: 5, - }), - ], - adjustments: [ - expect.objectContaining({ - id: expect.not.stringContaining(lineItemAdjustment.id), - code: "PROMOTION_APPLIED", - amount: 122.13438735177866, - }), - ], - }), - ]), - }) - ) - - response = await api.post( - `/store/carts/${cart.id}/line-items`, - { - variant_id: productWithDefaultTax.variants[0].id, - quantity: 1, - }, - storeHeaders - ) - - expect(response.data.cart).toEqual( - expect.objectContaining({ - id: cart.id, - currency_code: "usd", - items: expect.arrayContaining([ - expect.objectContaining({ - unit_price: 2000, - is_tax_inclusive: true, - quantity: 1, - title: "Test variant default tax", - tax_lines: [ - // Uses the california default rate - expect.objectContaining({ - description: "CA Default Rate", - code: "CADEFAULT", - rate: 5, - provider_id: "system", - }), - ], - }), - ]), - }) - ) - }) - it("adding an existing variant should update or create line item depending on metadata", async () => { const product = ( await api.post(`/admin/products`, productData, adminHeaders) @@ -1304,102 +1088,6 @@ medusaIntegrationTestRunner({ }) }) - describe("POST /store/carts/:id/shipping-methods", () => { - it("should add a shipping methods to a cart", async () => { - const cart = await cartModule.createCarts({ - currency_code: "usd", - shipping_address: { country_code: "us" }, - items: [], - }) - - const shippingProfile = - await fulfillmentModule.createShippingProfiles({ - name: "Test", - type: "default", - }) - - const fulfillmentSet = await fulfillmentModule.createFulfillmentSets({ - name: "Test", - type: "test-type", - service_zones: [ - { - name: "Test", - geo_zones: [{ type: "country", country_code: "us" }], - }, - ], - }) - - await api.post( - "/admin/price-preferences", - { - attribute: "currency_code", - value: "usd", - is_tax_inclusive: true, - }, - adminHeaders - ) - - const priceSet = await pricingModule.createPriceSets({ - prices: [{ amount: 3000, currency_code: "usd" }], - }) - - const shippingOption = await fulfillmentModule.createShippingOptions({ - name: "Test shipping option", - service_zone_id: fulfillmentSet.service_zones[0].id, - shipping_profile_id: shippingProfile.id, - provider_id: "manual_test-provider", - price_type: "flat", - type: { - label: "Test type", - description: "Test description", - code: "test-code", - }, - rules: [ - { - operator: RuleOperator.EQ, - attribute: "is_return", - value: "false", - }, - { - operator: RuleOperator.EQ, - attribute: "enabled_in_store", - value: "true", - }, - ], - }) - - await remoteLink.create([ - { - [Modules.FULFILLMENT]: { shipping_option_id: shippingOption.id }, - [Modules.PRICING]: { price_set_id: priceSet.id }, - }, - ]) - - let response = await api.post( - `/store/carts/${cart.id}/shipping-methods`, - { option_id: shippingOption.id }, - storeHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.cart).toEqual( - expect.objectContaining({ - id: cart.id, - shipping_methods: [ - { - shipping_option_id: shippingOption.id, - amount: 3000, - is_tax_inclusive: true, - id: expect.any(String), - tax_lines: [], - adjustments: [], - }, - ], - }) - ) - }) - }) - describe("POST /store/carts/:id/complete", () => { let salesChannel let product diff --git a/packages/core/core-flows/src/cart/steps/index.ts b/packages/core/core-flows/src/cart/steps/index.ts index 5f9cddd02f526..de29ab97c2f77 100644 --- a/packages/core/core-flows/src/cart/steps/index.ts +++ b/packages/core/core-flows/src/cart/steps/index.ts @@ -14,7 +14,6 @@ export * from "./get-promotion-codes-to-apply" export * from "./get-variant-price-sets" export * from "./get-variants" export * from "./prepare-adjustments-from-promotion-actions" -export * from "./refresh-cart-shipping-methods" export * from "./remove-line-item-adjustments" export * from "./remove-shipping-method-adjustments" export * from "./remove-shipping-method-from-cart" @@ -27,4 +26,3 @@ export * from "./update-line-items" export * from "./validate-cart-payments" export * from "./validate-cart-shipping-options" export * from "./validate-variant-prices" - diff --git a/packages/core/core-flows/src/cart/steps/refresh-cart-shipping-methods.ts b/packages/core/core-flows/src/cart/steps/refresh-cart-shipping-methods.ts deleted file mode 100644 index 5c3830838ab32..0000000000000 --- a/packages/core/core-flows/src/cart/steps/refresh-cart-shipping-methods.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - CartDTO, - ICartModuleService, - IFulfillmentModuleService, -} from "@medusajs/framework/types" -import { Modules, arrayDifference } from "@medusajs/framework/utils" -import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" - -export interface RefreshCartShippingMethodsStepInput { - cart: CartDTO -} - -export const refreshCartShippingMethodsStepId = "refresh-cart-shipping-methods" -/** - * This step refreshes the shipping methods of a cart. - */ -export const refreshCartShippingMethodsStep = createStep( - refreshCartShippingMethodsStepId, - async (data: RefreshCartShippingMethodsStepInput, { container }) => { - const { cart } = data - const { shipping_methods: shippingMethods = [] } = cart - - if (!shippingMethods?.length) { - return new StepResponse(void 0, []) - } - - const fulfillmentModule = container.resolve( - Modules.FULFILLMENT - ) - - const cartModule = container.resolve(Modules.CART) - - const shippingOptionIds: string[] = shippingMethods.map( - (sm) => sm.shipping_option_id! - ) - - const validShippingOptions = - await fulfillmentModule.listShippingOptionsForContext( - { - id: shippingOptionIds, - context: { ...cart, is_return: "false", enabled_in_store: "true" }, - address: { - country_code: cart.shipping_address?.country_code, - province_code: cart.shipping_address?.province, - city: cart.shipping_address?.city, - postal_expression: cart.shipping_address?.postal_code, - }, - }, - { relations: ["rules"] } - ) - - const validShippingOptionIds = validShippingOptions.map((o) => o.id) - const invalidShippingOptionIds = arrayDifference( - shippingOptionIds, - validShippingOptionIds - ) - - const shippingMethodsToDelete = shippingMethods - .filter((sm) => invalidShippingOptionIds.includes(sm.shipping_option_id!)) - .map((sm) => sm.id) - - await cartModule.softDeleteShippingMethods(shippingMethodsToDelete) - - return new StepResponse(void 0, shippingMethodsToDelete) - }, - async (shippingMethodsToRestore, { container }) => { - if (shippingMethodsToRestore?.length) { - const cartModule = container.resolve(Modules.CART) - - await cartModule.restoreShippingMethods(shippingMethodsToRestore) - } - } -) diff --git a/packages/core/core-flows/src/cart/steps/remove-shipping-method-from-cart.ts b/packages/core/core-flows/src/cart/steps/remove-shipping-method-from-cart.ts index d9c739abd05cc..3706c8b02c0d1 100644 --- a/packages/core/core-flows/src/cart/steps/remove-shipping-method-from-cart.ts +++ b/packages/core/core-flows/src/cart/steps/remove-shipping-method-from-cart.ts @@ -16,6 +16,10 @@ export const removeShippingMethodFromCartStep = createStep( async (data: RemoveShippingMethodFromCartStepInput, { container }) => { const cartService = container.resolve(Modules.CART) + if (!data?.shipping_method_ids?.length) { + return new StepResponse(null, []) + } + const methods = await cartService.softDeleteShippingMethods( data.shipping_method_ids ) @@ -23,7 +27,7 @@ export const removeShippingMethodFromCartStep = createStep( return new StepResponse(methods, data.shipping_method_ids) }, async (ids, { container }) => { - if (!ids) { + if (!ids?.length) { return } diff --git a/packages/core/core-flows/src/cart/steps/update-shipping-methods.ts b/packages/core/core-flows/src/cart/steps/update-shipping-methods.ts new file mode 100644 index 0000000000000..b53b2f981a7d6 --- /dev/null +++ b/packages/core/core-flows/src/cart/steps/update-shipping-methods.ts @@ -0,0 +1,43 @@ +import { + ICartModuleService, + UpdateShippingMethodDTO, +} from "@medusajs/framework/types" +import { + Modules, + getSelectsAndRelationsFromObjectArray, +} from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" + +export const updateShippingMethodsStepId = "update-shipping-methods-step" +/** + * This step updates a cart's shipping methods. + */ +export const updateShippingMethodsStep = createStep( + updateShippingMethodsStepId, + async (data: UpdateShippingMethodDTO[], { container }) => { + if (!data?.length) { + return new StepResponse([], []) + } + + const cartModule = container.resolve(Modules.CART) + const { selects, relations } = getSelectsAndRelationsFromObjectArray(data) + + const dataBeforeUpdate = await cartModule.listShippingMethods( + { id: data.map((d) => d.id!) }, + { select: selects, relations } + ) + + const updatedItems = await cartModule.updateShippingMethods(data) + + return new StepResponse(updatedItems, dataBeforeUpdate) + }, + async (dataBeforeUpdate, { container }) => { + if (!dataBeforeUpdate?.length) { + return + } + + const cartModule: ICartModuleService = container.resolve(Modules.CART) + + await cartModule.updateShippingMethods(dataBeforeUpdate) + } +) diff --git a/packages/core/core-flows/src/cart/steps/validate-shipping-options-price.ts b/packages/core/core-flows/src/cart/steps/validate-shipping-options-price.ts new file mode 100644 index 0000000000000..51784e93cdb10 --- /dev/null +++ b/packages/core/core-flows/src/cart/steps/validate-shipping-options-price.ts @@ -0,0 +1,37 @@ +import { isDefined, MedusaError } from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" + +export const validateCartShippingOptionsStepId = + "validate-cart-shipping-options" +/** + * This step validates shipping options to ensure they have a price. + */ +export const validateCartShippingOptionsPriceStep = createStep( + "validate-cart-shipping-options-price", + async (data: { shippingOptions: any[] }, { container }) => { + const { shippingOptions = [] } = data + const optionsMissingPrices: string[] = [] + + for (const shippingOption of shippingOptions) { + const { calculated_price, ...options } = shippingOption + + if ( + shippingOption?.id && + !isDefined(calculated_price?.calculated_amount) + ) { + optionsMissingPrices.push(options.id) + } + } + + if (optionsMissingPrices.length) { + const ids = optionsMissingPrices.join(", ") + + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Shipping options with IDs ${ids} do not have a price` + ) + } + + return new StepResponse(void 0) + } +) diff --git a/packages/core/core-flows/src/cart/utils/fields.ts b/packages/core/core-flows/src/cart/utils/fields.ts index 9d58454953fe6..f3d6f7e69aa78 100644 --- a/packages/core/core-flows/src/cart/utils/fields.ts +++ b/packages/core/core-flows/src/cart/utils/fields.ts @@ -3,6 +3,7 @@ export const cartFieldsForRefreshSteps = [ "currency_code", "quantity", "subtotal", + "total", "item_subtotal", "shipping_subtotal", "region_id", diff --git a/packages/core/core-flows/src/cart/workflows/add-shipping-method-to-cart.ts b/packages/core/core-flows/src/cart/workflows/add-shipping-method-to-cart.ts index 86a2cf16b0799..d097137586e85 100644 --- a/packages/core/core-flows/src/cart/workflows/add-shipping-method-to-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/add-shipping-method-to-cart.ts @@ -14,7 +14,9 @@ import { } from "../steps" import { validateCartStep } from "../steps/validate-cart" import { validateAndReturnShippingMethodsDataStep } from "../steps/validate-shipping-methods-data" +import { validateCartShippingOptionsPriceStep } from "../steps/validate-shipping-options-price" import { cartFieldsForRefreshSteps } from "../utils/fields" +import { listShippingOptionsForCartWorkflow } from "./list-shipping-options-for-cart" import { updateCartPromotionsWorkflow } from "./update-cart-promotions" import { updateTaxLinesWorkflow } from "./update-tax-lines" @@ -54,30 +56,24 @@ export const addShippingMethodToCartWorkflow = createWorkflow( shippingOptionsContext: { is_return: "false", enabled_in_store: "true" }, }) - const shippingOptions = useRemoteQueryStep({ - entry_point: "shipping_option", - fields: [ - "id", - "name", - "calculated_price.calculated_amount", - "calculated_price.is_calculated_price_tax_inclusive", - "provider_id", - ], - variables: { - id: optionIds, - calculated_price: { - context: { currency_code: cart.currency_code }, - }, + const shippingOptions = listShippingOptionsForCartWorkflow.runAsStep({ + input: { + option_ids: optionIds, + cart_id: cart.id, + is_return: false, }, - }).config({ name: "fetch-shipping-option" }) + }) + + validateCartShippingOptionsPriceStep({ shippingOptions }) const validateShippingMethodsDataInput = transform( { input, shippingOptions }, - (data) => { - return data.input.options.map((inputOption) => { - const shippingOption = data.shippingOptions.find( + ({ input, shippingOptions }) => { + return input.options.map((inputOption) => { + const shippingOption = shippingOptions.find( (so) => so.id === inputOption.id ) + return { id: inputOption.id, provider_id: shippingOption?.provider_id, diff --git a/packages/core/core-flows/src/cart/workflows/add-to-cart.ts b/packages/core/core-flows/src/cart/workflows/add-to-cart.ts index 90a129cf5df48..7050c005adc00 100644 --- a/packages/core/core-flows/src/cart/workflows/add-to-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/add-to-cart.ts @@ -8,27 +8,20 @@ import { parallelize, transform, WorkflowData, - WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { emitEventStep } from "../../common/steps/emit-event" import { useRemoteQueryStep } from "../../common/steps/use-remote-query" import { createLineItemsStep, getLineItemActionsStep, - refreshCartShippingMethodsStep, updateLineItemsStep, } from "../steps" import { validateCartStep } from "../steps/validate-cart" import { validateVariantPricesStep } from "../steps/validate-variant-prices" -import { - cartFieldsForRefreshSteps, - productVariantsFields, -} from "../utils/fields" +import { productVariantsFields } from "../utils/fields" import { prepareLineItemData } from "../utils/prepare-line-item-data" import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory" -import { refreshPaymentCollectionForCartWorkflow } from "./refresh-payment-collection" -import { updateCartPromotionsWorkflow } from "./update-cart-promotions" -import { updateTaxLinesWorkflow } from "./update-tax-lines" +import { refreshCartItemsWorkflow } from "./refresh-cart-items" export const addToCartWorkflowId = "add-to-cart" /** @@ -44,6 +37,7 @@ export const addToCartWorkflow = createWorkflow( }) // TODO: This is on par with the context used in v1.*, but we can be more flexible. + // TODO: create a common workflow to fetch variants and its prices const pricingContext = transform({ cart: input.cart }, (data) => { return { currency_code: data.cart.currency_code, @@ -100,7 +94,7 @@ export const addToCartWorkflow = createWorkflow( }, }) - const [createdItems, updatedItems] = parallelize( + parallelize( createLineItemsStep({ id: input.cart.id, items: itemsToCreate, @@ -111,43 +105,13 @@ export const addToCartWorkflow = createWorkflow( }) ) - const items = transform({ createdItems, updatedItems }, (data) => { - return [...(data.createdItems || []), ...(data.updatedItems || [])] - }) - - const cart = useRemoteQueryStep({ - entry_point: "cart", - fields: cartFieldsForRefreshSteps, - variables: { id: input.cart.id }, - list: false, - }).config({ name: "refetch–cart" }) - - parallelize( - refreshCartShippingMethodsStep({ cart }), - emitEventStep({ - eventName: CartWorkflowEvents.UPDATED, - data: { id: input.cart.id }, - }) - ) - - updateTaxLinesWorkflow.runAsStep({ - input: { - cart_id: input.cart.id, - }, + refreshCartItemsWorkflow.runAsStep({ + input: { cart_id: input.cart.id }, }) - updateCartPromotionsWorkflow.runAsStep({ - input: { - cart_id: input.cart.id, - }, + emitEventStep({ + eventName: CartWorkflowEvents.UPDATED, + data: { id: input.cart.id }, }) - - refreshPaymentCollectionForCartWorkflow.runAsStep({ - input: { - cart_id: input.cart.id, - }, - }) - - return new WorkflowResponse(items) } ) diff --git a/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart.ts b/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart.ts index d1069c0e3f809..c2a72e172e3fb 100644 --- a/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart.ts @@ -1,8 +1,7 @@ -import { deepFlatMap, isPresent, MedusaError } from "@medusajs/framework/utils" +import { deepFlatMap } from "@medusajs/framework/utils" import { createWorkflow, transform, - when, WorkflowData, WorkflowResponse, } from "@medusajs/framework/workflows-sdk" @@ -16,7 +15,14 @@ export const listShippingOptionsForCartWorkflowId = */ export const listShippingOptionsForCartWorkflow = createWorkflow( listShippingOptionsForCartWorkflowId, - (input: WorkflowData<{ cart_id: string; is_return?: boolean }>) => { + ( + input: WorkflowData<{ + cart_id: string + option_ids?: string[] + is_return?: boolean + enabled_in_store?: boolean + }> + ) => { const cartQuery = useQueryGraphStep({ entity: "cart", filters: { id: input.cart_id }, @@ -28,6 +34,8 @@ export const listShippingOptionsForCartWorkflow = createWorkflow( "shipping_address.city", "shipping_address.country_code", "shipping_address.province", + "shipping_address.postal_code", + "item_total", "total", ], options: { throwIfKeyNotFound: true }, @@ -70,42 +78,31 @@ export const listShippingOptionsForCartWorkflow = createWorkflow( } ) - const customerGroupIds = when( - "get-customer-group", - { cart }, - ({ cart }) => { - return !!cart.id - } - ).then(() => { - const customerQuery = useQueryGraphStep({ - entity: "customer", - filters: { id: cart.customer_id }, - fields: ["groups.id"], - }).config({ name: "get-customer" }) - - return transform({ customerQuery }, ({ customerQuery }) => { - const customer = customerQuery.data[0] + const queryVariables = transform( + { input, fulfillmentSetIds, cart }, + ({ input, fulfillmentSetIds, cart }) => ({ + id: input.option_ids, - if (!isPresent(customer)) { - return [] - } + context: { + is_return: input.is_return ?? false, + enabled_in_store: input.enabled_in_store ?? true, + }, - const { groups = [] } = customer + filters: { + fulfillment_set_id: fulfillmentSetIds, - return groups.map((group) => group.id) - }) - }) + address: { + country_code: cart.shipping_address?.country_code, + province_code: cart.shipping_address?.province, + city: cart.shipping_address?.city, + postal_expression: cart.shipping_address?.postal_code, + }, + }, - const pricingContext = transform( - { cart, customerGroupIds }, - ({ cart, customerGroupIds }) => ({ - ...cart, - customer_group_id: customerGroupIds, + calculated_price: { context: cart }, }) ) - const isReturn = transform({ input }, ({ input }) => !!input.is_return) - const shippingOptions = useRemoteQueryStep({ entry_point: "shipping_options", fields: [ @@ -116,7 +113,6 @@ export const listShippingOptionsForCartWorkflow = createWorkflow( "shipping_profile_id", "provider_id", "data", - "amount", "type.id", "type.label", @@ -132,55 +128,22 @@ export const listShippingOptionsForCartWorkflow = createWorkflow( "calculated_price.*", ], - variables: { - context: { - is_return: isReturn, - enabled_in_store: "true", - }, - filters: { - fulfillment_set_id: fulfillmentSetIds, - address: { - city: cart.shipping_address?.city, - country_code: cart.shipping_address?.country_code, - province_code: cart.shipping_address?.province, - }, - }, - - calculated_price: { - context: pricingContext, - }, - }, + variables: queryVariables, }).config({ name: "shipping-options-query" }) - const shippingOptionsWithPrice = transform({ shippingOptions }, (data) => { - const optionsMissingPrices: string[] = [] - - const options = data.shippingOptions.map((shippingOption) => { - const { calculated_price, ...options } = shippingOption ?? {} - - if (options?.id && !isPresent(calculated_price?.calculated_amount)) { - optionsMissingPrices.push(options.id) - } + const shippingOptionsWithPrice = transform( + { shippingOptions }, + ({ shippingOptions }) => + shippingOptions.map((shippingOption) => { + const price = shippingOption.calculated_price - return { - ...options, - amount: calculated_price?.calculated_amount, - is_tax_inclusive: - !!calculated_price?.is_calculated_price_tax_inclusive, - } - }) - - if (optionsMissingPrices.length) { - const ids = optionsMissingPrices.join(", ") - - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Shipping options with IDs ${ids} do not have a price` - ) - } - - return options - }) + return { + ...shippingOption, + amount: price?.calculated_amount, + is_tax_inclusive: !!price?.is_calculated_price_tax_inclusive, + } + }) + ) return new WorkflowResponse(shippingOptionsWithPrice) } diff --git a/packages/core/core-flows/src/cart/workflows/refresh-cart-items.ts b/packages/core/core-flows/src/cart/workflows/refresh-cart-items.ts index 93c4eadbe02a8..46e1194d86213 100644 --- a/packages/core/core-flows/src/cart/workflows/refresh-cart-items.ts +++ b/packages/core/core-flows/src/cart/workflows/refresh-cart-items.ts @@ -6,13 +6,14 @@ import { WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { useRemoteQueryStep } from "../../common/steps/use-remote-query" -import { refreshCartShippingMethodsStep, updateLineItemsStep } from "../steps" +import { updateLineItemsStep } from "../steps" import { validateVariantPricesStep } from "../steps/validate-variant-prices" import { cartFieldsForRefreshSteps, productVariantsFields, } from "../utils/fields" import { prepareLineItemData } from "../utils/prepare-line-item-data" +import { refreshCartShippingMethodsWorkflow } from "./refresh-cart-shipping-methods" import { refreshPaymentCollectionForCartWorkflow } from "./refresh-payment-collection" import { updateCartPromotionsWorkflow } from "./update-cart-promotions" import { updateTaxLinesWorkflow } from "./update-tax-lines" @@ -100,7 +101,9 @@ export const refreshCartItemsWorkflow = createWorkflow( list: false, }).config({ name: "refetch–cart" }) - refreshCartShippingMethodsStep({ cart: refetchedCart }) + refreshCartShippingMethodsWorkflow.runAsStep({ + input: { cart_id: cart.id }, + }) updateTaxLinesWorkflow.runAsStep({ input: { cart_id: cart.id }, diff --git a/packages/core/core-flows/src/cart/workflows/refresh-cart-shipping-methods.ts b/packages/core/core-flows/src/cart/workflows/refresh-cart-shipping-methods.ts new file mode 100644 index 0000000000000..9d8850686809c --- /dev/null +++ b/packages/core/core-flows/src/cart/workflows/refresh-cart-shipping-methods.ts @@ -0,0 +1,125 @@ +import { isDefined, isPresent } from "@medusajs/framework/utils" +import { + createWorkflow, + parallelize, + transform, + when, + WorkflowData, +} from "@medusajs/framework/workflows-sdk" +import { useQueryGraphStep } from "../../common" +import { removeShippingMethodFromCartStep } from "../steps" +import { updateShippingMethodsStep } from "../steps/update-shipping-methods" +import { listShippingOptionsForCartWorkflow } from "./list-shipping-options-for-cart" + +export const refreshCartShippingMethodsWorkflowId = + "refresh-cart-shipping-methods" +/** + * This workflow refreshes a cart's shipping methods + */ +export const refreshCartShippingMethodsWorkflow = createWorkflow( + refreshCartShippingMethodsWorkflowId, + (input: WorkflowData<{ cart_id: string }>) => { + const cartQuery = useQueryGraphStep({ + entity: "cart", + filters: { id: input.cart_id }, + fields: [ + "id", + "sales_channel_id", + "currency_code", + "region_id", + "shipping_methods.*", + "shipping_address.city", + "shipping_address.country_code", + "shipping_address.province", + "shipping_methods.shipping_option_id", + "total", + ], + options: { throwIfKeyNotFound: true }, + }).config({ name: "get-cart" }) + + const cart = transform({ cartQuery }, ({ cartQuery }) => cartQuery.data[0]) + const shippingOptionIds: string[] = transform({ cart }, ({ cart }) => + (cart.shipping_methods || []) + .map((shippingMethod) => shippingMethod.shipping_option_id) + .filter(Boolean) + ) + + when({ shippingOptionIds }, ({ shippingOptionIds }) => { + return !!shippingOptionIds?.length + }).then(() => { + const shippingOptions = listShippingOptionsForCartWorkflow.runAsStep({ + input: { + option_ids: shippingOptionIds, + cart_id: cart.id, + is_return: false, + }, + }) + + // Creates an object on which shipping methods to remove or update depending + // on the validity of the shipping options for the cart + const shippingMethodsData = transform( + { cart, shippingOptions }, + ({ cart, shippingOptions }) => { + const { shipping_methods: shippingMethods = [] } = cart + + const validShippingMethods = shippingMethods.filter( + (shippingMethod) => { + // Fetch the available shipping options for the cart context and find the one associated + // with the current shipping method + const shippingOption = shippingOptions.find( + (shippingOption) => + shippingOption.id === shippingMethod.shipping_option_id + ) + + const shippingOptionPrice = + shippingOption?.calculated_price?.calculated_amount + + // The shipping method is only valid if both the shipping option and the price is found + // for the context of the cart. The invalid options will lead to a deleted shipping method + if (isPresent(shippingOption) && isDefined(shippingOptionPrice)) { + return true + } + + return false + } + ) + + const shippingMethodIds = shippingMethods.map((sm) => sm.id) + const validShippingMethodIds = validShippingMethods.map((sm) => sm.id) + const invalidShippingMethodIds = shippingMethodIds.filter( + (id) => !validShippingMethodIds.includes(id) + ) + + const shippingMethodsToUpdate = validShippingMethods.map( + (shippingMethod) => { + const shippingOption = shippingOptions.find( + (s) => s.id === shippingMethod.shipping_option_id + )! + + return { + id: shippingMethod.id, + shipping_option_id: shippingOption.id, + amount: shippingOption.calculated_price.calculated_amount, + is_tax_inclusive: + shippingOption.calculated_price + .is_calculated_price_tax_inclusive, + } + } + ) + + return { + shippingMethodsToRemove: invalidShippingMethodIds, + shippingMethodsToUpdate, + } + } + ) + + parallelize( + removeShippingMethodFromCartStep({ + shipping_method_ids: shippingMethodsData.shippingMethodsToRemove, + }), + updateShippingMethodsStep(shippingMethodsData.shippingMethodsToUpdate) + ) + }) + } +) diff --git a/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts b/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts index edfe4320338ec..528c4b01a4376 100644 --- a/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts @@ -1,28 +1,16 @@ import { UpdateLineItemInCartWorkflowInputDTO } from "@medusajs/framework/types" -import { CartWorkflowEvents } from "@medusajs/framework/utils" import { WorkflowData, - WorkflowResponse, createWorkflow, - parallelize, transform, } from "@medusajs/framework/workflows-sdk" -import { emitEventStep } from "../../common/steps/emit-event" import { useRemoteQueryStep } from "../../common/steps/use-remote-query" import { updateLineItemsStepWithSelector } from "../../line-item/steps" -import { refreshCartShippingMethodsStep } from "../steps" import { validateCartStep } from "../steps/validate-cart" import { validateVariantPricesStep } from "../steps/validate-variant-prices" -import { - cartFieldsForRefreshSteps, - productVariantsFields, -} from "../utils/fields" +import { productVariantsFields } from "../utils/fields" import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory" -import { refreshPaymentCollectionForCartWorkflow } from "./refresh-payment-collection" -import { updateCartPromotionsWorkflow } from "./update-cart-promotions" - -// TODO: The UpdateLineItemsWorkflow are missing the following steps: -// - Validate shipping methods for new items (fulfillment module) +import { refreshCartItemsWorkflow } from "./refresh-cart-items" export const updateLineItemInCartWorkflowId = "update-line-item-in-cart" /** @@ -89,35 +77,10 @@ export const updateLineItemInCartWorkflow = createWorkflow( } }) - const result = updateLineItemsStepWithSelector(lineItemUpdate) - - const cart = useRemoteQueryStep({ - entry_point: "cart", - fields: cartFieldsForRefreshSteps, - variables: { id: input.cart.id }, - list: false, - }).config({ name: "refetch–cart" }) + updateLineItemsStepWithSelector(lineItemUpdate) - refreshCartShippingMethodsStep({ cart }) - - updateCartPromotionsWorkflow.runAsStep({ - input: { - cart_id: input.cart.id, - }, + refreshCartItemsWorkflow.runAsStep({ + input: { cart_id: input.cart.id }, }) - - parallelize( - refreshPaymentCollectionForCartWorkflow.runAsStep({ - input: { cart_id: input.cart.id }, - }), - emitEventStep({ - eventName: CartWorkflowEvents.UPDATED, - data: { id: input.cart.id }, - }) - ) - - const updatedItem = transform({ result }, (data) => data.result?.[0]) - - return new WorkflowResponse(updatedItem) } ) diff --git a/packages/core/types/src/cart/mutations.ts b/packages/core/types/src/cart/mutations.ts index a8e13860c8ea5..3d51cd8cb6051 100644 --- a/packages/core/types/src/cart/mutations.ts +++ b/packages/core/types/src/cart/mutations.ts @@ -767,6 +767,11 @@ export interface UpdateShippingMethodDTO { */ amount?: BigNumberInput + /** + * The tax inclusivity setting of the shipping method. + */ + is_tax_inclusive?: boolean + /** * The data of the shipping method. */ diff --git a/packages/core/types/src/cart/service.ts b/packages/core/types/src/cart/service.ts index cc2f31532618e..f9f42d41abc7d 100644 --- a/packages/core/types/src/cart/service.ts +++ b/packages/core/types/src/cart/service.ts @@ -38,6 +38,7 @@ import { UpdateLineItemTaxLineDTO, UpdateLineItemWithSelectorDTO, UpdateShippingMethodAdjustmentDTO, + UpdateShippingMethodDTO, UpdateShippingMethodTaxLineDTO, UpsertLineItemAdjustmentDTO, } from "./mutations" @@ -822,6 +823,46 @@ export interface ICartModuleService extends IModuleService { sharedContext?: Context ): Promise + /** + * This method updates existing shipping methods. + * + * @param {UpdateShippingMethodDTO[]} data - A list of shipping methods to update + * @returns {Promise} The updated shipping methods. + * + * @example + * const shippingMethods = await cartModuleService.updateShippingMethods([ + * { + * id: "casm_123", + * amount: 2, + * }, + * ]) + */ + updateShippingMethods( + data: UpdateShippingMethodDTO[] + ): Promise + + /** + * This method updates an existing shipping method. + * + * @param {string} shippingMethodId - The shipping methods's ID. + * @param {Partial} data - The attributes to update in the shipping method. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The updated shipping method. + * + * @example + * const lineItem = await cartModuleService.updateShippingMethods( + * "casm_123", + * { + * amount: 3000, + * } + * ) + */ + updateShippingMethods( + shippingMethodId: string, + data: Partial, + sharedContext?: Context + ): Promise + /** * This method retrieves a paginated list of line item adjustments based on optional filters and configuration. * diff --git a/packages/medusa/src/api/admin/shipping-options/validators.ts b/packages/medusa/src/api/admin/shipping-options/validators.ts index e63e6215abe95..61a8b59dc495e 100644 --- a/packages/medusa/src/api/admin/shipping-options/validators.ts +++ b/packages/medusa/src/api/admin/shipping-options/validators.ts @@ -86,7 +86,7 @@ export const AdminCreateShippingOptionTypeObject = z const AdminPriceRules = z.array( z.object({ - attribute: z.literal("total"), + attribute: z.literal("item_total"), operator: z.nativeEnum(PricingRuleOperator), value: z.number(), }) diff --git a/packages/modules/fulfillment/integration-tests/__tests__/fulfillment-module-service/shipping-option.spec.ts b/packages/modules/fulfillment/integration-tests/__tests__/fulfillment-module-service/shipping-option.spec.ts index 72f73687f529a..109c23d606148 100644 --- a/packages/modules/fulfillment/integration-tests/__tests__/fulfillment-module-service/shipping-option.spec.ts +++ b/packages/modules/fulfillment/integration-tests/__tests__/fulfillment-module-service/shipping-option.spec.ts @@ -8,11 +8,11 @@ import { GeoZoneType, Modules, } from "@medusajs/framework/utils" -import { FulfillmentProviderService } from "@services" import { MockEventBusService, moduleIntegrationTestRunner, } from "@medusajs/test-utils" +import { FulfillmentProviderService } from "@services" import { resolve } from "path" import { buildExpectedEventMessageShape,