From f1a05f4725dcc45150f014769562bd3dfbc0f1f8 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Thu, 17 Aug 2023 14:14:45 +0200 Subject: [PATCH] feat(admin, admin-ui, medusa-js, medusa-react, medusa): Support Admin Extensions (#4761) Co-authored-by: Rares Stefan <948623+StephixOne@users.noreply.github.com> Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> --- .changeset/odd-dolls-sit.md | 10 + .eslintrc.js | 47 + packages/admin-ui/.gitignore | 3 +- packages/admin-ui/jest.config.js | 13 + packages/admin-ui/package.json | 67 +- packages/admin-ui/scripts/create-dev-entry.js | 23 + packages/admin-ui/src/client/index.ts | 16 + packages/admin-ui/src/index.ts | 44 +- packages/admin-ui/src/node/actions/build.ts | 58 + packages/admin-ui/src/node/actions/clean.ts | 18 + packages/admin-ui/src/node/actions/develop.ts | 103 + packages/admin-ui/src/node/actions/index.ts | 5 + packages/admin-ui/src/node/constants.ts | 13 + packages/admin-ui/src/node/index.ts | 11 + packages/admin-ui/src/node/types.ts | 80 + .../utils/__tests__/normalize-path.spec.ts | 58 + .../__tests__/validate-extensions.spec.ts | 60 + .../admin-ui/src/node/utils/copy-filter.ts | 22 + .../src/node/utils/create-cache-dir.ts | 46 + .../admin-ui/src/node/utils/create-entry.ts | 321 + .../admin-ui/src/node/utils/get-client-env.ts | 61 + packages/admin-ui/src/node/utils/index.ts | 27 + packages/admin-ui/src/node/utils/logger.ts | 74 + .../admin-ui/src/node/utils/normalize-path.ts | 6 + .../admin-ui/src/node/utils/validate-args.ts | 28 + .../src/node/utils/validate-extensions.ts | 685 + .../node/utils/watch-local-admin-folder.ts | 57 + .../node/webpack/get-custom-webpack-config.ts | 52 + .../src/node/webpack/get-webpack-config.ts | 185 + packages/admin-ui/src/node/webpack/index.ts | 5 + .../src/node/webpack/webpack-aliases.ts | 9 + .../webpack/with-custom-webpack-config.ts | 16 + packages/admin-ui/src/types/build.ts | 15 - packages/admin-ui/src/types/config.ts | 5 - packages/admin-ui/src/types/dev.ts | 4 - packages/admin-ui/src/types/index.ts | 3 - packages/admin-ui/src/types/misc.ts | 9 - packages/admin-ui/src/utils/format-base.ts | 9 - .../src/utils/get-custom-vite-config.ts | 76 - .../src/utils/get-custom-vite-dev-config.ts | 24 - packages/admin-ui/src/utils/index.ts | 3 - packages/admin-ui/tsconfig.json | 12 +- packages/admin-ui/tsconfig.spec.json | 5 + packages/admin-ui/tsup.config.ts | 11 + packages/admin-ui/ui/index.html | 1 - packages/admin-ui/ui/src/App.tsx | 2 +- .../admin-ui/ui/src/assets/styles/global.css | 129 +- .../components/atoms/settings-card/index.tsx | 8 +- .../extensions/route-container/index.tsx | 80 + .../route-container/route-error-element.tsx | 75 + .../use-route-container-props.tsx | 8 + .../extensions/setting-container/index.tsx | 14 + .../setting-error-element.tsx | 77 + .../use-setting-container-props.tsx | 7 + .../extensions/widget-container/index.tsx | 32 + .../extensions/widget-container/types.ts | 77 + .../use-widget-container-props.tsx | 31 + .../widget-error-boundary.tsx | 136 + .../icons/arrow-uturn-left/index.tsx | 29 + .../fundamentals/icons/info-icon/index.tsx | 18 +- .../fundamentals/icons/squares-plus/index.tsx | 29 + .../molecules/input-signin/index.tsx | 3 +- .../organisms/analytics-config-form/index.tsx | 61 +- .../components/organisms/login-card/index.tsx | 98 +- .../components/organisms/sidebar/index.tsx | 21 +- .../src/components/templates/user-table.tsx | 2 +- .../ui/src/constants/forbidden-routes.ts | 59 + .../ui/src/constants/injection-zones.ts | 52 + .../ui/src/constants/medusa-backend-url.ts | 2 +- .../src/domain/collections/details/index.tsx | 111 +- .../ui/src/domain/collections/index.tsx | 15 + .../ui/src/domain/customers/details/index.tsx | 81 +- .../src/domain/customers/groups/details.tsx | 61 +- .../ui/src/domain/customers/groups/index.tsx | 44 +- .../ui/src/domain/customers/index.tsx | 43 +- .../ui/src/domain/discounts/details/index.tsx | 75 +- .../ui/src/domain/discounts/index.tsx | 41 +- .../src/domain/gift-cards/details/index.tsx | 27 + .../ui/src/domain/gift-cards/index.tsx | 15 + .../ui/src/domain/gift-cards/manage/index.tsx | 29 + .../ui/src/domain/gift-cards/overview.tsx | 25 + .../__tests__/claim-type-form.test.tsx | 59 - .../__tests__/items-to-receive-form.test.tsx | 59 - .../__tests__/items-to-return-form.test.tsx | 119 - .../__tests__/items-to-send-form.test.tsx | 80 - .../__tests__/refund-amount-form.test.tsx | 59 - .../__tests__/claim-summary.test.tsx | 72 - .../__tests__/send-notification-form.test.tsx | 52 - .../__tests__/shipping-address-form.test.tsx | 53 - .../__tests__/shipping-form.test.tsx | 148 - .../orders/details/detail-cards/summary.tsx | 8 +- .../ui/src/domain/orders/details/index.tsx | 42 +- .../domain/orders/draft-orders/details.tsx | 36 + .../src/domain/orders/draft-orders/index.tsx | 28 +- .../admin-ui/ui/src/domain/orders/index.tsx | 43 +- .../src/domain/pricing/batch-job/import.tsx | 6 +- .../ui/src/domain/pricing/details/index.tsx | 70 +- .../admin-ui/ui/src/domain/pricing/index.tsx | 31 +- .../src/domain/product-categories/index.tsx | 17 + .../src/domain/products/batch-job/import.tsx | 6 +- .../ui/src/domain/products/edit/index.tsx | 47 +- .../admin-ui/ui/src/domain/products/index.tsx | 15 + .../ui/src/domain/products/overview/index.tsx | 41 +- .../src/domain/publishable-api-keys/index.tsx | 20 + .../ui/src/domain/sales-channels/index.tsx | 19 +- .../admin-ui/ui/src/domain/settings/index.tsx | 250 +- .../admin-ui/ui/src/fonts/Inter-Medium.ttf | Bin 0 -> 314712 bytes .../ui/src/hooks/use-extension-base-props.tsx | 25 + packages/admin-ui/ui/src/index.css | 3 - packages/admin-ui/ui/src/index.d.ts | 1 + packages/admin-ui/ui/src/main.tsx | 15 +- packages/admin-ui/ui/src/medusa-app.tsx | 43 +- packages/admin-ui/ui/src/pages/a.tsx | 24 +- packages/admin-ui/ui/src/pages/invite.tsx | 133 +- packages/admin-ui/ui/src/pages/login.tsx | 1 - .../admin-ui/ui/src/providers/providers.tsx | 29 +- .../ui/src/providers/route-provider.tsx | 55 + .../ui/src/providers/setting-provider.tsx | 49 + .../ui/src/providers/widget-provider.tsx | 38 + .../ui/src/registries/route-registry.tsx | 133 + .../ui/src/registries/setting-registry.tsx | 60 + .../ui/src/registries/widget-registry.tsx | 26 + packages/admin-ui/ui/src/types/extensions.ts | 151 + packages/admin-ui/ui/src/utils/extensions.ts | 30 + packages/admin-ui/ui/src/vite-env.d.ts | 3 - packages/admin-ui/ui/tailwind.config.js | 27 +- .../admin-ui/ui/test/fixtures/fixtures.json | 1274 -- packages/admin-ui/ui/test/fixtures/index.ts | 27 - .../admin-ui/ui/test/mocks/medusa-react.tsx | 49 - packages/admin-ui/ui/test/setup.ts | 19 - .../ui/test/utils/render-with-providers.tsx | 27 - packages/admin-ui/ui/tsconfig.json | 1 + packages/admin-ui/vite.config.dev.ts | 26 - packages/admin-ui/webpack.config.dev.ts | 42 + packages/admin/.gitignore | 7 +- packages/admin/package.json | 31 +- packages/admin/src/api/index.ts | 46 +- packages/admin/src/commands/build.ts | 132 +- packages/admin/src/commands/bundle.ts | 188 + packages/admin/src/commands/create-cli.ts | 59 +- packages/admin/src/commands/dev.ts | 6 - packages/admin/src/commands/develop.ts | 32 + packages/admin/src/commands/eject.ts | 111 - packages/admin/src/lib/index.ts | 3 + packages/admin/src/setup/index.ts | 115 +- packages/admin/src/types/index.ts | 43 +- packages/admin/src/types/options.ts | 22 + packages/admin/src/types/routes.ts | 3 + packages/admin/src/types/setting.ts | 3 + packages/admin/src/types/widgets.ts | 50 + packages/admin/src/utils/build-manifest.ts | 146 + packages/admin/src/utils/get-plugin-paths.ts | 42 + packages/admin/src/utils/index.ts | 3 +- packages/admin/src/utils/load-config.ts | 47 +- packages/admin/src/utils/reporter.ts | 19 - packages/admin/src/utils/validate-path.ts | 19 - packages/medusa-cli/package.json | 2 +- packages/medusa-js/package.json | 2 +- packages/medusa-js/src/index.ts | 8 +- .../medusa-js/src/resources/admin/custom.ts | 64 + .../medusa-js/src/resources/admin/index.ts | 3 + packages/medusa-js/src/utils.ts | 14 + packages/medusa-payment-stripe/package.json | 14 +- .../src/admin/shared/components/badge.tsx | 17 + .../src/admin/shared/components/container.tsx | 24 + .../src/admin/shared/components/dot.tsx | 33 + .../src/admin/shared/components/table.tsx | 174 + .../src/admin/shared/icons/link.tsx | 23 + .../src/admin/shared/icons/stripe-logo.tsx | 25 + .../src/admin/widgets/index.tsx | 34 + .../medusa-payment-stripe/src/api/index.ts | 27 +- .../src/controllers/get-payments.ts | 51 + .../src/core/__tests__/stripe-base.spec.ts | 2 +- .../src/core/stripe-base.ts | 8 +- packages/medusa-payment-stripe/src/types.ts | 16 + .../medusa-payment-stripe/tailwind.config.js | 5 + .../medusa-payment-stripe/tsconfig.admin.json | 8 + packages/medusa-payment-stripe/tsconfig.json | 6 +- .../tsconfig.server.json | 7 + packages/medusa-react/package.json | 6 +- .../src/hooks/admin/collections/mutations.ts | 2 +- .../src/hooks/admin/custom/index.ts | 2 + .../src/hooks/admin/custom/mutations.ts | 128 + .../src/hooks/admin/custom/queries.ts | 28 + .../medusa-react/src/hooks/admin/index.ts | 13 +- packages/medusa-react/tsconfig.json | 2 +- packages/medusa/src/commands/develop.js | 80 +- .../src/commands/utils/resolve-admin-cli.ts | 20 + yarn.lock | 17660 +++++++--------- 189 files changed, 14570 insertions(+), 12773 deletions(-) create mode 100644 .changeset/odd-dolls-sit.md create mode 100644 packages/admin-ui/jest.config.js create mode 100644 packages/admin-ui/scripts/create-dev-entry.js create mode 100644 packages/admin-ui/src/client/index.ts create mode 100644 packages/admin-ui/src/node/actions/build.ts create mode 100644 packages/admin-ui/src/node/actions/clean.ts create mode 100644 packages/admin-ui/src/node/actions/develop.ts create mode 100644 packages/admin-ui/src/node/actions/index.ts create mode 100644 packages/admin-ui/src/node/constants.ts create mode 100644 packages/admin-ui/src/node/index.ts create mode 100644 packages/admin-ui/src/node/types.ts create mode 100644 packages/admin-ui/src/node/utils/__tests__/normalize-path.spec.ts create mode 100644 packages/admin-ui/src/node/utils/__tests__/validate-extensions.spec.ts create mode 100644 packages/admin-ui/src/node/utils/copy-filter.ts create mode 100644 packages/admin-ui/src/node/utils/create-cache-dir.ts create mode 100644 packages/admin-ui/src/node/utils/create-entry.ts create mode 100644 packages/admin-ui/src/node/utils/get-client-env.ts create mode 100644 packages/admin-ui/src/node/utils/index.ts create mode 100644 packages/admin-ui/src/node/utils/logger.ts create mode 100644 packages/admin-ui/src/node/utils/normalize-path.ts create mode 100644 packages/admin-ui/src/node/utils/validate-args.ts create mode 100644 packages/admin-ui/src/node/utils/validate-extensions.ts create mode 100644 packages/admin-ui/src/node/utils/watch-local-admin-folder.ts create mode 100644 packages/admin-ui/src/node/webpack/get-custom-webpack-config.ts create mode 100644 packages/admin-ui/src/node/webpack/get-webpack-config.ts create mode 100644 packages/admin-ui/src/node/webpack/index.ts create mode 100644 packages/admin-ui/src/node/webpack/webpack-aliases.ts create mode 100644 packages/admin-ui/src/node/webpack/with-custom-webpack-config.ts delete mode 100644 packages/admin-ui/src/types/build.ts delete mode 100644 packages/admin-ui/src/types/config.ts delete mode 100644 packages/admin-ui/src/types/dev.ts delete mode 100644 packages/admin-ui/src/types/index.ts delete mode 100644 packages/admin-ui/src/types/misc.ts delete mode 100644 packages/admin-ui/src/utils/format-base.ts delete mode 100644 packages/admin-ui/src/utils/get-custom-vite-config.ts delete mode 100644 packages/admin-ui/src/utils/get-custom-vite-dev-config.ts delete mode 100644 packages/admin-ui/src/utils/index.ts create mode 100644 packages/admin-ui/tsconfig.spec.json create mode 100644 packages/admin-ui/tsup.config.ts create mode 100644 packages/admin-ui/ui/src/components/extensions/route-container/index.tsx create mode 100644 packages/admin-ui/ui/src/components/extensions/route-container/route-error-element.tsx create mode 100644 packages/admin-ui/ui/src/components/extensions/route-container/use-route-container-props.tsx create mode 100644 packages/admin-ui/ui/src/components/extensions/setting-container/index.tsx create mode 100644 packages/admin-ui/ui/src/components/extensions/setting-container/setting-error-element.tsx create mode 100644 packages/admin-ui/ui/src/components/extensions/setting-container/use-setting-container-props.tsx create mode 100644 packages/admin-ui/ui/src/components/extensions/widget-container/index.tsx create mode 100644 packages/admin-ui/ui/src/components/extensions/widget-container/types.ts create mode 100644 packages/admin-ui/ui/src/components/extensions/widget-container/use-widget-container-props.tsx create mode 100644 packages/admin-ui/ui/src/components/extensions/widget-container/widget-error-boundary.tsx create mode 100644 packages/admin-ui/ui/src/components/fundamentals/icons/arrow-uturn-left/index.tsx create mode 100644 packages/admin-ui/ui/src/components/fundamentals/icons/squares-plus/index.tsx create mode 100644 packages/admin-ui/ui/src/constants/forbidden-routes.ts create mode 100644 packages/admin-ui/ui/src/constants/injection-zones.ts delete mode 100644 packages/admin-ui/ui/src/domain/orders/components/claim-type-form/__tests__/claim-type-form.test.tsx delete mode 100644 packages/admin-ui/ui/src/domain/orders/components/items-to-receive-form/__tests__/items-to-receive-form.test.tsx delete mode 100644 packages/admin-ui/ui/src/domain/orders/components/items-to-return-form/__tests__/items-to-return-form.test.tsx delete mode 100644 packages/admin-ui/ui/src/domain/orders/components/items-to-send-form/__tests__/items-to-send-form.test.tsx delete mode 100644 packages/admin-ui/ui/src/domain/orders/components/refund-amount-form/__tests__/refund-amount-form.test.tsx delete mode 100644 packages/admin-ui/ui/src/domain/orders/components/rma-summaries/__tests__/claim-summary.test.tsx delete mode 100644 packages/admin-ui/ui/src/domain/orders/components/send-notification-form/__tests__/send-notification-form.test.tsx delete mode 100644 packages/admin-ui/ui/src/domain/orders/components/shipping-address-form/__tests__/shipping-address-form.test.tsx delete mode 100644 packages/admin-ui/ui/src/domain/orders/components/shipping-form/__tests__/shipping-form.test.tsx create mode 100644 packages/admin-ui/ui/src/fonts/Inter-Medium.ttf create mode 100644 packages/admin-ui/ui/src/hooks/use-extension-base-props.tsx delete mode 100644 packages/admin-ui/ui/src/index.css create mode 100644 packages/admin-ui/ui/src/index.d.ts create mode 100644 packages/admin-ui/ui/src/providers/route-provider.tsx create mode 100644 packages/admin-ui/ui/src/providers/setting-provider.tsx create mode 100644 packages/admin-ui/ui/src/providers/widget-provider.tsx create mode 100644 packages/admin-ui/ui/src/registries/route-registry.tsx create mode 100644 packages/admin-ui/ui/src/registries/setting-registry.tsx create mode 100644 packages/admin-ui/ui/src/registries/widget-registry.tsx create mode 100644 packages/admin-ui/ui/src/types/extensions.ts create mode 100644 packages/admin-ui/ui/src/utils/extensions.ts delete mode 100644 packages/admin-ui/ui/src/vite-env.d.ts delete mode 100644 packages/admin-ui/ui/test/fixtures/fixtures.json delete mode 100644 packages/admin-ui/ui/test/fixtures/index.ts delete mode 100644 packages/admin-ui/ui/test/mocks/medusa-react.tsx delete mode 100644 packages/admin-ui/ui/test/setup.ts delete mode 100644 packages/admin-ui/ui/test/utils/render-with-providers.tsx delete mode 100644 packages/admin-ui/vite.config.dev.ts create mode 100644 packages/admin-ui/webpack.config.dev.ts create mode 100644 packages/admin/src/commands/bundle.ts delete mode 100644 packages/admin/src/commands/dev.ts create mode 100644 packages/admin/src/commands/develop.ts delete mode 100644 packages/admin/src/commands/eject.ts create mode 100644 packages/admin/src/lib/index.ts create mode 100644 packages/admin/src/types/options.ts create mode 100644 packages/admin/src/types/routes.ts create mode 100644 packages/admin/src/types/setting.ts create mode 100644 packages/admin/src/types/widgets.ts create mode 100644 packages/admin/src/utils/build-manifest.ts create mode 100644 packages/admin/src/utils/get-plugin-paths.ts delete mode 100644 packages/admin/src/utils/reporter.ts delete mode 100644 packages/admin/src/utils/validate-path.ts create mode 100644 packages/medusa-js/src/resources/admin/custom.ts create mode 100644 packages/medusa-payment-stripe/src/admin/shared/components/badge.tsx create mode 100644 packages/medusa-payment-stripe/src/admin/shared/components/container.tsx create mode 100644 packages/medusa-payment-stripe/src/admin/shared/components/dot.tsx create mode 100644 packages/medusa-payment-stripe/src/admin/shared/components/table.tsx create mode 100644 packages/medusa-payment-stripe/src/admin/shared/icons/link.tsx create mode 100644 packages/medusa-payment-stripe/src/admin/shared/icons/stripe-logo.tsx create mode 100644 packages/medusa-payment-stripe/src/admin/widgets/index.tsx create mode 100644 packages/medusa-payment-stripe/src/controllers/get-payments.ts create mode 100644 packages/medusa-payment-stripe/tailwind.config.js create mode 100644 packages/medusa-payment-stripe/tsconfig.admin.json create mode 100644 packages/medusa-payment-stripe/tsconfig.server.json create mode 100644 packages/medusa-react/src/hooks/admin/custom/index.ts create mode 100644 packages/medusa-react/src/hooks/admin/custom/mutations.ts create mode 100644 packages/medusa-react/src/hooks/admin/custom/queries.ts create mode 100644 packages/medusa/src/commands/utils/resolve-admin-cli.ts diff --git a/.changeset/odd-dolls-sit.md b/.changeset/odd-dolls-sit.md new file mode 100644 index 0000000000..0a69d9453f --- /dev/null +++ b/.changeset/odd-dolls-sit.md @@ -0,0 +1,10 @@ +--- +"@medusajs/admin-ui": major +"@medusajs/admin": major +"medusa-payment-stripe": patch +"medusa-react": patch +"@medusajs/medusa-js": patch +"@medusajs/medusa": patch +--- + +feat(admin, admin-ui, medusa, medusa-js, medusa-react, stripe-plugin): Support admin extensions diff --git a/.eslintrc.js b/.eslintrc.js index 896d7bf212..ee219a0d44 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -85,6 +85,7 @@ module.exports = { "./packages/medusa-payment-stripe/tsconfig.spec.json", "./packages/medusa-payment-paypal/tsconfig.spec.json", "./packages/admin-ui/tsconfig.json", + "./packages/admin-ui/tsconfig.spec.json", "./packages/event-bus-local/tsconfig.spec.json", "./packages/event-bus-redis/tsconfig.spec.json", "./packages/medusa-plugin-meilisearch/tsconfig.spec.json", @@ -94,6 +95,7 @@ module.exports = { "./packages/stock-location/tsconfig.spec.json", "./packages/cache-redis/tsconfig.spec.json", "./packages/cache-inmemory/tsconfig.spec.json", + "./packages/admin-ui/tsconfig.json", "./packages/create-medusa-app/tsconfig.json", "./packages/product/tsconfig.json", ], @@ -141,6 +143,9 @@ module.exports = { sourceType: "module", // Allows for the use of imports project: "./packages/admin-ui/ui/tsconfig.json", }, + globals: { + __BASE__: "readonly", + }, env: { browser: true, }, @@ -177,5 +182,47 @@ module.exports = { project: "./packages/admin/tsconfig.json", }, }, + { + files: [ + "packages/medusa-payment-stripe/src/admin/**/*.ts", + "packages/medusa-payment-stripe/src/admin/**/*.tsx", + ], + plugins: ["unused-imports"], + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + ], + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features + sourceType: "module", // Allows for the use of imports + project: "./packages/medusa-payment-stripe/tsconfig.admin.json", + }, + env: { + browser: true, + }, + rules: { + "prettier/prettier": "error", + "react/prop-types": "off", + "new-cap": "off", + "require-jsdoc": "off", + "valid-jsdoc": "off", + "no-unused-expressions": "off", + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "warn", + { + vars: "all", + varsIgnorePattern: "^_", + args: "after-used", + argsIgnorePattern: "^_", + }, + ], + }, + }, ], } diff --git a/packages/admin-ui/.gitignore b/packages/admin-ui/.gitignore index cc4ef712b4..531f55bb2d 100644 --- a/packages/admin-ui/.gitignore +++ b/packages/admin-ui/.gitignore @@ -1,4 +1,5 @@ /dist /build .vercel -/ui/preview \ No newline at end of file +/ui/preview +/ui/src/extensions \ No newline at end of file diff --git a/packages/admin-ui/jest.config.js b/packages/admin-ui/jest.config.js new file mode 100644 index 0000000000..1b626a0af1 --- /dev/null +++ b/packages/admin-ui/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + globals: { + "ts-jest": { + tsConfig: "tsconfig.spec.json", + isolatedModules: false, + }, + }, + transform: { + "^.+\\.[jt]s?$": "ts-jest", + }, + testEnvironment: `node`, + moduleFileExtensions: [`js`, `ts`], +} diff --git a/packages/admin-ui/package.json b/packages/admin-ui/package.json index 972c7cd79c..bc918168c7 100644 --- a/packages/admin-ui/package.json +++ b/packages/admin-ui/package.json @@ -9,7 +9,10 @@ "directory": "packages/admin-ui" }, "exports": { - ".": "./dist/index.js", + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, "./ui": "./ui", "./package.json": "./package.json" }, @@ -20,14 +23,20 @@ "ui" ], "scripts": { - "dev": "vite -c vite.config.dev.ts --port 7001", - "build": "tsc --build", - "test:ui": "vitest --config vite.config.dev.ts", - "test:ui:once": "vitest --config vite.config.dev.ts --run", - "test": "echo \"Tests disabled temporarily\"" + "test": "jest --runInBand --forceExit -- ./src/**/__tests__/**/*.ts", + "create:dev:entry": "node ./scripts/create-dev-entry.js", + "dev": "yarn create:dev:entry && webpack serve --mode=development --config ./webpack.config.dev.ts --progress profile", + "analyze:bundle": "ANALYZE_BUNDLE=true webpack --config ./webpack.config.dev.ts", + "analyze:deps": "ANALYZE_DEPS=true webpack serve --config ./webpack.config.dev.ts --progress profile", + "build": "tsup" }, "dependencies": { + "@babel/parser": "7.22.5", + "@babel/traverse": "7.22.5", "@hookform/error-message": "^2.0.1", + "@medusajs/ui": "0.0.0-snapshot-20230816112538", + "@medusajs/ui-preset": "0.0.0-snapshot-20230816112538", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", "@radix-ui/react-accordion": "^1.0.1", "@radix-ui/react-avatar": "^1.0.1", "@radix-ui/react-collapsible": "^1.0.1", @@ -40,26 +49,37 @@ "@radix-ui/react-switch": "^1.0.1", "@radix-ui/react-tooltip": "^1.0.3", "@segment/analytics-next": "^1.51.1", + "@svgr/webpack": "^8.0.1", + "@swc/core": "^1.3.61", "@tailwindcss/forms": "^0.5.3", - "@tailwindcss/line-clamp": "^0.4.2", "@tanstack/react-query": "4.22.0", "@tanstack/react-table": "^8.7.9", - "@vitejs/plugin-react": "^3.1.0", "autoprefixer": "^10.4.13", + "chokidar": "^3.5.3", "clsx": "^1.2.1", "copy-to-clipboard": "^3.3.1", + "css-loader": "^6.8.1", "emoji-picker-react": "^4.4.3", "framer-motion": "^9.1.6", - "medusa-react": "^9.0.3", + "html-webpack-plugin": "^5.5.1", + "md5": "^2.3.0", + "medusa-react": "*", + "mini-css-extract-plugin": "^2.7.6", "moment": "^2.29.4", + "path-browserify": "^1.0.1", "pluralize": "^8.0.0", "postcss": "^8.4.21", + "postcss-loader": "^7.3.2", + "postcss-preset-env": "^8.4.1", + "prism-react-renderer": "^2.0.4", + "process": "^0.11.10", "query-string": "^8.1.0", "react": "^18.2.0", "react-collapsible": "^2.8.3", "react-country-flag": "^3.0.2", "react-currency-input-field": "^3.6.8", "react-datepicker": "^4.8.0", + "react-dev-utils": "^12.0.1", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", @@ -71,30 +91,39 @@ "react-json-tree": "^0.17.0", "react-jwt": "^1.1.4", "react-nestable": "^2.0.0", + "react-refresh": "^0.14.0", "react-router-dom": "6.8.0", "react-select": "^5.5.4", "react-table": "^7.7.0", + "source-map-loader": "^4.0.1", + "style-loader": "^3.3.3", + "swc-loader": "^0.2.3", + "swc-minify-webpack-plugin": "^2.1.1", "tailwindcss": "3.2.2", "tailwindcss-radix": "^2.7.0", + "ts-dedent": "^2.2.0", "type-fest": "^3.6.0", - "vite": "^4.1.4" - }, - "peerDependencies": { - "@medusajs/medusa": "^1.12.0" + "webpack": "^5.84.1", + "webpack-dev-server": "4.15.0", + "webpackbar": "^5.0.2" }, "devDependencies": { - "@medusajs/medusa": "^1.13.1", - "@medusajs/types": "^1.10.1", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^14.0.0", - "@testing-library/user-event": "^14.4.3", + "@babel/types": "7.22.5", + "@medusajs/medusa": "*", + "@medusajs/types": "*", "@types/pluralize": "^0.0.29", "@types/react": "^18.0.27", "@types/react-datepicker": "^4.10.0", "@types/react-dom": "^18.0.10", "@types/react-table": "^7.7.9", + "duplicate-dependencies-webpack-plugin": "^1.0.2", + "jest": "25.5.4", + "ts-jest": "25.5.1", + "ts-node": "^10.9.1", + "tsup": "6.7.0", "typescript": "^4.9.3", - "vitest": "^0.28.5" + "webpack-bundle-analyzer": "^4.9.0", + "webpack-cli": "^5.1.1" }, "packageManager": "yarn@3.2.1" } diff --git a/packages/admin-ui/scripts/create-dev-entry.js b/packages/admin-ui/scripts/create-dev-entry.js new file mode 100644 index 0000000000..e5f12d9213 --- /dev/null +++ b/packages/admin-ui/scripts/create-dev-entry.js @@ -0,0 +1,23 @@ +const fse = require("fs-extra") +const path = require("path") + +function createDevEntryFile() { + const devEntryContent = ` + const extensions = [] + + export default extensions + ` + + const devEntryPath = path.resolve( + __dirname, + "..", + "ui", + "src", + "extensions", + "_main-entry.ts" + ) + + fse.outputFileSync(devEntryPath, devEntryContent) +} + +createDevEntryFile() diff --git a/packages/admin-ui/src/client/index.ts b/packages/admin-ui/src/client/index.ts new file mode 100644 index 0000000000..2c6bf5bc56 --- /dev/null +++ b/packages/admin-ui/src/client/index.ts @@ -0,0 +1,16 @@ +export { forbiddenRoutes } from "../../ui/src/constants/forbidden-routes" +export { injectionZones } from "../../ui/src/constants/injection-zones" +export type { + Extension, + ForbiddenRoute, + InjectionZone, + RouteConfig, + RouteExtension, + RouteProps, + SettingConfig, + SettingExtension, + SettingProps, + WidgetConfig, + WidgetExtension, + WidgetProps, +} from "../../ui/src/types/extensions" diff --git a/packages/admin-ui/src/index.ts b/packages/admin-ui/src/index.ts index d95c44ceb7..c5fde3da89 100644 --- a/packages/admin-ui/src/index.ts +++ b/packages/admin-ui/src/index.ts @@ -1,42 +1,2 @@ -import dns from "dns" -import fse from "fs-extra" -import { resolve } from "path" -import vite from "vite" -import { AdminBuildConfig } from "./types" -import { AdminDevConfig } from "./types/dev" -import { getCustomViteConfig, getCustomViteDevConfig } from "./utils" - -async function build(options?: AdminBuildConfig) { - const config = getCustomViteConfig(options) - - await vite.build(config).catch((_err) => { - process.exit(1) - }) - - await fse.writeJSON( - resolve(config.build.outDir, "build-manifest.json"), - options - ) -} - -async function watch() { - throw new Error("Not implemented") -} - -async function clean() { - throw new Error("Not implemented") -} - -async function dev(options: AdminDevConfig) { - // Resolve localhost for Node v16 and older. - // @see https://vitejs.dev/config/server-options.html#server-host. - dns.setDefaultResultOrder("verbatim") - - const server = await vite.createServer(getCustomViteDevConfig(options)) - await server.listen() - - server.printUrls() -} - -export { build, dev, watch, clean } -export type { AdminBuildConfig, AdminDevConfig } +export * from "./client" +export * from "./node" diff --git a/packages/admin-ui/src/node/actions/build.ts b/packages/admin-ui/src/node/actions/build.ts new file mode 100644 index 0000000000..8a33b7e12f --- /dev/null +++ b/packages/admin-ui/src/node/actions/build.ts @@ -0,0 +1,58 @@ +import path from "node:path" +import webpack, { WebpackError } from "webpack" +import { BuildArgs } from "../types" +import { logger } from "../utils" +import { createCacheDir } from "../utils/create-cache-dir" +import { getCustomWebpackConfig } from "../webpack" + +/** + * Builds the admin UI. + */ +export async function build({ + appDir, + buildDir, + plugins, + options, + reporting = "fancy", +}: BuildArgs) { + await createCacheDir({ appDir, plugins }) + + const cacheDir = path.resolve(appDir, ".cache") + const entry = path.resolve(cacheDir, "admin", "src", "main.tsx") + const dest = path.resolve(appDir, buildDir) + const env = "production" + + const config = await getCustomWebpackConfig(appDir, { + entry, + dest, + cacheDir, + env, + options, + reporting, + }) + + const compiler = webpack(config) + + return new Promise((resolve, reject) => { + compiler.run((err: WebpackError, stats) => { + if (err) { + if (err.details) { + logger.error(err.details) + } + + reject(err) + } + + const info = stats.toJson() + + if (stats.hasErrors()) { + logger.error(JSON.stringify(info.errors)) + } + + return resolve({ + stats, + warnings: info.warnings, + }) + }) + }) +} diff --git a/packages/admin-ui/src/node/actions/clean.ts b/packages/admin-ui/src/node/actions/clean.ts new file mode 100644 index 0000000000..c1465e1f06 --- /dev/null +++ b/packages/admin-ui/src/node/actions/clean.ts @@ -0,0 +1,18 @@ +import fse from "fs-extra" +import path from "node:path" + +type CleanArgs = { + appDir: string + outDir: string +} + +/** + * Cleans the build directory and cache directory. + */ +export async function clean({ appDir, outDir }: CleanArgs) { + const cacheDir = path.resolve(appDir, ".cache", "admin") + const buildDir = path.resolve(appDir, outDir) + + await fse.remove(buildDir) + await fse.remove(cacheDir) +} diff --git a/packages/admin-ui/src/node/actions/develop.ts b/packages/admin-ui/src/node/actions/develop.ts new file mode 100644 index 0000000000..ea7418deed --- /dev/null +++ b/packages/admin-ui/src/node/actions/develop.ts @@ -0,0 +1,103 @@ +import path from "node:path" +import openBrowser from "react-dev-utils/openBrowser" +import webpack from "webpack" +import WebpackDevDerver, { + Configuration as DevServerConfiguration, +} from "webpack-dev-server" + +import { DevelopArgs } from "../types" +import { logger, watchLocalAdminFolder } from "../utils" +import { createCacheDir } from "../utils/create-cache-dir" +import { getCustomWebpackConfig } from "../webpack" + +/** + * Starts a development server for the admin UI. + */ +export async function develop({ + appDir, + buildDir, + plugins, + options = { + path: "/", + backend: "http://localhost:9000", + develop: { + open: true, + port: 7001, + logLevel: "error", + stats: "normal", + }, + }, +}: DevelopArgs) { + const { cacheDir } = await createCacheDir({ + appDir, + plugins, + }) + + const entry = path.resolve(cacheDir, "admin", "src", "main.tsx") + const dest = path.resolve(appDir, buildDir) + const env = "development" + + const config = await getCustomWebpackConfig(appDir, { + entry, + dest, + cacheDir, + env, + options, + }) + + const compiler = webpack({ + ...config, + infrastructureLogging: { level: options.develop.logLevel }, + stats: options.develop.stats === "normal" ? "errors-only" : undefined, + }) + + const devServerArgs: DevServerConfiguration = { + port: options.develop.port, + client: { + logging: "none", + overlay: { + errors: true, + warnings: false, + }, + }, + open: false, + onListening: options.develop.open + ? function (devServer) { + if (!devServer) { + logger.warn("Failed to open browser.") + } + + openBrowser( + `http://localhost:${options.develop.port}${ + options.path ? options.path : "" + }` + ) + } + : undefined, + devMiddleware: { + publicPath: options.path, + stats: options.develop.stats === "normal" ? false : undefined, + }, + historyApiFallback: { + index: options.path, + disableDotRule: true, + }, + hot: true, + } + + const server = new WebpackDevDerver(devServerArgs, compiler) + + const runServer = async () => { + logger.info( + `Started development server on http://localhost:${options.develop.port}${ + options.path ? options.path : "" + }` + ) + + await server.start() + } + + await runServer() + + await watchLocalAdminFolder(appDir, cacheDir, plugins) +} diff --git a/packages/admin-ui/src/node/actions/index.ts b/packages/admin-ui/src/node/actions/index.ts new file mode 100644 index 0000000000..3dae7bd34b --- /dev/null +++ b/packages/admin-ui/src/node/actions/index.ts @@ -0,0 +1,5 @@ +import { build } from "./build" +import { clean } from "./clean" +import { develop } from "./develop" + +export { clean, build, develop } diff --git a/packages/admin-ui/src/node/constants.ts b/packages/admin-ui/src/node/constants.ts new file mode 100644 index 0000000000..2b1e7e1234 --- /dev/null +++ b/packages/admin-ui/src/node/constants.ts @@ -0,0 +1,13 @@ +export const ALIASED_PACKAGES = [ + "react", + "react-dom", + "react-router-dom", + "react-dnd", + "react-dnd-html5-backend", + "react-select", + "react-helmet-async", + "@tanstack/react-query", + "@tanstack/react-table", + "@emotion/react", + "medusa-react", +] as const diff --git a/packages/admin-ui/src/node/index.ts b/packages/admin-ui/src/node/index.ts new file mode 100644 index 0000000000..403771e016 --- /dev/null +++ b/packages/admin-ui/src/node/index.ts @@ -0,0 +1,11 @@ +export { build, clean, develop } from "./actions" +export { ALIASED_PACKAGES } from "./constants" +export type { AdminOptions, DevelopArgs } from "./types" +export { + findAllValidRoutes, + findAllValidSettings, + findAllValidWidgets, + logger, + normalizePath, +} from "./utils" +export { withCustomWebpackConfig } from "./webpack" diff --git a/packages/admin-ui/src/node/types.ts b/packages/admin-ui/src/node/types.ts new file mode 100644 index 0000000000..2fed2975dd --- /dev/null +++ b/packages/admin-ui/src/node/types.ts @@ -0,0 +1,80 @@ +import type { Configuration } from "webpack" + +export type DevelopOptions = { + /** + * Determines whether the development server should open the admin dashboard + * in the browser. + * + * @default true + */ + open?: boolean + /** + * The port the development server should run on. + * @default 7001 + * */ + port?: number + /** + * Determines the log level of the development server. + * @default "error" + */ + logLevel?: "error" | "none" | "warn" | "info" | "log" | "verbose" + /** + * Determines the verbosity of the development server. + * @default "normal" + */ + stats?: "normal" | "debug" +} + +export type AdminOptions = { + /** + * The URL of your Medusa instance. + * + * This option will only be used if `serve` is `false`. + */ + backend?: string + /** + * The path to the admin dashboard. The path must be in the format of `/`. + * The chosen path cannot be one of the reserved paths: "admin", "store". + * @default "/app" + */ + path?: string + /** + * The directory to output the build to. By default the plugin will build + * the dashboard to the `build` directory in the root folder. + * @default undefined + */ + outDir?: string + /** + * Options for the development server. + */ + develop?: DevelopOptions +} + +type BuildReporting = "minimal" | "fancy" + +export type WebpackConfigArgs = { + entry: string + dest: string + cacheDir: string + env: "development" | "production" + options?: AdminOptions + template?: string + reporting?: BuildReporting +} + +export type CustomWebpackConfigArgs = WebpackConfigArgs & { + devServer?: Configuration["devServer"] +} + +type BaseArgs = { + appDir: string + buildDir: string + plugins?: string[] + options?: AdminOptions +} + +export type BuildArgs = BaseArgs & { + reporting?: BuildReporting +} + +export type DevelopArgs = BaseArgs diff --git a/packages/admin-ui/src/node/utils/__tests__/normalize-path.spec.ts b/packages/admin-ui/src/node/utils/__tests__/normalize-path.spec.ts new file mode 100644 index 0000000000..0c756c0d5a --- /dev/null +++ b/packages/admin-ui/src/node/utils/__tests__/normalize-path.spec.ts @@ -0,0 +1,58 @@ +import path from "path" +import { normalizePath } from "../normalize-path" + +describe("normalize path", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("normalizePath", () => { + it("should normalize a file path", async () => { + const testPath = path.join("/", "custom", "page.tsx") + + const result = normalizePath(testPath) + + expect(result).toEqual("/custom/page.tsx") + }) + + it("should normalize a file path with brackets", async () => { + const testPath = path.join("/", "custom", "[id]", "page.tsx") + + const result = normalizePath(testPath) + + expect(result).toEqual("/custom/[id]/page.tsx") + }) + }) + + describe("test windows platform", () => { + const originalPlatform = process.platform + + beforeAll(() => { + Object.defineProperty(process, "platform", { + value: "win32", + }) + }) + + afterAll(() => { + Object.defineProperty(process, "platform", { + value: originalPlatform, + }) + }) + + it("should normalize a file path on Windows", async () => { + const testPath = path.win32.join("/", "custom", "page.tsx") + + const result = normalizePath(testPath) + + expect(result).toEqual("/custom/page.tsx") + }) + + it("should normalize a file path with brackets on Windows", async () => { + const testPath = path.win32.join("/", "custom", "[id]", "page.tsx") + + const result = normalizePath(testPath) + + expect(result).toEqual("/custom/[id]/page.tsx") + }) + }) +}) diff --git a/packages/admin-ui/src/node/utils/__tests__/validate-extensions.spec.ts b/packages/admin-ui/src/node/utils/__tests__/validate-extensions.spec.ts new file mode 100644 index 0000000000..a94bb9c8e1 --- /dev/null +++ b/packages/admin-ui/src/node/utils/__tests__/validate-extensions.spec.ts @@ -0,0 +1,60 @@ +import path from "path" +import { createPath } from "../validate-extensions" + +describe("validate extensions", () => { + beforeEach(function () { + jest.clearAllMocks() + }) + + describe("createPath", () => { + it("should return a URL path", async () => { + const testPath = path.join("/", "custom", "page.tsx") + + const result = createPath(testPath) + + expect(result).toEqual("/custom") + }) + + it("should return a URL path with a parameter", async () => { + const testPath = path.join("/", "custom", "[id]", "page.tsx") + + const result = createPath(testPath) + + expect(result).toEqual("/custom/:id") + }) + }) + + describe("test windows platform", () => { + const originalPlatform = process.platform + + beforeAll(() => { + Object.defineProperty(process, "platform", { + value: "win32", + }) + }) + + afterAll(() => { + Object.defineProperty(process, "platform", { + value: originalPlatform, + }) + }) + + describe("createPath", () => { + it("should return a URL path on Windows", async () => { + const testPath = path.win32.join("/", "custom", "page.tsx") + + const result = createPath(testPath) + + expect(result).toEqual("/custom") + }) + + it("should return a URL path with a parameter on Windows", async () => { + const testPath = path.win32.join("/", "custom", "[id]", "page.tsx") + + const result = createPath(testPath) + + expect(result).toEqual("/custom/:id") + }) + }) + }) +}) diff --git a/packages/admin-ui/src/node/utils/copy-filter.ts b/packages/admin-ui/src/node/utils/copy-filter.ts new file mode 100644 index 0000000000..3ec568124e --- /dev/null +++ b/packages/admin-ui/src/node/utils/copy-filter.ts @@ -0,0 +1,22 @@ +import fse from "fs-extra" + +/** + * Filter function to exclude test files and folders, as well as webpack configurations from being copied to the cache folder. + */ +export function copyFilter(src: string) { + if (fse.lstatSync(src).isDirectory() && src.includes("__test__")) { + return false + } + + if (fse.lstatSync(src).isFile()) { + if ( + src.includes(".test") || + src.includes(".spec") || + src.includes("webpack.config") + ) { + return false + } + } + + return true +} diff --git a/packages/admin-ui/src/node/utils/create-cache-dir.ts b/packages/admin-ui/src/node/utils/create-cache-dir.ts new file mode 100644 index 0000000000..eecc906d10 --- /dev/null +++ b/packages/admin-ui/src/node/utils/create-cache-dir.ts @@ -0,0 +1,46 @@ +import fse from "fs-extra" +import path from "node:path" +import { copyFilter } from "./copy-filter" +import { createEntry } from "./create-entry" +import { logger } from "./logger" + +async function copyAdmin(dest: string) { + const adminDir = path.resolve(__dirname, "..", "ui") + const destDir = path.resolve(dest, "admin") + + try { + await fse.copy(adminDir, destDir, { + filter: copyFilter, + }) + } catch (err) { + logger.panic( + `Could not copy the admin UI to ${destDir}. See the error below for details:`, + { + error: err, + } + ) + } +} + +type CreateCacheDirArgs = { + appDir: string + plugins?: string[] +} + +async function createCacheDir({ appDir, plugins }: CreateCacheDirArgs) { + const cacheDir = path.resolve(appDir, ".cache") + + await copyAdmin(cacheDir) + + await createEntry({ + appDir, + dest: cacheDir, + plugins, + }) + + return { + cacheDir, + } +} + +export { createCacheDir } diff --git a/packages/admin-ui/src/node/utils/create-entry.ts b/packages/admin-ui/src/node/utils/create-entry.ts new file mode 100644 index 0000000000..d16e47e748 --- /dev/null +++ b/packages/admin-ui/src/node/utils/create-entry.ts @@ -0,0 +1,321 @@ +import fse from "fs-extra" +import path from "node:path" +import dedent from "ts-dedent" +import { copyFilter } from "./copy-filter" +import { logger } from "./logger" +import { normalizePath } from "./normalize-path" +import { + findAllValidRoutes, + findAllValidSettings, + findAllValidWidgets, +} from "./validate-extensions" + +const FILE_EXT_REGEX = /\.[^/.]+$/ + +async function copyLocalExtensions(src: string, dest: string) { + try { + await fse.copy(src, dest, { + filter: copyFilter, + }) + } catch (err) { + logger.error( + `Could not copy local extensions to cache folder. See the error below for details:`, + { + error: err, + } + ) + + return false + } + + return true +} + +/** + * Creates an entry file for any local extensions, if they exist. + */ +async function createLocalExtensionsEntry(appDir: string, dest: string) { + const localAdminDir = path.resolve(appDir, "src", "admin") + + const localAdminDirExists = await fse.pathExists(localAdminDir) + + if (!localAdminDirExists) { + return false + } + + const copied = await copyLocalExtensions( + localAdminDir, + path.resolve(dest, "admin", "src", "extensions") + ) + + if (!copied) { + logger.error( + "Could not copy local extensions to cache folder. See above error for details. The error must be fixed before any local extensions can be injected." + ) + return false + } + + const [localWidgets, localRoutes, localSettings] = await Promise.all([ + findAllValidWidgets( + path.resolve(dest, "admin", "src", "extensions", "widgets") + ), + findAllValidRoutes( + path.resolve(dest, "admin", "src", "extensions", "routes") + ), + findAllValidSettings( + path.resolve(dest, "admin", "src", "extensions", "settings") + ), + ]) + + const widgetsArray = localWidgets.map((file, index) => { + const relativePath = normalizePath( + path + .relative(path.resolve(dest, "admin", "src", "extensions"), file) + .replace(FILE_EXT_REGEX, "") + ) + + return { + importStatement: `import Widget${index}, { config as widgetConfig${index} } from "./${relativePath}"`, + extension: `{ Component: Widget${index}, config: { ...widgetConfig${index}, type: "widget" } }`, + } + }) + + const routesArray = localRoutes.map((route, index) => { + const relativePath = normalizePath( + path + .relative(path.resolve(dest, "admin", "src", "extensions"), route.file) + .replace(FILE_EXT_REGEX, "") + ) + + const importStatement = route.hasConfig + ? `import Page${index}, { config as routeConfig${index} } from "./${relativePath}"` + : `import Page${index} from "./${relativePath}"` + + const extension = route.hasConfig + ? `{ Component: Page${index}, config: { ...routeConfig${index}, type: "route", path: "${route.path}" } }` + : `{ Component: Page${index}, config: { path: "${route.path}", type: "route" } }` + + return { + importStatement, + extension, + } + }) + + const settingsArray = localSettings.map((setting, index) => { + const relativePath = normalizePath( + path + .relative( + path.resolve(dest, "admin", "src", "extensions"), + setting.file + ) + .replace(FILE_EXT_REGEX, "") + ) + + return { + importStatement: `import Setting${index}, { config as settingConfig${index} } from "./${relativePath}"`, + extension: `{ Component: Setting${index}, config: { ...settingConfig${index}, path: "${setting.path}", type: "setting" } }`, + } + }) + + const extensionsArray = [...widgetsArray, ...routesArray, ...settingsArray] + + const extensionsEntry = dedent` + ${extensionsArray.map((extension) => extension.importStatement).join("\n")} + + const LocalEntry = { + identifier: "local", + extensions: [ + ${extensionsArray.map((extension) => extension.extension).join(",\n")} + ], + } + + export default LocalEntry + ` + + try { + await fse.outputFile( + path.resolve(dest, "admin", "src", "extensions", "_local-entry.ts"), + extensionsEntry + ) + } catch (err) { + logger.panic( + `Failed to write the entry file for the local extensions. See the error below for details:`, + { + error: err, + } + ) + } + + return true +} + +function findPluginsWithExtensions(plugins: string[]) { + const pluginsWithExtensions: string[] = [] + + for (const plugin of plugins) { + try { + const pluginDir = path.dirname( + require.resolve(`${plugin}/package.json`, { + paths: [process.cwd()], + }) + ) + const entrypoint = path.resolve( + pluginDir, + "dist", + "admin", + "_virtual_entry.js" + ) + + if (fse.existsSync(entrypoint)) { + pluginsWithExtensions.push(entrypoint) + } + } catch (_err) { + logger.warn( + `There was an error while attempting to load extensions from the plugin: ${plugin}. Are you sure it is installed?` + ) + // no plugin found - noop + } + } + + return pluginsWithExtensions +} + +async function writeTailwindContentFile(dest: string, plugins: string[]) { + const tailwindContent = dedent` + const path = require("path") + + const devPath = path.join(__dirname, "..", "..", "src/admin/**/*.{js,jsx,ts,tsx}") + + module.exports = { + content: [ + devPath, + ${plugins + .map((plugin) => { + const tailwindContentPath = normalizePath( + path.relative( + path.resolve(dest, "admin"), + path.dirname(path.join(plugin, "..", "..")) + ) + ) + + return `"${tailwindContentPath}/dist/admin/**/*.{js,jsx,ts,tsx}"` + }) + .join(",\n")} + ], + } + + ` + + try { + await fse.outputFile( + path.resolve(dest, "admin", "tailwind.content.js"), + tailwindContent + ) + } catch (err) { + logger.warn( + `Failed to write the Tailwind content file to ${dest}. The admin UI will remain functional, but CSS classes applied to extensions from plugins might not have the correct styles` + ) + } +} + +async function createMainExtensionsEntry( + dest: string, + plugins: string[], + hasLocalExtensions: boolean +) { + if (!plugins.length && !hasLocalExtensions) { + // We still want to generate the entry file, even if there are no extensions + // to load, so that the admin UI can be built without errors + const emptyEntry = dedent` + const extensions = [] + + export default extensions + ` + + try { + await fse.outputFile( + path.resolve(dest, "admin", "src", "extensions", "_main-entry.ts"), + emptyEntry + ) + } catch (err) { + logger.panic( + `Failed to write the entry file for the main extensions. See the error below for details:`, + { + error: err, + } + ) + } + + return + } + + const pluginsArray = plugins.map((plugin) => { + const relativePath = normalizePath( + path + .relative(path.resolve(dest, "admin", "src", "extensions"), plugin) + .replace(FILE_EXT_REGEX, "") + ) + + return relativePath + }) + + const extensionsArray = [ + ...pluginsArray.map((plugin, index) => { + const importStatement = `import Plugin${index} from "${plugin}"` + + return { + importStatement, + extension: `Plugin${index}`, + } + }), + ...(hasLocalExtensions + ? [ + { + importStatement: `import LocalEntry from "./_local-entry"`, + extension: `LocalEntry`, + }, + ] + : []), + ] + + const extensionsEntry = dedent` + ${extensionsArray + .map((extension) => extension.importStatement) + .join("\n")} + + const extensions = [ + ${extensionsArray.map((extension) => extension.extension).join(",\n")} + ] + + export default extensions + ` + + try { + await fse.outputFile( + path.resolve(dest, "admin", "src", "extensions", "_main-entry.ts"), + extensionsEntry + ) + } catch (err) { + logger.panic( + `Failed to write the extensions entry file. See the error below for details:`, + { + error: err, + } + ) + } +} + +type CreateEntryArgs = { + appDir: string + dest: string + plugins?: string[] +} + +export async function createEntry({ appDir, dest, plugins }: CreateEntryArgs) { + const hasLocalExtensions = await createLocalExtensionsEntry(appDir, dest) + + const adminPlugins = findPluginsWithExtensions(plugins) + + await createMainExtensionsEntry(dest, adminPlugins, hasLocalExtensions) + await writeTailwindContentFile(dest, adminPlugins) +} diff --git a/packages/admin-ui/src/node/utils/get-client-env.ts b/packages/admin-ui/src/node/utils/get-client-env.ts new file mode 100644 index 0000000000..0a795f71da --- /dev/null +++ b/packages/admin-ui/src/node/utils/get-client-env.ts @@ -0,0 +1,61 @@ +import dotenv from "dotenv" +import fse from "fs-extra" +import path from "node:path" + +const MEDUSA_ADMIN = /^MEDUSA_ADMIN_/i + +let ENV_FILE_NAME = "" +switch (process.env.NODE_ENV) { + case "production": + ENV_FILE_NAME = ".env.production" + break + case "staging": + ENV_FILE_NAME = ".env.staging" + break + case "test": + ENV_FILE_NAME = ".env.test" + break + case "development": + default: + ENV_FILE_NAME = ".env" + break +} + +if (fse.existsSync(ENV_FILE_NAME)) { + dotenv.config({ path: path.resolve(process.cwd(), ENV_FILE_NAME) }) +} else if (ENV_FILE_NAME !== ".env") { + // Fall back to .env if the specified file does not exist + dotenv.config({ path: path.resolve(process.cwd(), ".env") }) +} + +type GetClientEnvArgs = { + path?: string + env?: string + backend?: string +} + +export const getClientEnv = (args: GetClientEnvArgs) => { + const raw = Object.keys(process.env) + .filter((key) => MEDUSA_ADMIN.test(key)) + .reduce( + (acc, current) => { + acc[current] = process.env[current] + + return acc + }, + { + ADMIN_PATH: args.path || "/", + NODE_ENV: args.env || "development", + MEDUSA_BACKEND_URL: args.backend || process.env.MEDUSA_BACKEND_URL, + } + ) + + const stringified = { + "process.env": Object.keys(raw).reduce((env, key) => { + env[key] = JSON.stringify(raw[key]) + return env + }, {}), + } + + return stringified +} diff --git a/packages/admin-ui/src/node/utils/index.ts b/packages/admin-ui/src/node/utils/index.ts new file mode 100644 index 0000000000..04386a7c3d --- /dev/null +++ b/packages/admin-ui/src/node/utils/index.ts @@ -0,0 +1,27 @@ +import { createCacheDir } from "./create-cache-dir" +import { getClientEnv } from "./get-client-env" +import { logger } from "./logger" +import { normalizePath } from "./normalize-path" +import { + findAllValidRoutes, + findAllValidSettings, + findAllValidWidgets, + validateRoute, + validateSetting, + validateWidget, +} from "./validate-extensions" +import { watchLocalAdminFolder } from "./watch-local-admin-folder" + +export { + logger, + normalizePath, + getClientEnv, + createCacheDir, + validateWidget, + validateRoute, + validateSetting, + findAllValidWidgets, + findAllValidRoutes, + findAllValidSettings, + watchLocalAdminFolder, +} diff --git a/packages/admin-ui/src/node/utils/logger.ts b/packages/admin-ui/src/node/utils/logger.ts new file mode 100644 index 0000000000..247dc00ad9 --- /dev/null +++ b/packages/admin-ui/src/node/utils/logger.ts @@ -0,0 +1,74 @@ +import colors from "picocolors" +import readline from "readline" + +const prefix = "[@medusajs/admin]" + +type LogType = "error" | "warn" | "info" + +interface LogOptions { + clearScreen?: boolean +} + +interface LogErrorOptions extends LogOptions { + error?: Error | null +} + +interface Logger { + info(msg: string, options?: LogOptions): void + warn(msg: string, options?: LogOptions): void + error(msg: string, options?: LogErrorOptions): void + panic(msg: string, options?: LogErrorOptions): void +} + +function clearScreen() { + const repeatCount = process.stdout.rows - 2 + const blank = repeatCount > 0 ? "\n".repeat(repeatCount) : "" + console.log(blank) + readline.cursorTo(process.stdout, 0, 0) + readline.clearScreenDown(process.stdout) +} + +const canClearScreen = process.stdout.isTTY && !process.env.CI +const clear = canClearScreen + ? clearScreen + : () => { + // noop + } + +function createLogger(): Logger { + const output = (type: LogType, msg: string, options?: LogErrorOptions) => { + const method = type === "info" ? "log" : type + const format = () => { + const tag = + type === "info" + ? colors.cyan(colors.bold(prefix)) + : type === "warn" + ? colors.yellow(colors.bold(prefix)) + : colors.red(colors.bold(prefix)) + return `${colors.dim(new Date().toLocaleTimeString())} ${tag} ${msg}` + } + + if (options?.clearScreen) { + clear() + } + + console[method](format()) + + if (options?.error) { + console.error(options.error) + } + } + + return { + info: (msg, options) => output("info", msg, options), + warn: (msg, options) => output("warn", msg, options), + error: (msg, options) => output("error", msg, options), + panic: (msg, options) => { + output("error", msg, options) + output("error", "Exiting process", {}) + process.exit(1) + }, + } +} + +export const logger = createLogger() diff --git a/packages/admin-ui/src/node/utils/normalize-path.ts b/packages/admin-ui/src/node/utils/normalize-path.ts new file mode 100644 index 0000000000..32a4eae164 --- /dev/null +++ b/packages/admin-ui/src/node/utils/normalize-path.ts @@ -0,0 +1,6 @@ +export function normalizePath(path: string): string { + const isWindows = process.platform === "win32" + const separator = isWindows ? "\\" : "/" + const regex = new RegExp(`\\${separator}`, "g") + return path.replace(regex, "/") +} diff --git a/packages/admin-ui/src/node/utils/validate-args.ts b/packages/admin-ui/src/node/utils/validate-args.ts new file mode 100644 index 0000000000..fc8de6b99a --- /dev/null +++ b/packages/admin-ui/src/node/utils/validate-args.ts @@ -0,0 +1,28 @@ +import { CustomWebpackConfigArgs } from "../types" +import { logger } from "./logger" + +function validateArgs(args: CustomWebpackConfigArgs) { + const { options } = args + + if (options.path) { + if (!options.path.startsWith("/")) { + logger.panic( + "'path' in the options of `@medusajs/admin` must start with a '/'" + ) + } + + if (options.path !== "/" && options.path.endsWith("/")) { + logger.panic( + "'path' in the options of `@medusajs/admin` cannot end with a '/'" + ) + } + + if (typeof options.path !== "string") { + logger.panic( + "'path' in the options of `@medusajs/admin` must be a string" + ) + } + } +} + +export { validateArgs } diff --git a/packages/admin-ui/src/node/utils/validate-extensions.ts b/packages/admin-ui/src/node/utils/validate-extensions.ts new file mode 100644 index 0000000000..a5e61d4b37 --- /dev/null +++ b/packages/admin-ui/src/node/utils/validate-extensions.ts @@ -0,0 +1,685 @@ +import { parse, ParseResult, ParserOptions } from "@babel/parser" +import traverse, { NodePath } from "@babel/traverse" +import type { + ExportDefaultDeclaration, + ExportNamedDeclaration, + ObjectExpression, + ObjectMethod, + ObjectProperty, + SpreadElement, +} from "@babel/types" +import fse from "fs-extra" +import path from "path" +import { forbiddenRoutes, InjectionZone, injectionZones } from "../../client" +import { logger } from "./logger" +import { normalizePath } from "./normalize-path" + +function isValidInjectionZone(zone: any): zone is InjectionZone { + return injectionZones.includes(zone) +} + +/** + * Validates that the widget config export is valid. + * In order to be valid it must have a `zone` property that is either a `InjectionZone` or a `InjectionZone` array. + */ +function validateWidgetConfigExport( + properties: (ObjectMethod | ObjectProperty | SpreadElement)[] +): boolean { + const zoneProperty = properties.find( + (p) => + p.type === "ObjectProperty" && + p.key.type === "Identifier" && + p.key.name === "zone" + ) as ObjectProperty | undefined + + if (!zoneProperty) { + return false + } + + let zoneIsValid = false + + if (zoneProperty.value.type === "StringLiteral") { + zoneIsValid = isValidInjectionZone(zoneProperty.value.value) + } else if (zoneProperty.value.type === "ArrayExpression") { + zoneIsValid = zoneProperty.value.elements.every( + (zone) => + zone.type === "StringLiteral" && isValidInjectionZone(zone.value) + ) + } + + return zoneIsValid +} + +function validateRouteConfigExport( + properties: (ObjectMethod | ObjectProperty | SpreadElement)[] +): boolean { + const linkProperty = properties.find( + (p) => + p.type === "ObjectProperty" && + p.key.type === "Identifier" && + p.key.name === "link" + ) as ObjectProperty | undefined + + // Link property is optional for routes + if (!linkProperty) { + return true + } + + const linkValue = linkProperty.value as ObjectExpression + + let labelIsValid = false + + // Check that the linkProperty is an object and has a `label` property that is a string + if ( + linkValue.properties.some( + (p) => + p.type === "ObjectProperty" && + p.key.type === "Identifier" && + p.key.name === "label" && + p.value.type === "StringLiteral" + ) + ) { + labelIsValid = true + } + + return labelIsValid +} + +function validateSettingConfigExport( + properties: (ObjectMethod | ObjectProperty | SpreadElement)[] +): boolean { + const cardProperty = properties.find( + (p) => + p.type === "ObjectProperty" && + p.key.type === "Identifier" && + p.key.name === "card" + ) as ObjectProperty | undefined + + // Link property is required for settings + if (!cardProperty) { + return false + } + + const cardValue = cardProperty.value as ObjectExpression + + let hasLabel = false + let hasDescription = false + + if ( + cardValue.properties.some( + (p) => + p.type === "ObjectProperty" && + p.key.type === "Identifier" && + p.key.name === "label" && + p.value.type === "StringLiteral" + ) + ) { + hasLabel = true + } + + if ( + cardValue.properties.some( + (p) => + p.type === "ObjectProperty" && + p.key.type === "Identifier" && + p.key.name === "description" && + p.value.type === "StringLiteral" + ) + ) { + hasDescription = true + } + + return hasLabel && hasDescription +} + +function validateConfigExport( + path: NodePath, + type: "widget" | "route" | "setting" +) { + let hasValidConfigExport = false + + const declaration = path.node.declaration + if (declaration && declaration.type === "VariableDeclaration") { + const configDeclaration = declaration.declarations.find( + (d) => + d.type === "VariableDeclarator" && + d.id.type === "Identifier" && + d.id.name === "config" + ) + + if ( + configDeclaration && + configDeclaration.init.type === "ObjectExpression" + ) { + const properties = configDeclaration.init.properties + + if (type === "widget") { + hasValidConfigExport = validateWidgetConfigExport(properties) + } + + if (type === "route") { + hasValidConfigExport = validateRouteConfigExport(properties) + } + + if (type === "setting") { + hasValidConfigExport = validateSettingConfigExport(properties) + } + } else { + hasValidConfigExport = false + } + } + + return hasValidConfigExport +} + +/** + * Validates that the default export of a file is a valid React component. + * This is determined by checking if the default export is a function declaration + * with a return statement that returns a JSX element or fragment. + */ +function validateDefaultExport( + path: NodePath, + ast: ParseResult +) { + let hasComponentExport = false + + const declaration = path.node.declaration + if ( + declaration && + (declaration.type === "Identifier" || + declaration.type === "FunctionDeclaration") + ) { + const exportName = + declaration.type === "Identifier" + ? declaration.name + : declaration.id && declaration.id.name + + if (exportName) { + try { + traverse(ast, { + VariableDeclarator({ node, scope }) { + let isDefaultExport = false + + if (node.id.type === "Identifier" && node.id.name === exportName) { + isDefaultExport = true + } + + if (!isDefaultExport) { + return + } + + traverse( + node, + { + ReturnStatement(path) { + if ( + path.node.argument?.type === "JSXElement" || + path.node.argument?.type === "JSXFragment" + ) { + hasComponentExport = true + } + }, + }, + scope + ) + }, + }) + } catch (e) { + logger.error( + `There was an error while validating the default export of ${path}. The following error must be resolved before continuing:`, + { + error: e, + } + ) + return false + } + } + } + + return hasComponentExport +} + +/** + * Validates that a widget file has a valid default export and a valid config export. + * + */ +async function validateWidget(file: string) { + const content = await fse.readFile(file, "utf-8") + + const parserOptions: ParserOptions = { + sourceType: "module", + plugins: ["jsx"], + } + + if (file.endsWith(".ts") || file.endsWith(".tsx")) { + parserOptions.plugins.push("typescript") + } + + let ast: ParseResult + + try { + ast = parse(content, parserOptions) + } catch (e) { + logger.error( + `An error occurred while parsing the Widget "${file}", and the Widget cannot be injected. The following error must be resolved before continuing:`, + { + error: e, + } + ) + return false + } + + let hasConfigExport = false + let hasComponentExport = false + + try { + traverse(ast, { + ExportDefaultDeclaration: (path) => { + hasComponentExport = validateDefaultExport(path, ast) + }, + ExportNamedDeclaration: (path) => { + hasConfigExport = validateConfigExport(path, "widget") + }, + }) + } catch (e) { + logger.error( + `An error occurred while validating the Widget "${file}". The following error must be resolved before continuing:`, + { + error: e, + } + ) + return false + } + + if (hasConfigExport && !hasComponentExport) { + if (!hasComponentExport) { + logger.error( + `The default export in the Widget "${file}" is invalid and the widget will not be injected. Please make sure that the default export is a valid React component.` + ) + } + } + + if (!hasConfigExport && hasComponentExport) { + logger.error( + `The Widget config export in "${file}" is invalid and the Widget cannot be injected. Please ensure that the config is valid.` + ) + } + + return hasConfigExport && hasComponentExport +} + +/** + * This function takes a file path and converts it to a URL path. + * It converts the file path to a URL path by replacing any + * square brackets with colons, and then removing the "page.[jt]s" suffix. + */ +function createPath(filePath: string): string { + const normalizedPath = normalizePath(filePath) + + const regex = /\[(.*?)\]/g + const strippedPath = normalizedPath.replace(regex, ":$1") + + const url = strippedPath.replace(/\/page\.[jt]sx?$/i, "") + + return url +} + +function isForbiddenRoute(path: any): boolean { + return forbiddenRoutes.includes(path) +} + +function validatePath( + path: string, + origin: string +): { + valid: boolean + error: string +} { + if (isForbiddenRoute(path)) { + return { + error: `A route from ${origin} is using a forbidden path: ${path}.`, + valid: false, + } + } + + const specialChars = ["/", ":", "-"] + + for (let i = 0; i < path.length; i++) { + const currentChar = path[i] + + if ( + !specialChars.includes(currentChar) && + !/^[a-z0-9]$/i.test(currentChar) + ) { + return { + error: `A route from ${origin} is using an invalid path: ${path}. Only alphanumeric characters, "/", ":", and "-" are allowed.`, + valid: false, + } + } + + if (currentChar === ":" && (i === 0 || path[i - 1] !== "/")) { + return { + error: `A route from ${origin} is using an invalid path: ${path}. All dynamic segments must be preceded by a "/".`, + valid: false, + } + } + } + + return { + valid: true, + error: "", + } +} + +/** + * Validates that a file is a valid route. + * This is determined by checking if the file exports a valid React component + * as the default export, and a optional route config as a named export. + * If the file is not a valid route, `null` is returned. + * If the file is a valid route, a `ValidRouteResult` is returned. + */ +async function validateRoute( + file: string, + basePath: string +): Promise<{ + path: string + hasConfig: boolean + file: string +} | null> { + const cleanPath = createPath(file.replace(basePath, "")) + + const { valid, error } = validatePath(cleanPath, file) + + if (!valid) { + logger.error( + `The path ${cleanPath} for the UI Route "${file}" is invalid and the route cannot be injected. The following error must be fixed before the route can be injected: ${error}` + ) + + return null + } + + const content = await fse.readFile(file, "utf-8") + + let hasComponentExport = false + let hasConfigExport = false + + const parserOptions: ParserOptions = { + sourceType: "module", + plugins: ["jsx"], + } + + if (file.endsWith(".ts") || file.endsWith(".tsx")) { + parserOptions.plugins.push("typescript") + } + + let ast: ParseResult + + try { + ast = parse(content, parserOptions) + } catch (e) { + logger.error( + `An error occurred while parsing the UI Route "${file}", and the UI Route cannot be injected. The following error must be resolved before continuing:`, + { + error: e, + } + ) + return null + } + + try { + traverse(ast, { + ExportDefaultDeclaration: (path) => { + hasComponentExport = validateDefaultExport(path, ast) + }, + ExportNamedDeclaration: (path) => { + hasConfigExport = validateConfigExport(path, "route") + }, + }) + } catch (e) { + logger.error( + `An error occurred while validating the UI Route "${file}", and the UI Route cannot be injected. The following error must be resolved before continuing:`, + { + error: e, + } + ) + return null + } + + if (!hasComponentExport) { + logger.error( + `The default export in the UI Route "${file}" is invalid and the route cannot be injected. Please make sure that the default export is a valid React component.` + ) + + return null + } + + return { + path: cleanPath, + hasConfig: hasConfigExport, + file, + } +} + +async function validateSetting(file: string, basePath: string) { + const cleanPath = createPath(file.replace(basePath, "")) + + const { valid, error } = validatePath(cleanPath, file) + + if (!valid) { + logger.error( + `The path ${cleanPath} for the Setting "${file}" is invalid and the setting cannot be injected. The following error must be fixed before the Setting can be injected: ${error}` + ) + + return null + } + + const content = await fse.readFile(file, "utf-8") + + let hasComponentExport = false + let hasConfigExport = false + + const parserOptions: ParserOptions = { + sourceType: "module", + plugins: ["jsx"], + } + + if (file.endsWith(".ts") || file.endsWith(".tsx")) { + parserOptions.plugins.push("typescript") + } + + let ast: ParseResult + + try { + ast = parse(content, parserOptions) + } catch (e) { + logger.error( + ` + An error occured while parsing the Setting "${file}". The following error must be resolved before continuing: + `, + { + error: e, + } + ) + + return null + } + + try { + traverse(ast, { + ExportDefaultDeclaration: (path) => { + hasComponentExport = validateDefaultExport(path, ast) + }, + ExportNamedDeclaration: (path) => { + hasConfigExport = validateConfigExport(path, "setting") + }, + }) + } catch (e) { + logger.error( + ` + An error occured while validating the Setting "${file}". The following error must be resolved before continuing:`, + { + error: e, + } + ) + return null + } + + if (!hasComponentExport) { + logger.error( + `The default export in the Setting "${file}" is invalid and the page will not be injected. Please make sure that the default export is a valid React component.` + ) + + return null + } + + if (!hasConfigExport) { + logger.error( + `The named export "config" in the Setting "${file}" is invalid or missing and the settings page will not be injected. Please make sure that the file exports a valid config.` + ) + + return null + } + + return { + path: cleanPath, + file, + } +} + +async function findAllValidSettings(dir: string) { + const settingsFiles: string[] = [] + + const dirExists = await fse.pathExists(dir) + + if (!dirExists) { + return [] + } + + const paths = await fse.readdir(dir) + + let hasSubDirs = false + + // We only check the first level of directories for settings files + for (const pa of paths) { + const filePath = path.join(dir, pa) + const fileStat = await fse.stat(filePath) + + if (fileStat.isDirectory()) { + const files = await fse.readdir(filePath) + + for (const file of files) { + const filePath = path.join(dir, pa, file) + const fileStat = await fse.stat(filePath) + + if (fileStat.isFile() && /^(.*\/)?page\.[jt]sx?$/i.test(file)) { + settingsFiles.push(filePath) + break + } else if (fileStat.isDirectory()) { + hasSubDirs = true + } + } + } + } + + if (hasSubDirs) { + logger.warn( + `The directory ${dir} contains subdirectories. Settings do not support nested routes, only UI Routes support nested paths.` + ) + } + + const validSettingsFiles = await Promise.all( + settingsFiles.map(async (file) => validateSetting(file, dir)) + ) + + return validSettingsFiles.filter((file) => file !== null) +} + +/** + * Scans a directory for valid widgets. + * A valid widget is a file that exports a valid widget config and a valid React component. + */ +async function findAllValidWidgets(dir: string) { + const jsxAndTsxFiles: string[] = [] + + const dirExists = await fse.pathExists(dir) + + if (!dirExists) { + return [] + } + + async function traverseDirectory(currentPath: string) { + const files = await fse.readdir(currentPath) + + for (const file of files) { + const filePath = path.join(currentPath, file) + const fileStat = await fse.stat(filePath) + + if (fileStat.isDirectory()) { + await traverseDirectory(filePath) + } else if (fileStat.isFile() && /\.(js|jsx|ts|tsx)$/i.test(file)) { + jsxAndTsxFiles.push(filePath) + } + } + } + + await traverseDirectory(dir) + + const promises = jsxAndTsxFiles.map((file) => { + const isValid = validateWidget(file) + + return isValid ? file : null + }) + + const validFiles = await Promise.all(promises) + + return validFiles.filter((file) => file !== null) +} + +/** + * Scans a directory for valid routes. + * A valid route is a file that exports a optional route config and a valid React component. + */ +async function findAllValidRoutes(dir: string) { + const pageFiles: string[] = [] + + const dirExists = await fse.pathExists(dir) + + if (!dirExists) { + return [] + } + + async function traverseDirectory(currentPath: string) { + const files = await fse.readdir(currentPath) + + for (const file of files) { + const filePath = path.join(currentPath, file) + const fileStat = await fse.stat(filePath) + + if (fileStat.isDirectory()) { + await traverseDirectory(filePath) + } else if (fileStat.isFile() && /^(.*\/)?page\.[jt]sx?$/i.test(file)) { + pageFiles.push(filePath) + } + } + } + + await traverseDirectory(dir) + + const promises = pageFiles.map(async (file) => { + return validateRoute(file, dir) + }) + + const validFiles = await Promise.all(promises) + + return validFiles.filter((file) => file !== null) +} + +export { + createPath, + validateWidget, + validateRoute, + validateSetting, + findAllValidSettings, + findAllValidWidgets, + findAllValidRoutes, +} diff --git a/packages/admin-ui/src/node/utils/watch-local-admin-folder.ts b/packages/admin-ui/src/node/utils/watch-local-admin-folder.ts new file mode 100644 index 0000000000..362219878a --- /dev/null +++ b/packages/admin-ui/src/node/utils/watch-local-admin-folder.ts @@ -0,0 +1,57 @@ +import chokidar from "chokidar" +import fse from "fs-extra" +import path from "node:path" +import { createEntry } from "./create-entry" +import { logger } from "./logger" + +/** + * Watches the local admin directory for changes and updates the extensions cache directory accordingly. + */ +export async function watchLocalAdminFolder( + appDir: string, + cacheDir: string, + plugins: string[] +) { + const adminDir = path.resolve(appDir, "src", "admin") + + const watcher = chokidar.watch(adminDir, { + ignored: /(^|[/\\])\../, + ignoreInitial: true, + }) + + watcher.on("all", async (event, file) => { + if (event === "unlinkDir" || event === "unlink") { + removeUnlinkedFile(file, appDir, cacheDir) + } + + await createEntry({ + appDir, + dest: cacheDir, + plugins, + }) + + logger.info("Extensions cache directory was re-initialized") + }) + + process + .on("SIGINT", async () => { + await watcher.close() + }) + .on("SIGTERM", async () => { + await watcher.close() + }) +} + +function removeUnlinkedFile(file: string, appDir: string, cacheDir: string) { + const srcDir = path.resolve(appDir, "src", "admin") + const relativePath = path.relative(srcDir, file) + + const destDir = path.resolve(cacheDir, "admin", "src", "extensions") + const fileToDelete = path.resolve(destDir, relativePath) + + try { + fse.removeSync(fileToDelete) + } catch (error) { + logger.error(`An error occurred while removing ${fileToDelete}: ${error}`) + } +} diff --git a/packages/admin-ui/src/node/webpack/get-custom-webpack-config.ts b/packages/admin-ui/src/node/webpack/get-custom-webpack-config.ts new file mode 100644 index 0000000000..75a7634574 --- /dev/null +++ b/packages/admin-ui/src/node/webpack/get-custom-webpack-config.ts @@ -0,0 +1,52 @@ +import fse from "fs-extra" +import path from "node:path" +import webpack from "webpack" +import { CustomWebpackConfigArgs } from "../types" +import { logger } from "../utils" +import { validateArgs } from "../utils/validate-args" +import { getWebpackConfig } from "./get-webpack-config" +import { withCustomWebpackConfig } from "./with-custom-webpack-config" + +export async function getCustomWebpackConfig( + appDir: string, + args: CustomWebpackConfigArgs +) { + validateArgs(args) + + let config = getWebpackConfig(args) + + const adminConfigPath = path.join(appDir, "src", "admin", "webpack.config.js") + + const pathExists = await fse.pathExists(adminConfigPath) + + if (pathExists) { + let webpackAdminConfig: ReturnType + + try { + webpackAdminConfig = require(adminConfigPath) + } catch (e) { + logger.panic( + `An error occured while trying to load your custom Webpack config. See the error below for details:`, + { + error: e, + } + ) + } + + if (typeof webpackAdminConfig === "function") { + if (args.devServer) { + config.devServer = args.devServer + } + + config = webpackAdminConfig(config, webpack) + + if (!config) { + logger.panic( + "Nothing was returned from your custom webpack configuration" + ) + } + } + } + + return config +} diff --git a/packages/admin-ui/src/node/webpack/get-webpack-config.ts b/packages/admin-ui/src/node/webpack/get-webpack-config.ts new file mode 100644 index 0000000000..77661b4346 --- /dev/null +++ b/packages/admin-ui/src/node/webpack/get-webpack-config.ts @@ -0,0 +1,185 @@ +import ReactRefreshPlugin from "@pmmmwh/react-refresh-webpack-plugin" +import HtmlWebpackPlugin from "html-webpack-plugin" +import MiniCssExtractPlugin from "mini-css-extract-plugin" +import path from "node:path" +import { SwcMinifyWebpackPlugin } from "swc-minify-webpack-plugin" +import type { Configuration } from "webpack" +import webpack from "webpack" +import WebpackBar from "webpackbar" +import { WebpackConfigArgs } from "../types" +import { getClientEnv } from "../utils" +import { webpackAliases } from "./webpack-aliases" + +function formatPublicPath(path?: string) { + if (!path) { + return "/app/" + } + + if (path === "/") { + return path + } + + return path.endsWith("/") ? path : `${path}/` +} + +export function getWebpackConfig({ + entry, + dest, + cacheDir, + env, + options, + template, + reporting = "fancy", +}: WebpackConfigArgs): Configuration { + const isProd = env === "production" + + const envVars = getClientEnv({ + env, + backend: options?.backend, + path: options?.path, + }) + + const publicPath = formatPublicPath(options?.path) + + const webpackPlugins = isProd + ? [ + new MiniCssExtractPlugin({ + filename: "[name].[chunkhash].css", + chunkFilename: "[name].[chunkhash].css", + }), + new WebpackBar({ + basic: reporting === "minimal", + fancy: reporting === "fancy", + }), + ] + : [new MiniCssExtractPlugin()] + + return { + mode: env, + bail: !!isProd, + devtool: isProd ? false : "eval-source-map", + entry: [entry], + output: { + path: dest, + filename: isProd ? "[name].[contenthash:8].js" : "[name].bundle.js", + chunkFilename: isProd + ? "[name].[contenthash:8].chunk.js" + : "[name].chunk.js", + }, + optimization: { + minimize: true, + minimizer: [new SwcMinifyWebpackPlugin()], + moduleIds: "deterministic", + runtimeChunk: true, + }, + module: { + rules: [ + { + test: /\.tsx?$/, + exclude: /node_modules/, + include: [cacheDir], + use: { + loader: "swc-loader", + options: { + jsc: { + parser: { + syntax: "typescript", // Use TypeScript syntax for parsing + jsx: true, // Enable JSX parsing + }, + transform: { + react: { + runtime: "automatic", + }, + }, + }, + }, + }, + }, + { + test: /\.jsx?$/, + exclude: /node_modules/, + include: [cacheDir], + use: { + loader: "swc-loader", + options: { + jsc: { + parser: { + syntax: "ecmascript", // Use Ecmascript syntax for parsing + jsx: true, // Enable JSX parsing + }, + transform: { + react: { + runtime: "automatic", + }, + }, + }, + }, + }, + }, + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader", "postcss-loader"], + }, + { + test: /\.svg$/, + oneOf: [ + { + type: "asset/resource", + resourceQuery: /url/, + }, + { + type: "asset/inline", + resourceQuery: /base64/, + }, + { + issuer: /\.[jt]sx?$/, + use: ["@svgr/webpack"], + }, + ], + generator: { + filename: `images/${isProd ? "[name]-[hash][ext]" : "[name][ext]"}`, + }, + }, + { + test: /\.(eot|otf|ttf|woff|woff2)$/, + type: "asset/resource", + }, + { + test: /\.(js|mjs)(\.map)?$/, + enforce: "pre", + use: ["source-map-loader"], + }, + { + test: /\.m?jsx?$/, + resolve: { + fullySpecified: false, + }, + }, + ], + }, + resolve: { + alias: webpackAliases, + symlinks: false, + extensions: [".js", ".jsx", ".ts", ".tsx"], + mainFields: ["browser", "module", "main"], + modules: ["node_modules", path.resolve(__dirname, "..", "node_modules")], + fallback: { + readline: false, + path: false, + }, + }, + plugins: [ + new HtmlWebpackPlugin({ + inject: true, + template: template || path.resolve(__dirname, "..", "ui", "index.html"), + publicPath: publicPath, + }), + + new webpack.DefinePlugin(envVars), + + !isProd && new ReactRefreshPlugin(), + + ...webpackPlugins, + ].filter(Boolean), + } +} diff --git a/packages/admin-ui/src/node/webpack/index.ts b/packages/admin-ui/src/node/webpack/index.ts new file mode 100644 index 0000000000..b67e229172 --- /dev/null +++ b/packages/admin-ui/src/node/webpack/index.ts @@ -0,0 +1,5 @@ +import { getCustomWebpackConfig } from "./get-custom-webpack-config" +import { getWebpackConfig } from "./get-webpack-config" +import { withCustomWebpackConfig } from "./with-custom-webpack-config" + +export { getCustomWebpackConfig, getWebpackConfig, withCustomWebpackConfig } diff --git a/packages/admin-ui/src/node/webpack/webpack-aliases.ts b/packages/admin-ui/src/node/webpack/webpack-aliases.ts new file mode 100644 index 0000000000..3193f9a7a6 --- /dev/null +++ b/packages/admin-ui/src/node/webpack/webpack-aliases.ts @@ -0,0 +1,9 @@ +import { ALIASED_PACKAGES } from "../constants" + +/** + * Ensure that the admin-ui uses the same version of these packages as the project. + */ +export const webpackAliases = ALIASED_PACKAGES.reduce((acc, pkg) => { + acc[`${pkg}$`] = require.resolve(pkg) + return acc +}, {}) diff --git a/packages/admin-ui/src/node/webpack/with-custom-webpack-config.ts b/packages/admin-ui/src/node/webpack/with-custom-webpack-config.ts new file mode 100644 index 0000000000..0d7510bf06 --- /dev/null +++ b/packages/admin-ui/src/node/webpack/with-custom-webpack-config.ts @@ -0,0 +1,16 @@ +import webpack, { type Configuration } from "webpack" + +/** + * Helper function to create a custom webpack config that can be used to + * extend the default webpack config used to build the admin UI. + */ +export function withCustomWebpackConfig( + callback: ( + config: Configuration, + webpackInstance: typeof webpack + ) => Configuration +) { + return (config: Configuration, webpackInstance: typeof webpack) => { + return callback(config, webpackInstance) + } +} diff --git a/packages/admin-ui/src/types/build.ts b/packages/admin-ui/src/types/build.ts deleted file mode 100644 index 0449a178db..0000000000 --- a/packages/admin-ui/src/types/build.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { DeepPartial } from "./misc" - -type GlobalsConfig = { - base?: string - backend?: string -} - -type BuildConfig = { - outDir?: string -} - -export type AdminBuildConfig = { - globals?: DeepPartial - build?: DeepPartial -} diff --git a/packages/admin-ui/src/types/config.ts b/packages/admin-ui/src/types/config.ts deleted file mode 100644 index 8bfd6137c9..0000000000 --- a/packages/admin-ui/src/types/config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { AdminBuildConfig } from "./build" - -export type AdminUIConfig = { - build?: AdminBuildConfig -} diff --git a/packages/admin-ui/src/types/dev.ts b/packages/admin-ui/src/types/dev.ts deleted file mode 100644 index b58d2e32ea..0000000000 --- a/packages/admin-ui/src/types/dev.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type AdminDevConfig = { - backend?: string - port?: number -} diff --git a/packages/admin-ui/src/types/index.ts b/packages/admin-ui/src/types/index.ts deleted file mode 100644 index 70119ba8ed..0000000000 --- a/packages/admin-ui/src/types/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./build" -export * from "./dev" -export * from "./misc" diff --git a/packages/admin-ui/src/types/misc.ts b/packages/admin-ui/src/types/misc.ts deleted file mode 100644 index 3ab124072a..0000000000 --- a/packages/admin-ui/src/types/misc.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type DeepPartial = { - [P in keyof T]?: T[P] extends (infer U)[] - ? DeepPartial[] - : T[P] extends ReadonlyArray - ? ReadonlyArray> - : DeepPartial -} - -export type Base = `/${T}/` diff --git a/packages/admin-ui/src/utils/format-base.ts b/packages/admin-ui/src/utils/format-base.ts deleted file mode 100644 index 5ac7d2c4cb..0000000000 --- a/packages/admin-ui/src/utils/format-base.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Base } from "../types" - -export const formatBase = (base?: T): Base => { - if (!base) { - return undefined - } - - return `/${base}/` -} diff --git a/packages/admin-ui/src/utils/get-custom-vite-config.ts b/packages/admin-ui/src/utils/get-custom-vite-config.ts deleted file mode 100644 index 3cdb8d8baa..0000000000 --- a/packages/admin-ui/src/utils/get-custom-vite-config.ts +++ /dev/null @@ -1,76 +0,0 @@ -import react from "@vitejs/plugin-react" -import { resolve } from "path" -import { BuildOptions, InlineConfig } from "vite" -import { AdminBuildConfig } from "../types" -import { formatBase } from "./format-base" - -export const getCustomViteConfig = (config: AdminBuildConfig): InlineConfig => { - const { globals = {}, build = {} } = config - - const uiPath = resolve(__dirname, "..", "..", "ui") - - const globalReplacements = () => { - let backend = undefined - - if (globals.backend) { - try { - // Test if the backend is a valid URL - new URL(globals.backend) - backend = globals.backend - } catch (_e) { - throw new Error( - `The provided backend URL is not valid: ${globals.backend}. Please provide a valid URL (e.g. https://my-medusa-server.com).` - ) - } - } - - const global = {} - - global["__BASE__"] = JSON.stringify(globals.base ? `/${globals.base}` : "/") - global["__MEDUSA_BACKEND_URL__"] = JSON.stringify(backend ? backend : "/") - - return global - } - - const buildConfig = (): BuildOptions => { - const { outDir } = build - - let destDir: string - - if (!outDir) { - /** - * Default build directory is at the root of the `@medusajs/admin-ui` package. - */ - destDir = resolve(process.cwd(), "build") - } else { - /** - * If a custom build directory is specified, it is resolved relative to the - * current working directory. - */ - destDir = resolve(process.cwd(), outDir) - } - - return { - outDir: destDir, - emptyOutDir: true, - } - } - - return { - plugins: [react()], - root: uiPath, - mode: "production", - base: formatBase(globals.base), - define: globalReplacements(), - build: buildConfig(), - resolve: { - alias: { - "@tanstack/react-query": resolve( - require.resolve("@tanstack/react-query") - ), - }, - }, - clearScreen: false, - logLevel: "error", - } -} diff --git a/packages/admin-ui/src/utils/get-custom-vite-dev-config.ts b/packages/admin-ui/src/utils/get-custom-vite-dev-config.ts deleted file mode 100644 index b0def537b7..0000000000 --- a/packages/admin-ui/src/utils/get-custom-vite-dev-config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import react from "@vitejs/plugin-react" -import { resolve } from "path" -import { InlineConfig } from "vite" -import { AdminDevConfig } from "../types/dev" - -export const getCustomViteDevConfig = ({ - backend = "http://localhost:9000", - port = 7001, -}: AdminDevConfig): InlineConfig => { - const uiPath = resolve(__dirname, "..", "..", "ui") - - return { - define: { - __BASE__: JSON.stringify("/"), - __MEDUSA_BACKEND_URL__: JSON.stringify(backend), - }, - plugins: [react()], - root: uiPath, - mode: "development", - server: { - port, - }, - } -} diff --git a/packages/admin-ui/src/utils/index.ts b/packages/admin-ui/src/utils/index.ts deleted file mode 100644 index 8e0a7575a5..0000000000 --- a/packages/admin-ui/src/utils/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./format-base" -export * from "./get-custom-vite-config" -export * from "./get-custom-vite-dev-config" diff --git a/packages/admin-ui/tsconfig.json b/packages/admin-ui/tsconfig.json index ff0e60262a..ca3b56127e 100644 --- a/packages/admin-ui/tsconfig.json +++ b/packages/admin-ui/tsconfig.json @@ -4,15 +4,21 @@ "declarationMap": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, + "lib": ["es2019"], "module": "commonjs", "moduleResolution": "node", "noEmit": false, "resolveJsonModule": true, "esModuleInterop": true, "outDir": "dist", - "rootDir": "src", + "rootDir": ".", "skipLibCheck": true }, - "include": ["src"], - "exclude": ["**/node_modules", "ui"] + "include": [ + "src", + "tsup.config.ts", + "webpack.config.dev.ts", + "src/node/webpack/get-webpack-config.ts" + ], + "exclude": ["node_modules", "ui", "./src/**/__tests__"] } diff --git a/packages/admin-ui/tsconfig.spec.json b/packages/admin-ui/tsconfig.spec.json new file mode 100644 index 0000000000..b800dda7ee --- /dev/null +++ b/packages/admin-ui/tsconfig.spec.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/admin-ui/tsup.config.ts b/packages/admin-ui/tsup.config.ts new file mode 100644 index 0000000000..c5a326ab4b --- /dev/null +++ b/packages/admin-ui/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: ["src/index.ts"], + dts: true, + skipNodeModulesBundle: true, + sourcemap: true, + minify: true, + clean: true, + format: ["cjs", "esm"], +}) diff --git a/packages/admin-ui/ui/index.html b/packages/admin-ui/ui/index.html index 3732020477..75ecf10b1d 100644 --- a/packages/admin-ui/ui/index.html +++ b/packages/admin-ui/ui/index.html @@ -8,6 +8,5 @@
- diff --git a/packages/admin-ui/ui/src/App.tsx b/packages/admin-ui/ui/src/App.tsx index 06c7fb4585..09e3aebc3e 100644 --- a/packages/admin-ui/ui/src/App.tsx +++ b/packages/admin-ui/ui/src/App.tsx @@ -26,7 +26,7 @@ const router = createBrowserRouter( ), { - basename: __BASE__, + basename: process.env.ADMIN_PATH, } ) diff --git a/packages/admin-ui/ui/src/assets/styles/global.css b/packages/admin-ui/ui/src/assets/styles/global.css index c5c679f4b7..d084ae9e49 100644 --- a/packages/admin-ui/ui/src/assets/styles/global.css +++ b/packages/admin-ui/ui/src/assets/styles/global.css @@ -10,6 +10,14 @@ font-display: swap; } +@font-face { + font-family: "Inter"; + src: url("../../fonts/Inter-Medium.ttf") format("truetype"); + font-weight: 500; + font-style: normal; + font-display: swap; +} + @font-face { font-family: "Inter"; src: url("../../fonts/Inter-SemiBold.ttf") format("truetype"); @@ -42,129 +50,129 @@ @layer components { .inter-5xlarge-regular { - @apply font-sans text-5xlarge leading-4xlarge font-normal; + @apply text-5xlarge leading-4xlarge font-sans font-normal; } .inter-5xlarge-semibold { - @apply font-sans text-5xlarge leading-4xlarge font-semibold; + @apply text-5xlarge leading-4xlarge font-sans font-semibold; } .inter-4xlarge-regular { - @apply font-sans text-4xlarge leading-3xlarge font-normal; + @apply text-4xlarge leading-3xlarge font-sans font-normal; } .inter-4xlarge-semibold { - @apply font-sans text-4xlarge leading-3xlarge font-semibold; + @apply text-4xlarge leading-3xlarge font-sans font-semibold; } .inter-3xlarge-regular { - @apply font-sans text-3xlarge leading-2xlarge font-normal; + @apply text-3xlarge leading-2xlarge font-sans font-normal; } .inter-3xlarge-semibold { - @apply font-sans text-3xlarge leading-2xlarge font-semibold; + @apply text-3xlarge leading-2xlarge font-sans font-semibold; } .inter-2xlarge-regular { - @apply font-sans text-2xlarge leading-xlarge font-normal; + @apply text-2xlarge leading-xlarge font-sans font-normal; } .inter-2xlarge-semibold { - @apply font-sans text-2xlarge leading-xlarge font-semibold; + @apply text-2xlarge leading-xlarge font-sans font-semibold; } .inter-xlarge-regular { - @apply font-sans text-xlarge leading-large font-normal; + @apply text-xlarge leading-large font-sans font-normal; } .inter-xlarge-semibold { - @apply font-sans text-xlarge leading-large font-semibold; + @apply text-xlarge leading-large font-sans font-semibold; } .inter-large-regular { - @apply font-sans text-large leading-base font-normal; + @apply text-large leading-base font-sans font-normal; } .inter-large-semibold { - @apply font-sans text-large leading-base font-semibold; + @apply text-large leading-base font-sans font-semibold; } .inter-base-regular { - @apply font-sans text-base leading-base font-normal; + @apply leading-base font-sans text-base font-normal; } .inter-base-semibold { - @apply font-sans text-base leading-base font-semibold; + @apply leading-base font-sans text-base font-semibold; } .inter-small-regular { - @apply font-sans text-small leading-small font-normal; + @apply text-small leading-small font-sans font-normal; } .inter-small-semibold { - @apply font-sans text-small leading-small font-semibold; + @apply text-small leading-small font-sans font-semibold; } .inter-xsmall-regular { - @apply font-sans text-xsmall leading-xsmall font-normal; + @apply text-xsmall leading-xsmall font-sans font-normal; } .inter-xsmall-semibold { - @apply font-sans text-xsmall leading-xsmall font-semibold; + @apply text-xsmall leading-xsmall font-sans font-semibold; } .mono-5xlarge-regular { - @apply font-mono text-5xlarge leading-4xlarge font-normal; + @apply text-5xlarge leading-4xlarge font-mono font-normal; } .mono-5xlarge-semibold { - @apply font-mono text-5xlarge leading-4xlarge font-bold; + @apply text-5xlarge leading-4xlarge font-mono font-bold; } .mono-4xlarge-regular { - @apply font-mono text-4xlarge leading-3xlarge font-normal; + @apply text-4xlarge leading-3xlarge font-mono font-normal; } .mono-4xlarge-semibold { - @apply font-mono text-4xlarge leading-3xlarge font-bold; + @apply text-4xlarge leading-3xlarge font-mono font-bold; } .mono-3xlarge-regular { - @apply font-mono text-3xlarge leading-2xlarge font-normal; + @apply text-3xlarge leading-2xlarge font-mono font-normal; } .mono-3xlarge-semibold { - @apply font-mono text-3xlarge leading-2xlarge font-bold; + @apply text-3xlarge leading-2xlarge font-mono font-bold; } .mono-2xlarge-regular { - @apply font-mono text-2xlarge leading-xlarge font-normal; + @apply text-2xlarge leading-xlarge font-mono font-normal; } .mono-2xlarge-semibold { - @apply font-mono text-2xlarge leading-xlarge font-bold; + @apply text-2xlarge leading-xlarge font-mono font-bold; } .mono-xlarge-regular { - @apply font-mono text-xlarge leading-large font-normal; + @apply text-xlarge leading-large font-mono font-normal; } .mono-xlarge-semibold { - @apply font-mono text-xlarge leading-large font-bold; + @apply text-xlarge leading-large font-mono font-bold; } .mono-large-regular { - @apply font-mono text-large leading-base font-normal; + @apply text-large leading-base font-mono font-normal; } .mono-large-semibold { - @apply font-mono text-large leading-base font-bold; + @apply text-large leading-base font-mono font-bold; } .mono-base-regular { - @apply font-mono text-base leading-base font-normal; + @apply leading-base font-mono text-base font-normal; } .mono-base-semibold { - @apply font-mono text-base leading-base font-bold; + @apply leading-base font-mono text-base font-bold; } .mono-small-regular { - @apply font-mono text-small leading-small font-normal; + @apply text-small leading-small font-mono font-normal; } .mono-small-semibold { - @apply font-mono text-small leading-small font-bold; + @apply text-small leading-small font-mono font-bold; } .mono-xsmall-regular { - @apply font-mono text-xsmall leading-xsmall font-normal; + @apply text-xsmall leading-xsmall font-mono font-normal; } .mono-xsmall-semibold { - @apply font-mono text-xsmall leading-xsmall font-bold; + @apply text-xsmall leading-xsmall font-mono font-bold; } .radio-outer-ring > span.indicator[data-state="checked"] { @@ -178,7 +186,7 @@ @layer components { .react-select-container { - @apply p-0 -mx-3 border-0 mb-1 cursor-text h-6; + @apply -mx-3 mb-1 h-6 cursor-text border-0 p-0; .react-select__control { @apply border-0 bg-inherit shadow-none; @@ -187,17 +195,17 @@ .react-select__control, .react-select__control--is-focused, .react-select__control--menu-is-open { - @apply h-6 p-0 m-0 !important; + @apply m-0 h-6 p-0 !important; } .react-select__value-container--is-multi, .react-select__value-container--has-value { - @apply h-6 pl-3 p-0 m-0 !important; + @apply m-0 h-6 p-0 pl-3 !important; } .react-select__menu, .react-select__menu-list { - @apply rounded-t-none mt-0 z-[110] !important; + @apply z-[110] mt-0 rounded-t-none !important; } .react-select__value-container { @@ -205,7 +213,7 @@ } .react-select__indicators { - @apply p-0 h-full items-center flex pr-3; + @apply flex h-full items-center p-0 pr-3; .react-select__indicator { @apply p-0; @@ -213,7 +221,7 @@ } .react-select__input { - @apply w-full mt-0 min-w-[120px] pt-0 !important; + @apply mt-0 w-full min-w-[120px] pt-0 !important; } .react-select__option, @@ -231,15 +239,15 @@ @layer components { .badge { - @apply w-min py-0.5 px-2 rounded-rounded inter-small-semibold; + @apply rounded-rounded inter-small-semibold w-min py-0.5 px-2; } .badge-disabled { - @apply bg-grey-50 bg-opacity-10 text-grey-50; + @apply bg-grey-50 text-grey-50 bg-opacity-10; } .badge-primary { - @apply bg-violet-60 bg-opacity-10 text-violet-60; + @apply bg-violet-60 text-violet-60 bg-opacity-10; } .badge-danger { @@ -251,11 +259,11 @@ } .badge-warning { - @apply bg-yellow-40 bg-opacity-20 text-yellow-60; + @apply bg-yellow-40 text-yellow-60 bg-opacity-20; } .badge-ghost { - @apply text-grey-90 border border-grey-20 whitespace-nowrap; + @apply text-grey-90 border-grey-20 whitespace-nowrap border; } .badge-default { @@ -263,7 +271,7 @@ } .btn { - @apply flex items-center justify-center rounded-rounded focus:outline-none focus:shadow-cta; + @apply rounded-rounded focus:shadow-cta flex items-center justify-center focus:outline-none; } .btn-large { @@ -279,23 +287,23 @@ } .btn-primary { - @apply bg-violet-60 text-grey-0 hover:bg-violet-50 active:bg-violet-70 disabled:bg-grey-20 disabled:text-grey-40; + @apply bg-violet-60 text-grey-0 active:bg-violet-70 disabled:bg-grey-20 disabled:text-grey-40 hover:bg-violet-50; } .btn-secondary { - @apply bg-grey-0 text-grey-90 border border-grey-20 hover:bg-grey-5 active:bg-grey-5 active:text-violet-60 focus:border-violet-60 disabled:bg-grey-0 disabled:text-grey-30; + @apply bg-grey-0 text-grey-90 border-grey-20 hover:bg-grey-5 active:bg-grey-5 active:text-violet-60 focus:border-violet-60 disabled:bg-grey-0 disabled:text-grey-30 border; } .btn-danger { - @apply bg-grey-0 text-rose-50 border border-grey-20 hover:bg-grey-10 active:bg-grey-20 disabled:bg-grey-0 disabled:text-grey-30; + @apply bg-grey-0 border-grey-20 hover:bg-grey-10 active:bg-grey-20 disabled:bg-grey-0 disabled:text-grey-30 border text-rose-50; } .btn-nuclear { - @apply bg-rose-50 text-grey-0 hover:bg-rose-40 active:bg-rose-60 disabled:bg-grey-20 disabled:text-grey-40; + @apply text-grey-0 hover:bg-rose-40 active:bg-rose-60 disabled:bg-grey-20 disabled:text-grey-40 bg-rose-50; } .btn-ghost { - @apply bg-transparent text-grey-90 hover:bg-grey-5 active:bg-grey-5 active:text-violet-60 focus:border-violet-60 disabled:bg-transparent disabled:text-grey-30; + @apply text-grey-90 hover:bg-grey-5 active:bg-grey-5 active:text-violet-60 focus:border-violet-60 disabled:text-grey-30 bg-transparent disabled:bg-transparent; } .btn-primary-large { @@ -329,11 +337,11 @@ @layer components { .date-picker { - @apply border-0 outline-none pt-6 !important; + @apply border-0 pt-6 outline-none !important; .react-datepicker__month-container { .react-datepicker__header { - @apply bg-inherit border-0; + @apply border-0 bg-inherit; } } @@ -341,7 +349,7 @@ @apply inter-base-semibold pt-4; .react-datepicker__day-name { - @apply w-[40px] m-0; + @apply m-0 w-[40px]; } } @@ -361,7 +369,7 @@ } .date { - @apply text-grey-90 m-[0px] w-[38px] h-[38px] align-middle relative leading-none pt-3; + @apply text-grey-90 relative m-[0px] h-[38px] w-[38px] pt-3 align-middle leading-none; :hover { @apply cursor-pointer; } @@ -396,7 +404,7 @@ } .vice-city { - @apply bg-gradient-to-tr from-vice-start to-vice-stop; + @apply from-vice-start to-vice-stop bg-gradient-to-tr; } .hidden-actions[data-state="open"] { @@ -426,9 +434,10 @@ @apply bg-grey-40; } - .accordion-margin-transition { + /* TODO: Fix this as it breaks builds when using preset */ + /* .accordion-margin-transition { @apply transition-[margin] duration-300 ease-[cubic-bezier(0.87,0,0.13,1)]; - } + } */ .col-tree:last-child .bottom-half-dash { @apply border-none; diff --git a/packages/admin-ui/ui/src/components/atoms/settings-card/index.tsx b/packages/admin-ui/ui/src/components/atoms/settings-card/index.tsx index d421a70812..0514358b8d 100644 --- a/packages/admin-ui/ui/src/components/atoms/settings-card/index.tsx +++ b/packages/admin-ui/ui/src/components/atoms/settings-card/index.tsx @@ -26,7 +26,7 @@ const SettingsCard: React.FC = ({ return ( + + + + + ) +} + +export default RouteErrorElement diff --git a/packages/admin-ui/ui/src/components/extensions/route-container/use-route-container-props.tsx b/packages/admin-ui/ui/src/components/extensions/route-container/use-route-container-props.tsx new file mode 100644 index 0000000000..3ea35a2b43 --- /dev/null +++ b/packages/admin-ui/ui/src/components/extensions/route-container/use-route-container-props.tsx @@ -0,0 +1,8 @@ +import { useExtensionBaseProps } from "../../../hooks/use-extension-base-props" +import { RouteProps } from "../../../types/extensions" + +export const useRouteContainerProps = (): RouteProps => { + const baseProps = useExtensionBaseProps() + + return baseProps +} diff --git a/packages/admin-ui/ui/src/components/extensions/setting-container/index.tsx b/packages/admin-ui/ui/src/components/extensions/setting-container/index.tsx new file mode 100644 index 0000000000..bbde662587 --- /dev/null +++ b/packages/admin-ui/ui/src/components/extensions/setting-container/index.tsx @@ -0,0 +1,14 @@ +import React, { ComponentType } from "react" +import { useSettingContainerProps } from "./use-setting-container-props" + +type SettingContainerProps = { + Page: ComponentType +} + +const SettingContainer = ({ Page }: SettingContainerProps) => { + const props = useSettingContainerProps() + + return React.createElement(Page, props) +} + +export default SettingContainer diff --git a/packages/admin-ui/ui/src/components/extensions/setting-container/setting-error-element.tsx b/packages/admin-ui/ui/src/components/extensions/setting-container/setting-error-element.tsx new file mode 100644 index 0000000000..920e74cce8 --- /dev/null +++ b/packages/admin-ui/ui/src/components/extensions/setting-container/setting-error-element.tsx @@ -0,0 +1,77 @@ +import { useEffect } from "react" +import { useRouteError } from "react-router-dom" +import Button from "../../fundamentals/button" +import RefreshIcon from "../../fundamentals/icons/refresh-icon" +import WarningCircleIcon from "../../fundamentals/icons/warning-circle" + +type SettingsPageErrorElementProps = { + origin: string +} + +const isProd = process.env.NODE_ENV === "production" + +const SettingsPageErrorElement = ({ + origin, +}: SettingsPageErrorElementProps) => { + const error = useRouteError() + + useEffect(() => { + if (!isProd && error) { + console.group( + `%cAn error occurred in a settings page from ${origin}:`, + "color: red; font-weight: bold;" + ) + console.error(error) + console.groupEnd() + } + }, [error, origin]) + + const reload = () => { + window.location.reload() + } + + return ( +
+
+
+ +
+
+

Uncaught error

+

+ {isProd + ? "An error unknown error occurred, and the page could not be loaded." + : `A Page from ${origin} crashed. See the console for more info.`} +

+

+ What should I do? +
+ If you are the developer of this setting page, you should fix the + error and reload the page. If you are not the developer, you should + contact the maintainer and report the error. +

+
+ +
+
+
+
+ ) +} + +export default SettingsPageErrorElement diff --git a/packages/admin-ui/ui/src/components/extensions/setting-container/use-setting-container-props.tsx b/packages/admin-ui/ui/src/components/extensions/setting-container/use-setting-container-props.tsx new file mode 100644 index 0000000000..323803ed05 --- /dev/null +++ b/packages/admin-ui/ui/src/components/extensions/setting-container/use-setting-container-props.tsx @@ -0,0 +1,7 @@ +import { useExtensionBaseProps } from "../../../hooks/use-extension-base-props" + +export const useSettingContainerProps = () => { + const baseProps = useExtensionBaseProps() + + return baseProps +} diff --git a/packages/admin-ui/ui/src/components/extensions/widget-container/index.tsx b/packages/admin-ui/ui/src/components/extensions/widget-container/index.tsx new file mode 100644 index 0000000000..2e145aee9d --- /dev/null +++ b/packages/admin-ui/ui/src/components/extensions/widget-container/index.tsx @@ -0,0 +1,32 @@ +import React from "react" +import { InjectionZone, Widget } from "../../../types/extensions" +import { EntityMap } from "./types" +import { useWidgetContainerProps } from "./use-widget-container-props" +import WidgetErrorBoundary from "./widget-error-boundary" + +type WidgetContainerProps = { + injectionZone: T + widget: Widget + entity: EntityMap[T] +} + +const WidgetContainer = ({ + injectionZone, + widget, + entity, +}: WidgetContainerProps) => { + const { Widget, origin } = widget + + const props = useWidgetContainerProps({ + injectionZone, + entity, + }) + + return ( + + {React.createElement(Widget, props)} + + ) +} + +export default WidgetContainer diff --git a/packages/admin-ui/ui/src/components/extensions/widget-container/types.ts b/packages/admin-ui/ui/src/components/extensions/widget-container/types.ts new file mode 100644 index 0000000000..344595b710 --- /dev/null +++ b/packages/admin-ui/ui/src/components/extensions/widget-container/types.ts @@ -0,0 +1,77 @@ +import { + Customer, + CustomerGroup, + Discount, + DraftOrder, + GiftCard, + Order, + PriceList, + Product, + ProductCollection, +} from "@medusajs/medusa" + +export type EntityMap = { + // Details + "product.details.after": Product + "product.details.before": Product + "product_collection.details.after": ProductCollection + "product_collection.details.before": ProductCollection + "order.details.after": Order + "order.details.before": Order + "draft_order.details.after": DraftOrder + "draft_order.details.before": DraftOrder + "customer.details.after": Customer + "customer.details.before": Customer + "customer_group.details.after": CustomerGroup + "customer_group.details.before": CustomerGroup + "discount.details.after": Discount + "discount.details.before": Discount + "price_list.details.after": PriceList + "price_list.details.before": PriceList + "gift_card.details.after": Product + "gift_card.details.before": Product + "custom_gift_card.after": GiftCard + "custom_gift_card.before": GiftCard + // List + "product.list.after"?: never | null | undefined + "product.list.before"?: never | null | undefined + "product_collection.list.after"?: never | null | undefined + "product_collection.list.before"?: never | null | undefined + "order.list.after"?: never | null | undefined + "order.list.before"?: never | null | undefined + "draft_order.list.after"?: never | null | undefined + "draft_order.list.before"?: never | null | undefined + "customer.list.after"?: never | null | undefined + "customer.list.before"?: never | null | undefined + "customer_group.list.after"?: never | null | undefined + "customer_group.list.before"?: never | null | undefined + "discount.list.after"?: never | null | undefined + "discount.list.before"?: never | null | undefined + "price_list.list.after"?: never | null | undefined + "price_list.list.before"?: never | null | undefined + "gift_card.list.after"?: never | null | undefined + "gift_card.list.before"?: never | null | undefined + // Login + "login.before"?: never | null | undefined + "login.after"?: never | null | undefined +} + +export const PropKeyMap = { + "product.details.after": "product", + "product.details.before": "product", + "product_collection.details.after": "productCollection", + "product_collection.details.before": "productCollection", + "order.details.after": "order", + "order.details.before": "order", + "draft_order.details.after": "draftOrder", + "draft_order.details.before": "draftOrder", + "customer.details.after": "customer", + "customer.details.before": "customer", + "customer_group.details.after": "customerGroup", + "customer_group.details.before": "customerGroup", + "discount.details.after": "discount", + "discount.details.before": "discount", + "price_list.details.after": "priceList", + "price_list.details.before": "priceList", + custom_gift_card: "giftCard", +} diff --git a/packages/admin-ui/ui/src/components/extensions/widget-container/use-widget-container-props.tsx b/packages/admin-ui/ui/src/components/extensions/widget-container/use-widget-container-props.tsx new file mode 100644 index 0000000000..dfb8b01d74 --- /dev/null +++ b/packages/admin-ui/ui/src/components/extensions/widget-container/use-widget-container-props.tsx @@ -0,0 +1,31 @@ +import { useExtensionBaseProps } from "../../../hooks/use-extension-base-props" +import { WidgetProps } from "../../../types/extensions" +import { EntityMap, PropKeyMap } from "./types" + +type UseWidgetContainerProps = { + injectionZone: T + entity?: EntityMap[T] +} + +export const useWidgetContainerProps = ({ + injectionZone, + entity, +}: UseWidgetContainerProps) => { + const baseProps = useExtensionBaseProps() satisfies WidgetProps + + /** + * Not all InjectionZones have an entity, so we need to check for it first, and then + * add it to the props if it exists. + */ + if (entity) { + const propKey = injectionZone as keyof typeof PropKeyMap + const entityKey = PropKeyMap[propKey] + + return { + ...baseProps, + [entityKey]: entity, + } + } + + return baseProps +} diff --git a/packages/admin-ui/ui/src/components/extensions/widget-container/widget-error-boundary.tsx b/packages/admin-ui/ui/src/components/extensions/widget-container/widget-error-boundary.tsx new file mode 100644 index 0000000000..7d374fc941 --- /dev/null +++ b/packages/admin-ui/ui/src/components/extensions/widget-container/widget-error-boundary.tsx @@ -0,0 +1,136 @@ +import React, { ErrorInfo } from "react" +import Button from "../../fundamentals/button" +import RefreshIcon from "../../fundamentals/icons/refresh-icon" +import WarningCircleIcon from "../../fundamentals/icons/warning-circle" +import XCircleIcon from "../../fundamentals/icons/x-circle-icon" + +type Props = { + children: React.ReactNode + origin: string +} + +type State = { + hasError: boolean + hidden?: boolean +} + +class WidgetErrorBoundary extends React.Component { + public state: State = { + hasError: false, + } + + public static getDerivedStateFromError(_: Error): State { + return { hasError: true, hidden: false } + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + if (process.env.NODE_ENV !== "production") { + console.group( + `%cAn error occurred in a widget from ${this.props.origin}:`, + "color: red; font-weight: bold, background-color: #fff;" + ) + console.error(error) + console.error( + "%cComponent Stack:", + "color: red", + errorInfo.componentStack + ) + console.groupEnd() + } + } + + public handleResetError() { + this.setState({ hasError: false }) + } + + public hideError() { + this.setState({ hidden: true }) + } + + public renderFallback() { + if (process.env.NODE_ENV !== "production" && !this.state.hidden) { + return ( + + ) + } + + // Don't render anything in production + return null + } + + render() { + if (this.state.hasError) { + return this.renderFallback() + } + + return this.props.children + } +} + +const FallbackWidget = ({ + origin, + reset, + hide, +}: { + origin: string + reset: () => void + hide: () => void +}) => { + return ( +
+
+ +
+
+

Uncaught error

+

+ A widget from {origin} crashed. See the console for + more info. +

+

+ What should I do? +
+ If you are the developer of this widget, you should fix the error and + reload the page. If you are not the developer, you should contact the + maintainer and report the error. +

+
+ + +
+
+
+ ) +} + +export default WidgetErrorBoundary diff --git a/packages/admin-ui/ui/src/components/fundamentals/icons/arrow-uturn-left/index.tsx b/packages/admin-ui/ui/src/components/fundamentals/icons/arrow-uturn-left/index.tsx new file mode 100644 index 0000000000..c1f3877847 --- /dev/null +++ b/packages/admin-ui/ui/src/components/fundamentals/icons/arrow-uturn-left/index.tsx @@ -0,0 +1,29 @@ +import React from "react" +import IconProps from "../types/icon-type" + +const ArrowUTurnLeft: React.FC = ({ + size = "20", + color = "currentColor", + ...attributes +}) => { + return ( + + + + ) +} + +export default ArrowUTurnLeft diff --git a/packages/admin-ui/ui/src/components/fundamentals/icons/info-icon/index.tsx b/packages/admin-ui/ui/src/components/fundamentals/icons/info-icon/index.tsx index 723aca577e..d83bb24190 100644 --- a/packages/admin-ui/ui/src/components/fundamentals/icons/info-icon/index.tsx +++ b/packages/admin-ui/ui/src/components/fundamentals/icons/info-icon/index.tsx @@ -10,27 +10,13 @@ const InfoIcon: React.FC = ({ - - = ({ + size = "20", + color = "currentColor", + ...attributes +}) => { + return ( + + + + ) +} + +export default SquaresPlus diff --git a/packages/admin-ui/ui/src/components/molecules/input-signin/index.tsx b/packages/admin-ui/ui/src/components/molecules/input-signin/index.tsx index bfad9be7b2..e06ebf7bf6 100644 --- a/packages/admin-ui/ui/src/components/molecules/input-signin/index.tsx +++ b/packages/admin-ui/ui/src/components/molecules/input-signin/index.tsx @@ -51,7 +51,7 @@ const SigninInput = React.forwardRef( return (
setShowPassword(!showPassword)} className="text-grey-40 focus:text-violet-60 px-4 focus:outline-none" + tabIndex={-1} > {showPassword ? : } diff --git a/packages/admin-ui/ui/src/components/organisms/analytics-config-form/index.tsx b/packages/admin-ui/ui/src/components/organisms/analytics-config-form/index.tsx index 307ffa5903..73f7d30312 100644 --- a/packages/admin-ui/ui/src/components/organisms/analytics-config-form/index.tsx +++ b/packages/admin-ui/ui/src/components/organisms/analytics-config-form/index.tsx @@ -3,6 +3,8 @@ import { useEffect } from "react" import { Controller, useWatch } from "react-hook-form" import { NestedForm } from "../../../utils/nested-form" import Switch from "../../atoms/switch" +import InfoIcon from "../../fundamentals/icons/info-icon" +import Tooltip from "../../atoms/tooltip" export type AnalyticsConfigFormType = { anonymize: boolean @@ -11,9 +13,10 @@ export type AnalyticsConfigFormType = { type Props = { form: NestedForm + compact?: boolean } -const AnalyticsConfigForm = ({ form }: Props) => { +const AnalyticsConfigForm = ({ form, compact }: Props) => { const { control, setValue, path } = form const watchOptOut = useWatch({ @@ -31,17 +34,33 @@ const AnalyticsConfigForm = ({ form }: Props) => { return (
-

Anonymize my usage data

-

- You can choose to anonymize your usage data. If this option is - selected, we will not collect your personal information, such as - your name and email address. -

+
+

+ Anonymize my usage data{" "} +

+ {compact && ( + + + + )} +
+ {!compact && ( +

+ You can choose to anonymize your usage data. If this option is + selected, we will not collect your personal information, such as + your name and email address. +

+ )}
{ }} />
-
+
-

- Opt out of sharing my usage data -

-

- You can always opt out of sharing your usage data at any time. -

+
+

+ Opt out of sharing my usage data +

+ {compact && ( + + + + )} +
+ {!compact && ( +

+ You can always opt out of sharing your usage data at any time. +

+ )}
{ const navigate = useNavigate() const { mutate, isLoading } = useAdminLogin() + const { getWidgets } = useWidgets() + const onSubmit = (values: FormValues) => { mutate(values, { onSuccess: () => { @@ -44,44 +48,66 @@ const LoginCard = ({ toResetPassword }: LoginCardProps) => { }) } return ( -
-
-

- Log in to Medusa -

-
- + {getWidgets("login.before").map((w, i) => { + return ( + - - + ) + })} + +
+

+ Log in to Medusa +

+
+ + + +
+ + + Forgot your password? +
- - - Forgot your password? - -
- + + {getWidgets("login.after").map((w, i) => { + return ( + + ) + })} +
) } diff --git a/packages/admin-ui/ui/src/components/organisms/sidebar/index.tsx b/packages/admin-ui/ui/src/components/organisms/sidebar/index.tsx index aff6957860..a5e02d7cd4 100644 --- a/packages/admin-ui/ui/src/components/organisms/sidebar/index.tsx +++ b/packages/admin-ui/ui/src/components/organisms/sidebar/index.tsx @@ -2,14 +2,16 @@ import { useAdminStore } from "medusa-react" import React, { useState } from "react" import { useFeatureFlag } from "../../../providers/feature-flag-provider" +import { useRoutes } from "../../../providers/route-provider" import BuildingsIcon from "../../fundamentals/icons/buildings-icon" import CartIcon from "../../fundamentals/icons/cart-icon" import CashIcon from "../../fundamentals/icons/cash-icon" import GearIcon from "../../fundamentals/icons/gear-icon" import GiftIcon from "../../fundamentals/icons/gift-icon" import SaleIcon from "../../fundamentals/icons/sale-icon" -import TagIcon from "../../fundamentals/icons/tag-icon" +import SquaresPlus from "../../fundamentals/icons/squares-plus" import SwatchIcon from "../../fundamentals/icons/swatch-icon" +import TagIcon from "../../fundamentals/icons/tag-icon" import UsersIcon from "../../fundamentals/icons/users-icon" import SidebarMenuItem from "../../molecules/sidebar-menu-item" import UserMenu from "../../molecules/user-menu" @@ -22,6 +24,8 @@ const Sidebar: React.FC = () => { const { isFeatureEnabled } = useFeatureFlag() const { store } = useAdminStore() + const { getLinks } = useRoutes() + const triggerHandler = () => { const id = triggerHandler.id++ return { @@ -104,6 +108,21 @@ const Sidebar: React.FC = () => { triggerHandler={triggerHandler} text={"Pricing"} /> + {getLinks().map(({ path, label, icon }, index) => { + const cleanLink = path.replace("/a/", "") + + const Icon = icon ? icon : SquaresPlus + + return ( + : } + triggerHandler={triggerHandler} + text={label} + /> + ) + })} } diff --git a/packages/admin-ui/ui/src/components/templates/user-table.tsx b/packages/admin-ui/ui/src/components/templates/user-table.tsx index b791d4eaba..1878fcab97 100644 --- a/packages/admin-ui/ui/src/components/templates/user-table.tsx +++ b/packages/admin-ui/ui/src/components/templates/user-table.tsx @@ -109,7 +109,7 @@ const UserTable: React.FC = ({ } return `${window.location.origin}${ - __BASE__ ? `${__BASE__}/` : "/" + process.env.ADMIN_PATH ? `${process.env.ADMIN_PATH}/` : "/" }invite?token={invite_token}` }, [store]) diff --git a/packages/admin-ui/ui/src/constants/forbidden-routes.ts b/packages/admin-ui/ui/src/constants/forbidden-routes.ts new file mode 100644 index 0000000000..208529d7ea --- /dev/null +++ b/packages/admin-ui/ui/src/constants/forbidden-routes.ts @@ -0,0 +1,59 @@ +export const forbiddenRoutes = [ + "/products", + "/products/:id", + "/product-categories", + "/product-categories", + "/orders", + "/orders/:id", + "/customers", + "/customers/:id", + "/customers/groups", + "/customers/groups/:id", + "/discounts", + "/discounts/new", + "/discounts/:id", + "/gift-cards", + "/gift-cards/:id", + "/gift-cards/manage", + "/pricing", + "/pricing/new", + "/pricing/:id", + "/inventory", + "/collections", + "/collections/:id", + "/draft-orders", + "/draft-orders/:id", + "/login", + "/sales-channels", + "/publishable-api-keys", + "/oauth", + "/oauth/:app_name", +] as const + +export const isSettingsRoute = (route: string) => { + return route.startsWith("/settings") +} + +export const isForbiddenRoute = (route: any): boolean => { + if (isSettingsRoute(route)) { + if (process.env.NODE_ENV !== "production") { + console.warn( + `The route "${route}" is a settings route. Please register the extension in the "settings" directory instead.` + ) + } + + return true + } + + if (forbiddenRoutes.includes(route)) { + if (process.env.NODE_ENV !== "production") { + console.warn( + `The route "${route}" is a forbidden route. We do not currently support overriding default routes.` + ) + } + + return true + } + + return false +} diff --git a/packages/admin-ui/ui/src/constants/injection-zones.ts b/packages/admin-ui/ui/src/constants/injection-zones.ts new file mode 100644 index 0000000000..7bedcfc246 --- /dev/null +++ b/packages/admin-ui/ui/src/constants/injection-zones.ts @@ -0,0 +1,52 @@ +export const injectionZones = [ + // Order injection zones + "order.details.before", + "order.details.after", + "order.list.before", + "order.list.after", + // Draft order injection zones + "draft_order.list.before", + "draft_order.list.after", + "draft_order.details.before", + "draft_order.details.after", + // Customer injection zones + "customer.details.before", + "customer.details.after", + "customer.list.before", + "customer.list.after", + // Customer group injection zones + "customer_group.details.before", + "customer_group.details.after", + "customer_group.list.before", + "customer_group.list.after", + // Product injection zones + "product.details.before", + "product.details.after", + "product.list.before", + "product.list.after", + // Product collection injection zones + "product_collection.details.before", + "product_collection.details.after", + "product_collection.list.before", + "product_collection.list.after", + // Price list injection zones + "price_list.details.before", + "price_list.details.after", + "price_list.list.before", + "price_list.list.after", + // Discount injection zones + "discount.details.before", + "discount.details.after", + "discount.list.before", + "discount.list.after", + // Gift card injection zones + "gift_card.details.before", + "gift_card.details.after", + "gift_card.list.before", + "gift_card.list.after", + "custom_gift_card.before", + "custom_gift_card.after", + // Login + "login.before", + "login.after", +] as const diff --git a/packages/admin-ui/ui/src/constants/medusa-backend-url.ts b/packages/admin-ui/ui/src/constants/medusa-backend-url.ts index 4b1b3a00a7..4679cc089e 100644 --- a/packages/admin-ui/ui/src/constants/medusa-backend-url.ts +++ b/packages/admin-ui/ui/src/constants/medusa-backend-url.ts @@ -1,2 +1,2 @@ export const MEDUSA_BACKEND_URL = - __MEDUSA_BACKEND_URL__ || "http://localhost:9000" + process.env.MEDUSA_BACKEND_URL || "http://localhost:9000" diff --git a/packages/admin-ui/ui/src/domain/collections/details/index.tsx b/packages/admin-ui/ui/src/domain/collections/details/index.tsx index b74e205dbc..162f58a53f 100644 --- a/packages/admin-ui/ui/src/domain/collections/details/index.tsx +++ b/packages/admin-ui/ui/src/domain/collections/details/index.tsx @@ -6,25 +6,30 @@ import { import { useEffect, useState } from "react" import { useNavigate, useParams } from "react-router-dom" import BackButton from "../../../components/atoms/back-button" +import Spacer from "../../../components/atoms/spacer" import Spinner from "../../../components/atoms/spinner" +import WidgetContainer from "../../../components/extensions/widget-container" import EditIcon from "../../../components/fundamentals/icons/edit-icon" import TrashIcon from "../../../components/fundamentals/icons/trash-icon" import Actionables from "../../../components/molecules/actionables" import JSONView from "../../../components/molecules/json-view" import DeletePrompt from "../../../components/organisms/delete-prompt" import { MetadataField } from "../../../components/organisms/metadata" +import RawJSON from "../../../components/organisms/raw-json" import Section from "../../../components/organisms/section" import CollectionModal from "../../../components/templates/collection-modal" import AddProductsTable from "../../../components/templates/collection-product-table/add-product-table" import ViewProductsTable from "../../../components/templates/collection-product-table/view-products-table" import useNotification from "../../../hooks/use-notification" +import { useWidgets } from "../../../providers/widget-provider" import Medusa from "../../../services/api" import { getErrorMessage } from "../../../utils/error-messages" +import { getErrorStatus } from "../../../utils/get-error-status" const CollectionDetails = () => { const { id } = useParams() - const { collection, isLoading, refetch } = useAdminCollection(id!) + const { collection, isLoading, error, refetch } = useAdminCollection(id!) const deleteCollection = useAdminDeleteCollection(id!) const updateCollection = useAdminUpdateCollection(id!) const [showEdit, setShowEdit] = useState(false) @@ -105,6 +110,32 @@ const CollectionDetails = () => { } }, [collection?.products]) + const { getWidgets } = useWidgets() + + if (error) { + const errorStatus = getErrorStatus(error) + + if (errorStatus) { + // If the product is not found, redirect to the 404 page + if (errorStatus.status === 404) { + navigate("/404") + return null + } + } + + // Let the error boundary handle the error + throw error + } + + if (isLoading || !collection) { + // temp, perhaps use skeletons? + return ( +
+ +
+ ) + } + return ( <>
@@ -113,12 +144,19 @@ const CollectionDetails = () => { path="/a/products?view=collections" label="Back to Collections" /> -
- {isLoading || !collection ? ( -
- -
- ) : ( +
+ {getWidgets("product_collection.details.before").map((w, i) => { + return ( + + ) + })} + +
@@ -155,29 +193,44 @@ const CollectionDetails = () => {
)}
- )} +
+ +
, + onClick: () => setShowAddProducts(!showAddProducts), + }, + ]} + > +

+ Products in this collection +

+ {collection && ( + + )} +
+ + {getWidgets("product_collection.details.after").map((w, i) => { + return ( + + ) + })} + +
-
, - onClick: () => setShowAddProducts(!showAddProducts), - }, - ]} - > -

- To start selling, all you need is a name, price, and image. -

- {collection && ( - - )} -
+
{showEdit && ( { + const { getNestedRoutes } = useRoutes() + + const nestedRoutes = getNestedRoutes("/collections") + return ( } /> + {nestedRoutes.map((r, i) => { + return ( + } + /> + ) + })} ) } diff --git a/packages/admin-ui/ui/src/domain/customers/details/index.tsx b/packages/admin-ui/ui/src/domain/customers/details/index.tsx index 8e14affda2..78ae71be25 100644 --- a/packages/admin-ui/ui/src/domain/customers/details/index.tsx +++ b/packages/admin-ui/ui/src/domain/customers/details/index.tsx @@ -1,10 +1,11 @@ import { useAdminCustomer } from "medusa-react" import moment from "moment" import { useState } from "react" -import { useParams } from "react-router-dom" +import { useNavigate, useParams } from "react-router-dom" import Avatar from "../../../components/atoms/avatar" import BackButton from "../../../components/atoms/back-button" import Spinner from "../../../components/atoms/spinner" +import WidgetContainer from "../../../components/extensions/widget-container" import EditIcon from "../../../components/fundamentals/icons/edit-icon" import StatusDot from "../../../components/fundamentals/status-indicator" import Actionables, { @@ -14,12 +15,15 @@ import BodyCard from "../../../components/organisms/body-card" import RawJSON from "../../../components/organisms/raw-json" import Section from "../../../components/organisms/section" import CustomerOrdersTable from "../../../components/templates/customer-orders-table" +import { useWidgets } from "../../../providers/widget-provider" +import { getErrorStatus } from "../../../utils/get-error-status" import EditCustomerModal from "./edit" const CustomerDetail = () => { const { id } = useParams() + const navigate = useNavigate() - const { customer, isLoading } = useAdminCustomer(id!) + const { customer, isLoading, error } = useAdminCustomer(id!) const [showEdit, setShowEdit] = useState(false) const customerName = () => { @@ -38,6 +42,31 @@ const CustomerDetail = () => { }, ] + const { getWidgets } = useWidgets() + + if (error) { + const errorStatus = getErrorStatus(error) + + if (errorStatus) { + // If the product is not found, redirect to the 404 page + if (errorStatus.status === 404) { + navigate("/404") + return null + } + } + + // Let the error boundary handle the error + throw error + } + + if (isLoading || !customer) { + return ( +
+ +
+ ) + } + return (
{ className="mb-xsmall" />
+ {getWidgets("customer.details.before").map((w, i) => { + return ( + + ) + })} +
@@ -61,7 +101,7 @@ const CustomerDetail = () => { {customerName()}

- {customer?.email} + {customer.email}

@@ -72,21 +112,21 @@ const CustomerDetail = () => {
First seen
-
{moment(customer?.created_at).format("DD MMM YYYY")}
+
{moment(customer.created_at).format("DD MMM YYYY")}
Phone
- {customer?.phone || "N/A"} + {customer.phone || "N/A"}
Orders
-
{customer?.orders.length}
+
{customer.orders.length}
@@ -94,28 +134,33 @@ const CustomerDetail = () => {
- {isLoading || !customer ? ( -
- -
- ) : ( -
- -
- )} +
+ +
+ {getWidgets("customer.details.after").map((w, i) => { + return ( + + ) + })} +
diff --git a/packages/admin-ui/ui/src/domain/customers/groups/details.tsx b/packages/admin-ui/ui/src/domain/customers/groups/details.tsx index 1b47a9bcc8..f7fe001066 100644 --- a/packages/admin-ui/ui/src/domain/customers/groups/details.tsx +++ b/packages/admin-ui/ui/src/domain/customers/groups/details.tsx @@ -11,6 +11,8 @@ import { useEffect, useState } from "react" import { useNavigate, useParams } from "react-router-dom" import BackButton from "../../../components/atoms/back-button" +import Spinner from "../../../components/atoms/spinner" +import WidgetContainer from "../../../components/extensions/widget-container" import EditIcon from "../../../components/fundamentals/icons/edit-icon" import PlusIcon from "../../../components/fundamentals/icons/plus-icon" import TrashIcon from "../../../components/fundamentals/icons/trash-icon" @@ -21,6 +23,8 @@ import CustomersListTable from "../../../components/templates/customer-group-tab import EditCustomersTable from "../../../components/templates/customer-group-table/edit-customers-table" import useQueryFilters from "../../../hooks/use-query-filters" import useToggleState from "../../../hooks/use-toggle-state" +import { useWidgets } from "../../../providers/widget-provider" +import { getErrorStatus } from "../../../utils/get-error-status" import CustomerGroupModal from "./customer-group-modal" /** @@ -127,7 +131,7 @@ function CustomerGroupCustomersList(props: CustomerGroupCustomersListProps) { {showCustomersModal && ( + +
+ ) } return ( @@ -243,8 +268,32 @@ function CustomerGroupDetails() { label="Back to customer groups" className="mb-4" /> - - +
+ {getWidgets("customer_group.details.before").map((w, i) => { + return ( + + ) + })} + + + + {getWidgets("customer_group.details.after").map((w, i) => { + return ( + + ) + })} + +
) } diff --git a/packages/admin-ui/ui/src/domain/customers/groups/index.tsx b/packages/admin-ui/ui/src/domain/customers/groups/index.tsx index adec305f39..ed8dfd01ca 100644 --- a/packages/admin-ui/ui/src/domain/customers/groups/index.tsx +++ b/packages/admin-ui/ui/src/domain/customers/groups/index.tsx @@ -1,8 +1,12 @@ import { Route, Routes } from "react-router-dom" +import RouteContainer from "../../../components/extensions/route-container" +import WidgetContainer from "../../../components/extensions/widget-container" import PlusIcon from "../../../components/fundamentals/icons/plus-icon" import BodyCard from "../../../components/organisms/body-card" import CustomerGroupsTable from "../../../components/templates/customer-group-table/customer-groups-table" import useToggleState from "../../../hooks/use-toggle-state" +import { useRoutes } from "../../../providers/route-provider" +import { useWidgets } from "../../../providers/widget-provider" import CustomersPageTableHeader from "../header" import CustomerGroupModal from "./customer-group-modal" import Details from "./details" @@ -12,6 +16,7 @@ import Details from "./details" */ function Index() { const { state, open, close } = useToggleState() + const { getWidgets } = useWidgets() const actions = [ { @@ -27,7 +32,18 @@ function Index() { return ( <> -
+
+ {getWidgets("customer_group.list.before").map((w, index) => { + return ( + + ) + })} + + + {getWidgets("customer_group.list.after").map((w, index) => { + return ( + + ) + })}
@@ -45,10 +72,25 @@ function Index() { * Customer groups routes */ function CustomerGroups() { + const { getNestedRoutes } = useRoutes() + + const nestedRoutes = getNestedRoutes("/customers/groups") + return ( } /> } /> + {nestedRoutes.map((r, i) => { + return ( + + } + /> + ) + })} ) } diff --git a/packages/admin-ui/ui/src/domain/customers/index.tsx b/packages/admin-ui/ui/src/domain/customers/index.tsx index 75f7065b21..3d47a1b455 100644 --- a/packages/admin-ui/ui/src/domain/customers/index.tsx +++ b/packages/admin-ui/ui/src/domain/customers/index.tsx @@ -1,31 +1,72 @@ import { Route, Routes } from "react-router-dom" import Spacer from "../../components/atoms/spacer" +import RouteContainer from "../../components/extensions/route-container" +import WidgetContainer from "../../components/extensions/widget-container" import BodyCard from "../../components/organisms/body-card" import CustomerTable from "../../components/templates/customer-table" +import { useRoutes } from "../../providers/route-provider" +import { useWidgets } from "../../providers/widget-provider" import Details from "./details" import CustomerGroups from "./groups" import CustomersPageTableHeader from "./header" const CustomerIndex = () => { + const { getWidgets } = useWidgets() + return ( -
+
+ {getWidgets("customer.list.before").map((w, index) => { + return ( + + ) + })} + } className="h-fit" > + + {getWidgets("customer.list.after").map((w, index) => { + return ( + + ) + })}
) } const Customers = () => { + const { getNestedRoutes } = useRoutes() + + const nestedRoutes = getNestedRoutes("/customers") + return ( } /> } /> } /> + {nestedRoutes.map((r, i) => { + return ( + } + /> + ) + })} ) } diff --git a/packages/admin-ui/ui/src/domain/discounts/details/index.tsx b/packages/admin-ui/ui/src/domain/discounts/details/index.tsx index 07b5c9c6c0..390c0ce65a 100644 --- a/packages/admin-ui/ui/src/domain/discounts/details/index.tsx +++ b/packages/admin-ui/ui/src/domain/discounts/details/index.tsx @@ -1,12 +1,15 @@ import { useAdminDeleteDiscount, useAdminDiscount } from "medusa-react" import { useState } from "react" -import { useParams } from "react-router-dom" +import { useNavigate, useParams } from "react-router-dom" import BackButton from "../../../components/atoms/back-button" import Spinner from "../../../components/atoms/spinner" +import WidgetContainer from "../../../components/extensions/widget-container" import DeletePrompt from "../../../components/organisms/delete-prompt" import RawJSON from "../../../components/organisms/raw-json" import useNotification from "../../../hooks/use-notification" +import { useWidgets } from "../../../providers/widget-provider" import { getErrorMessage } from "../../../utils/error-messages" +import { getErrorStatus } from "../../../utils/get-error-status" import { DiscountFormProvider } from "../new/discount-form/form/discount-form-context" import DiscountDetailsConditions from "./conditions" import Configurations from "./configurations" @@ -14,8 +17,9 @@ import General from "./general" const Edit = () => { const { id } = useParams() + const navigate = useNavigate() - const { discount, isLoading } = useAdminDiscount( + const { discount, isLoading, error } = useAdminDiscount( id!, { expand: "rule,rule.conditions" }, { @@ -26,6 +30,8 @@ const Edit = () => { const deleteDiscount = useAdminDeleteDiscount(id!) const notification = useNotification() + const { getWidgets } = useWidgets() + const handleDelete = () => { deleteDiscount.mutate(undefined, { onSuccess: () => { @@ -37,6 +43,29 @@ const Edit = () => { }) } + if (error) { + const errorStatus = getErrorStatus(error) + + if (errorStatus) { + // If the discount is not found, redirect to the 404 page + if (errorStatus.status === 404) { + navigate("/404") + return null + } + } + + // Let the error boundary handle the error + throw error + } + + if (isLoading || !discount) { + return ( +
+ +
+ ) + } + return (
{showDelete && ( @@ -55,20 +84,34 @@ const Edit = () => { path="/a/discounts" className="mb-xsmall" /> - {isLoading || !discount ? ( -
- -
- ) : ( -
- - - - - - -
- )} +
+ + {getWidgets("discount.details.before").map((w, index) => { + return ( + + ) + })} + + + + {getWidgets("discount.details.after").map((w, index) => { + return ( + + ) + })} + + +
) } diff --git a/packages/admin-ui/ui/src/domain/discounts/index.tsx b/packages/admin-ui/ui/src/domain/discounts/index.tsx index a45c5757be..0cfd7c840f 100644 --- a/packages/admin-ui/ui/src/domain/discounts/index.tsx +++ b/packages/admin-ui/ui/src/domain/discounts/index.tsx @@ -2,10 +2,14 @@ import { useState } from "react" import { Route, Routes } from "react-router-dom" import Fade from "../../components/atoms/fade-wrapper" import Spacer from "../../components/atoms/spacer" +import RouteContainer from "../../components/extensions/route-container" +import WidgetContainer from "../../components/extensions/widget-container" import PlusIcon from "../../components/fundamentals/icons/plus-icon" import BodyCard from "../../components/organisms/body-card" import TableViewHeader from "../../components/organisms/custom-table-header" import DiscountTable from "../../components/templates/discount-table" +import { useRoutes } from "../../providers/route-provider" +import { useWidgets } from "../../providers/widget-provider" import Details from "./details" import New from "./new" import DiscountForm from "./new/discount-form" @@ -22,9 +26,21 @@ const DiscountIndex = () => { }, ] + const { getWidgets } = useWidgets() + return (
-
+
+ {getWidgets("discount.list.before").map((w, index) => { + return ( + + ) + })} } @@ -32,6 +48,16 @@ const DiscountIndex = () => { > + {getWidgets("discount.list.after").map((w, index) => { + return ( + + ) + })}
@@ -44,11 +70,24 @@ const DiscountIndex = () => { } const Discounts = () => { + const { getNestedRoutes } = useRoutes() + + const nestedRoutes = getNestedRoutes("/discounts") + return ( } /> } /> } /> + {nestedRoutes.map((r, i) => { + return ( + } + /> + ) + })} ) } diff --git a/packages/admin-ui/ui/src/domain/gift-cards/details/index.tsx b/packages/admin-ui/ui/src/domain/gift-cards/details/index.tsx index f2ce710f67..c1304a9e44 100644 --- a/packages/admin-ui/ui/src/domain/gift-cards/details/index.tsx +++ b/packages/admin-ui/ui/src/domain/gift-cards/details/index.tsx @@ -3,6 +3,7 @@ import moment from "moment" import { useParams } from "react-router-dom" import BackButton from "../../../components/atoms/back-button" import Spinner from "../../../components/atoms/spinner" +import WidgetContainer from "../../../components/extensions/widget-container" import DollarSignIcon from "../../../components/fundamentals/icons/dollar-sign-icon" import EditIcon from "../../../components/fundamentals/icons/edit-icon" import StatusSelector from "../../../components/molecules/status-selector" @@ -10,6 +11,7 @@ import BodyCard from "../../../components/organisms/body-card" import RawJSON from "../../../components/organisms/raw-json" import useNotification from "../../../hooks/use-notification" import useToggleState from "../../../hooks/use-toggle-state" +import { useWidgets } from "../../../providers/widget-provider" import { getErrorMessage } from "../../../utils/error-messages" import { formatAmountWithSymbol } from "../../../utils/prices" import EditGiftCardModal from "./edit-gift-card-modal" @@ -24,6 +26,8 @@ const GiftCardDetails = () => { const updateGiftCard = useAdminUpdateGiftCard(giftCard?.id!) + const { getWidgets } = useWidgets() + const notification = useNotification() const { @@ -81,6 +85,17 @@ const GiftCardDetails = () => { ) : ( <>
+ {getWidgets("custom_gift_card.before").map((w, i) => { + return ( + + ) + })} + {
+ + {getWidgets("custom_gift_card.after").map((w, i) => { + return ( + + ) + })} +
diff --git a/packages/admin-ui/ui/src/domain/gift-cards/index.tsx b/packages/admin-ui/ui/src/domain/gift-cards/index.tsx index e62da12252..b233c9a158 100644 --- a/packages/admin-ui/ui/src/domain/gift-cards/index.tsx +++ b/packages/admin-ui/ui/src/domain/gift-cards/index.tsx @@ -1,14 +1,29 @@ import { Route, Routes } from "react-router-dom" +import RouteContainer from "../../components/extensions/route-container" +import { useRoutes } from "../../providers/route-provider" import GiftCardDetails from "./details" import ManageGiftCard from "./manage" import Overview from "./overview" const GiftCard = () => { + const { getNestedRoutes } = useRoutes() + + const nestedRoutes = getNestedRoutes("/gift-cards") + return ( } /> } /> } /> + {nestedRoutes.map((r, i) => { + return ( + } + /> + ) + })} ) } diff --git a/packages/admin-ui/ui/src/domain/gift-cards/manage/index.tsx b/packages/admin-ui/ui/src/domain/gift-cards/manage/index.tsx index 5c8f94bbcd..84690431c8 100644 --- a/packages/admin-ui/ui/src/domain/gift-cards/manage/index.tsx +++ b/packages/admin-ui/ui/src/domain/gift-cards/manage/index.tsx @@ -3,12 +3,14 @@ import { useAdminProducts } from "medusa-react" import { useNavigate } from "react-router-dom" import BackButton from "../../../components/atoms/back-button" import Spinner from "../../../components/atoms/spinner" +import WidgetContainer from "../../../components/extensions/widget-container" import GiftCardDenominationsSection from "../../../components/organisms/gift-card-denominations-section" import ProductAttributesSection from "../../../components/organisms/product-attributes-section" import ProductGeneralSection from "../../../components/organisms/product-general-section" import ProductMediaSection from "../../../components/organisms/product-media-section" import ProductRawSection from "../../../components/organisms/product-raw-section" import ProductThumbnailSection from "../../../components/organisms/product-thumbnail-section" +import { useWidgets } from "../../../providers/widget-provider" import { getErrorStatus } from "../../../utils/get-error-status" const Manage = () => { @@ -25,6 +27,8 @@ const Manage = () => { const giftCard = products?.[0] as Product | undefined + const { getWidgets } = useWidgets() + if (!giftCard) { return (
@@ -57,9 +61,34 @@ const Manage = () => { />
+ {getWidgets("gift_card.details.before").map((w, i) => { + return ( + + ) + })} + + + + + {getWidgets("gift_card.details.after").map((w, i) => { + return ( + + ) + })} +
diff --git a/packages/admin-ui/ui/src/domain/gift-cards/overview.tsx b/packages/admin-ui/ui/src/domain/gift-cards/overview.tsx index 765c8b4ff7..be2be2e46a 100644 --- a/packages/admin-ui/ui/src/domain/gift-cards/overview.tsx +++ b/packages/admin-ui/ui/src/domain/gift-cards/overview.tsx @@ -10,6 +10,7 @@ import { useNavigate } from "react-router-dom" import PageDescription from "../../components/atoms/page-description" import Spacer from "../../components/atoms/spacer" import Spinner from "../../components/atoms/spinner" +import WidgetContainer from "../../components/extensions/widget-container" import PlusIcon from "../../components/fundamentals/icons/plus-icon" import BannerCard from "../../components/molecules/banner-card" import BodyCard from "../../components/organisms/body-card" @@ -18,6 +19,7 @@ import GiftCardBanner from "../../components/organisms/gift-card-banner" import GiftCardTable from "../../components/templates/gift-card-table" import useNotification from "../../hooks/use-notification" import useToggleState from "../../hooks/use-toggle-state" +import { useWidgets } from "../../providers/widget-provider" import { ProductStatus } from "../../types/shared" import { getErrorMessage } from "../../utils/error-messages" import CustomGiftcard from "./custom-giftcard" @@ -94,6 +96,8 @@ const Overview = () => { } }, [giftCard, store]) + const { getWidgets } = useWidgets() + return ( <>
@@ -103,6 +107,16 @@ const Overview = () => { /> {!isLoading ? (
+ {getWidgets("gift_card.list.before").map((w, i) => { + return ( + + ) + })} {giftCardWithCurrency ? ( { > + + {getWidgets("gift_card.list.after").map((w, i) => { + return ( + + ) + })}
) : (
diff --git a/packages/admin-ui/ui/src/domain/orders/components/claim-type-form/__tests__/claim-type-form.test.tsx b/packages/admin-ui/ui/src/domain/orders/components/claim-type-form/__tests__/claim-type-form.test.tsx deleted file mode 100644 index 52afa7a46d..0000000000 --- a/packages/admin-ui/ui/src/domain/orders/components/claim-type-form/__tests__/claim-type-form.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Order } from "@medusajs/medusa" -import { renderHook, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import { useForm, UseFormReturn } from "react-hook-form" -import ClaimTypeForm from ".." -import { fixtures } from "../../../../../../test/fixtures" -import { renderWithProviders } from "../../../../../../test/utils/render-with-providers" -import { nestedForm } from "../../../../../utils/nested-form" -import { CreateClaimFormType } from "../../../details/claim/register-claim-menu" -import { getDefaultClaimValues } from "../../../details/utils/get-default-values" - -describe("ClaimTypeForm", () => { - let form: UseFormReturn - - beforeEach(() => { - const order = fixtures.get("order") as unknown as Order - - const { result } = renderHook(() => - useForm({ - defaultValues: getDefaultClaimValues(order), - }) - ) - - form = result.current - - renderWithProviders() - }) - - it("should render correctly with the initial value of refund", async () => { - const { - claim_type: { type }, - } = form.getValues() - - expect(screen.getByText("Refund")).toBeInTheDocument() - expect(screen.getByText("Replace")).toBeInTheDocument() - - expect(type).toEqual("refund") - }) - - it("should update the value of the form when a new type is selected", async () => { - const { - claim_type: { type: initialType }, - } = form.getValues() - - const user = userEvent.setup() - - expect(initialType).toEqual("refund") - - const replace = screen.getByLabelText("Replace") - - await user.click(replace) - - const { - claim_type: { type }, - } = form.getValues() - - expect(type).toEqual("replace") - }) -}) diff --git a/packages/admin-ui/ui/src/domain/orders/components/items-to-receive-form/__tests__/items-to-receive-form.test.tsx b/packages/admin-ui/ui/src/domain/orders/components/items-to-receive-form/__tests__/items-to-receive-form.test.tsx deleted file mode 100644 index 04373b0b5f..0000000000 --- a/packages/admin-ui/ui/src/domain/orders/components/items-to-receive-form/__tests__/items-to-receive-form.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Order, Return } from "@medusajs/medusa" -import { renderHook, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import { useForm, UseFormReturn } from "react-hook-form" -import { fixtures } from "../../../../../../test/fixtures" -import { renderWithProviders } from "../../../../../../test/utils/render-with-providers" -import { nestedForm } from "../../../../../utils/nested-form" -import { ReceiveReturnFormType } from "../../../details/receive-return" -import { getDefaultReceiveReturnValues } from "../../../details/utils/get-default-values" -import { ItemsToReceiveForm } from "../items-to-receive-form" - -describe("ItemsToReceiveForm with ReceiveReturnMenu", () => { - let form: UseFormReturn - - beforeEach(() => { - const order = fixtures.get("order") as unknown as Order - const return_ = fixtures.get("return") as unknown as Return - - const { result } = renderHook(() => - useForm({ - defaultValues: getDefaultReceiveReturnValues(order, return_), - }) - ) - - form = result.current - - renderWithProviders( - - ) - }) - - it("should render correctly", async () => { - expect(screen.getByText("Items to receive")).toBeInTheDocument() - expect(screen.getByText("Medusa Shorts")).toBeInTheDocument() - expect(screen.getByText("S")).toBeInTheDocument() - expect(screen.getByText("1")).toBeInTheDocument() - }) - - it("should mark an item as to be received when checkbox is checked", async () => { - const checkboxes = screen.getAllByRole("checkbox") - const user = userEvent.setup() - - // We expect two checkboxes, one for the header and one for the item - expect(checkboxes).toHaveLength(2) - - // Item checkbox - const checkbox = checkboxes[1] - expect(checkbox).not.toBeChecked() - - await user.click(checkbox) - - const { receive_items } = form.getValues() - expect(checkbox).toBeChecked() - expect(receive_items.items[0].receive).toEqual(true) - }) -}) diff --git a/packages/admin-ui/ui/src/domain/orders/components/items-to-return-form/__tests__/items-to-return-form.test.tsx b/packages/admin-ui/ui/src/domain/orders/components/items-to-return-form/__tests__/items-to-return-form.test.tsx deleted file mode 100644 index 5584684271..0000000000 --- a/packages/admin-ui/ui/src/domain/orders/components/items-to-return-form/__tests__/items-to-return-form.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { Order } from "@medusajs/medusa" -import { renderHook, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import { useForm, UseFormReturn } from "react-hook-form" -import ItemsToReturnForm from ".." -import { fixtures } from "../../../../../../test/fixtures" -import { renderWithProviders } from "../../../../../../test/utils/render-with-providers" -import { nestedForm } from "../../../../../utils/nested-form" -import { CreateClaimFormType } from "../../../details/claim/register-claim-menu" -import { getDefaultClaimValues } from "../../../details/utils/get-default-values" - -const order = fixtures.get("order") as unknown as Order - -describe("ItemsToSendForm with RegisterClaimMenu", () => { - let form: UseFormReturn - - beforeEach(() => { - const { result } = renderHook(() => - useForm({ - defaultValues: getDefaultClaimValues(order), - }) - ) - - form = result.current - - renderWithProviders( - - ) - }) - - it("should render correctly", async () => { - const titles = order.returnable_items?.map((item) => item.title) - - // expect all titles in titles array to appear at least once in the document - titles?.forEach((title) => { - expect(screen.getAllByText(title).length).toBeGreaterThan(0) - }) - }) - - it("should initially not display any items as marked for return", async () => { - const checkboxes = screen.getAllByRole("checkbox") - - checkboxes.forEach((checkbox) => { - expect(checkbox).not.toBeChecked() - }) - }) - - it("should mark all item as to be returned when checkbox is checked", async () => { - const checkboxes = screen.getAllByRole("checkbox") - - // Checkbox to select all items - const checkbox = checkboxes[0] - - const user = userEvent.setup() - - await user.click(checkbox) - - expect(checkbox).toBeChecked() - - const { return_items } = form.getValues() - - // expect all items to be marked for return - for (const item of return_items.items) { - expect(item.return).toBeTruthy() - } - }) - - it("should only mark the first item as to be returned", async () => { - const checkboxes = screen.getAllByRole("checkbox") - - // Checkbox to select first item - const checkbox = checkboxes[1] - - const user = userEvent.setup() - - await user.click(checkbox) - - expect(checkbox).toBeChecked() - - const { return_items } = form.getValues() - - // expect first item to be marked for return - expect(return_items.items[0].return).toBeTruthy() - - // expect all other items to not be marked for return - for (const item of return_items.items.slice(1)) { - expect(item.return).toBeFalsy() - } - }) - - it("should update quantity correctly", async () => { - const checkboxes = screen.getAllByRole("checkbox") - const checkbox = checkboxes[1] - - const user = userEvent.setup() - - await user.click(checkbox) - - expect(checkbox).toBeChecked() - - const decrement = screen.getByLabelText("Decrease quantity") - - await user.click(decrement) - - const { return_items } = form.getValues() - - expect(return_items.items[0].quantity).toEqual(1) - - const increment = screen.getByLabelText("Increase quantity") - - await user.click(increment) - - // should return to initial quantity - expect(return_items.items[0].quantity).toEqual(2) - }) -}) diff --git a/packages/admin-ui/ui/src/domain/orders/components/items-to-send-form/__tests__/items-to-send-form.test.tsx b/packages/admin-ui/ui/src/domain/orders/components/items-to-send-form/__tests__/items-to-send-form.test.tsx deleted file mode 100644 index 3afb457391..0000000000 --- a/packages/admin-ui/ui/src/domain/orders/components/items-to-send-form/__tests__/items-to-send-form.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Order } from "@medusajs/medusa" -import { renderHook, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import { useForm, UseFormReturn } from "react-hook-form" -import ItemsToSendForm from ".." -import { fixtures } from "../../../../../../test/fixtures" -import { renderWithProviders } from "../../../../../../test/utils/render-with-providers" -import { nestedForm } from "../../../../../utils/nested-form" -import { CreateClaimFormType } from "../../../details/claim/register-claim-menu" -import { getDefaultClaimValues } from "../../../details/utils/get-default-values" - -const order = fixtures.get("order") as unknown as Order - -describe("ItemsToSendForm with RegisterClaimMenu", () => { - let form: UseFormReturn - - beforeEach(() => { - const { result } = renderHook(() => - useForm({ - defaultValues: getDefaultClaimValues(order), - }) - ) - - form = result.current - - form.setValue("additional_items.items", [ - { - in_stock: 100, - original_price: 10000, - price: 10000, - product_title: "Test", - quantity: 1, - variant_id: "test", - variant_title: "Test", - }, - ]) - - renderWithProviders( - - ) - }) - - it("should render correctly", async () => { - expect(screen.getByText("Items to send")).toBeInTheDocument() - expect(screen.getByText("Add products")).toBeInTheDocument() - }) - - it("should display products to send correctly", async () => { - expect(screen.getByText("Test")).toBeInTheDocument() - expect(screen.getByText("€100.00")).toBeInTheDocument() - expect(screen.getByText("1")).toBeInTheDocument() - }) - - it("should update quantity correctly", async () => { - const { additional_items } = form.getValues() - - const user = userEvent.setup() - const increment = screen.getByLabelText("Increase quantity") - - await user.click(increment) - - expect(screen.getByText("2")).toBeInTheDocument() - expect(additional_items.items[0].quantity).toEqual(2) - - await user.click(increment) - - expect(screen.getByText("3")).toBeInTheDocument() - expect(additional_items.items[0].quantity).toEqual(3) - - const decrement = screen.getByLabelText("Decrease quantity") - - await user.click(decrement) - - expect(screen.getByText("2")).toBeInTheDocument() - expect(additional_items.items[0].quantity).toEqual(2) - }) -}) diff --git a/packages/admin-ui/ui/src/domain/orders/components/refund-amount-form/__tests__/refund-amount-form.test.tsx b/packages/admin-ui/ui/src/domain/orders/components/refund-amount-form/__tests__/refund-amount-form.test.tsx deleted file mode 100644 index bd3529ebae..0000000000 --- a/packages/admin-ui/ui/src/domain/orders/components/refund-amount-form/__tests__/refund-amount-form.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Order } from "@medusajs/medusa" -import { fireEvent, renderHook, screen, waitFor } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import { useForm, UseFormReturn } from "react-hook-form" -import RefundAmountForm from ".." -import { fixtures } from "../../../../../../test/fixtures" -import { renderWithProviders } from "../../../../../../test/utils/render-with-providers" -import { nestedForm } from "../../../../../utils/nested-form" -import { CreateClaimFormType } from "../../../details/claim/register-claim-menu" -import { getDefaultClaimValues } from "../../../details/utils/get-default-values" - -describe("RefundAmountForm refund claim", () => { - let form: UseFormReturn - - beforeEach(() => { - const order = fixtures.get("order") as unknown as Order - - const { result } = renderHook(() => - useForm({ - defaultValues: getDefaultClaimValues(order), - }) - ) - - form = result.current - - renderWithProviders( - - ) - }) - - it("should render correctly", async () => { - // Initial value should be 0 - expect(screen.getByText("€0.00")).toBeInTheDocument() - }) - - it("should update value when input is changed", async () => { - const button = screen.getByLabelText("Edit refund amount") - - const user = userEvent.setup() - - await user.click(button) - - const input = screen.getByPlaceholderText("-") - - fireEvent.change(input, { target: { value: "100" } }) - - await waitFor(() => { - const { - refund_amount: { amount }, - } = form.getValues() - - // We enter 100, but the value should be 10000 since we are transforming from dollars to cents - expect(amount).toEqual(10000) - }) - }) -}) diff --git a/packages/admin-ui/ui/src/domain/orders/components/rma-summaries/__tests__/claim-summary.test.tsx b/packages/admin-ui/ui/src/domain/orders/components/rma-summaries/__tests__/claim-summary.test.tsx deleted file mode 100644 index fa7af70ee3..0000000000 --- a/packages/admin-ui/ui/src/domain/orders/components/rma-summaries/__tests__/claim-summary.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Order, ShippingOption } from "@medusajs/medusa" -import { renderHook, screen } from "@testing-library/react" -import { useForm } from "react-hook-form" -import { fixtures } from "../../../../../../test/fixtures" -import { renderWithProviders } from "../../../../../../test/utils/render-with-providers" -import { CreateClaimFormType } from "../../../details/claim/register-claim-menu" -import { ClaimSummary } from "../claim-summary" - -describe("ClaimSummary", () => { - let order: Order - let so: ShippingOption - - beforeEach(() => { - order = fixtures.get("order") as unknown as Order - so = fixtures.get("shipping_option") as unknown as ShippingOption - - const { result } = renderHook(() => - useForm({ - defaultValues: { - return_items: { - items: fixtures.get("order").items.map((item) => ({ - item_id: item.id, - quantity: item.quantity, - return: true, - refundable: 90000, - total: 90000, - original_quantity: item.quantity, - })), - }, - additional_items: { - items: fixtures.list("line_item", 5).map((item) => ({ - item_id: item.id, - quantity: item.quantity, - price: 10000, - })), - }, - replacement_shipping: { - option: { - label: so.name, - value: { - id: so.id, - taxRate: 0, - }, - }, - }, - return_shipping: { - option: { - label: so.name, - value: { - id: so.id, - taxRate: 0, - }, - }, - }, - claim_type: { - type: "replace", - }, - }, - }) - ) - - renderWithProviders() - }) - - it("should render both a return and replacement shipping option", async () => { - expect(screen.getAllByText(so.name)).toHaveLength(2) - - expect(screen.getByText("Return shipping")).toBeInTheDocument() - expect(screen.getByText("Replacement shipping")).toBeInTheDocument() - expect(screen.getAllByText("Free")).toHaveLength(2) - }) -}) diff --git a/packages/admin-ui/ui/src/domain/orders/components/send-notification-form/__tests__/send-notification-form.test.tsx b/packages/admin-ui/ui/src/domain/orders/components/send-notification-form/__tests__/send-notification-form.test.tsx deleted file mode 100644 index 1117f4b359..0000000000 --- a/packages/admin-ui/ui/src/domain/orders/components/send-notification-form/__tests__/send-notification-form.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { renderHook, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import { useForm, UseFormReturn } from "react-hook-form" -import SendNotificationForm from ".." -import { renderWithProviders } from "../../../../../../test/utils/render-with-providers" -import { nestedForm } from "../../../../../utils/nested-form" -import { CreateClaimFormType } from "../../../details/claim/register-claim-menu" - -describe("SendNotificationForm", () => { - let form: UseFormReturn - - beforeEach(() => { - const { result } = renderHook(() => - useForm({ - defaultValues: { - notification: { - send_notification: true, - }, - }, - }) - ) - - form = result.current - - renderWithProviders( - - ) - }) - - it("should render initial value correctly", async () => { - const checkbox = screen.getByRole("checkbox") - - expect(checkbox).toBeChecked() - }) - - it("should update the value when the checkbox is clicked", async () => { - const checkbox = screen.getByRole("checkbox") - const user = userEvent.setup() - - await user.click(checkbox) - - const { - notification: { send_notification }, - } = form.getValues() - - expect(send_notification).toEqual(false) - expect(checkbox).not.toBeChecked() - }) -}) diff --git a/packages/admin-ui/ui/src/domain/orders/components/shipping-address-form/__tests__/shipping-address-form.test.tsx b/packages/admin-ui/ui/src/domain/orders/components/shipping-address-form/__tests__/shipping-address-form.test.tsx deleted file mode 100644 index a6002a3950..0000000000 --- a/packages/admin-ui/ui/src/domain/orders/components/shipping-address-form/__tests__/shipping-address-form.test.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Order } from "@medusajs/medusa" -import { renderHook, screen, waitFor } from "@testing-library/react" -import { useForm, UseFormReturn } from "react-hook-form" -import ShippingAddressForm from ".." -import { fixtures } from "../../../../../../test/fixtures" -import { renderWithProviders } from "../../../../../../test/utils/render-with-providers" -import { nestedForm } from "../../../../../utils/nested-form" -import { CreateClaimFormType } from "../../../details/claim/register-claim-menu" -import { getDefaultClaimValues } from "../../../details/utils/get-default-values" - -describe("ShippingAddressForm with RegisterClaimMenu", () => { - let form: UseFormReturn - - beforeEach(() => { - const order = fixtures.get("order") as unknown as Order - - const { result } = renderHook(() => - useForm({ - defaultValues: getDefaultClaimValues(order), - }) - ) - - form = result.current - - renderWithProviders( - - ) - }) - - it("should render the initial address correctly", async () => { - expect(screen.getByText("Shipping address")).toBeInTheDocument() - expect(screen.getByText("Faker Street 1, 3 Floor")).toBeInTheDocument() - expect(screen.getByText("Medusa JS, 2100 Copenhagen")).toBeInTheDocument() - expect(screen.getByText("Denmark")).toBeInTheDocument() - }) - - it("should render the address correctly when the address is changed", async () => { - await waitFor(() => { - form.setValue("shipping_address.address_1", "123 Second St") - form.setValue("shipping_address.address_2", "Apt 2") - }) - - const { - shipping_address: { address_1, address_2 }, - } = form.getValues() - - expect(address_1).toEqual("123 Second St") - expect(address_2).toEqual("Apt 2") - }) -}) diff --git a/packages/admin-ui/ui/src/domain/orders/components/shipping-form/__tests__/shipping-form.test.tsx b/packages/admin-ui/ui/src/domain/orders/components/shipping-form/__tests__/shipping-form.test.tsx deleted file mode 100644 index 3a61899fa0..0000000000 --- a/packages/admin-ui/ui/src/domain/orders/components/shipping-form/__tests__/shipping-form.test.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { Order, ShippingOption } from "@medusajs/medusa" -import { renderHook, screen, waitFor } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import { UserEvent } from "@testing-library/user-event/dist/types/setup/setup" -import { useForm, UseFormReturn } from "react-hook-form" -import ShippingForm from ".." -import { fixtures } from "../../../../../../test/fixtures" -import { renderWithProviders } from "../../../../../../test/utils/render-with-providers" -import { nestedForm } from "../../../../../utils/nested-form" -import { CreateClaimFormType } from "../../../details/claim/register-claim-menu" -import { getDefaultClaimValues } from "../../../details/utils/get-default-values" - -const selectFirstOption = async (user: UserEvent) => { - const combobox = screen.getByRole("combobox") - - await waitFor(() => { - combobox.focus() - }) - - // Open dropdown - await user.keyboard("{arrowdown}") - - // Go to first option and select - await user.keyboard("{arrowdown}") - await user.keyboard("{Enter}") -} - -describe("ShippingForm return shipping", () => { - let form: UseFormReturn - - beforeEach(() => { - const order = fixtures.get("order") as unknown as Order - - const { result } = renderHook(() => - useForm({ - defaultValues: getDefaultClaimValues(order), - }) - ) - - form = result.current - - renderWithProviders( -
- -
- ) - }) - - it("should render correctly when type is refund", async () => { - expect(screen.getByText("Shipping for return items")) - expect(screen.queryByText("Shipping for replacement items")).toBeNull() - }) - - it("should render options when dropdown is opened", async () => { - const user = userEvent.setup() - const combobox = screen.getByRole("combobox") - - await waitFor(() => { - combobox.focus() - }) - - await user.keyboard("{arrowdown}") - - await waitFor(() => { - expect(screen.getAllByText("Free Shipping")).toHaveLength(5) - }) - }) - - it("should select an option when clicked", async () => { - const user = userEvent.setup() - await selectFirstOption(user) - - await waitFor(() => { - expect(screen.getAllByText("Free Shipping")).toHaveLength(1) - }) - - const { return_shipping } = form.getValues() - - expect(return_shipping.option?.label).toEqual("Free Shipping") - expect(return_shipping.option?.value).toEqual( - expect.objectContaining({ - id: expect.any(String), - taxRate: 0, - }) - ) - }) - - it("should render correctly when option is selected", async () => { - const shippingOption = fixtures.get( - "shipping_option" - ) as unknown as ShippingOption - - await waitFor(() => { - form.setValue("return_shipping.option", { - label: shippingOption.name, - value: { - id: shippingOption.id, - taxRate: 0.12, - }, - }) - }) - - await waitFor(() => { - expect(screen.getByText(shippingOption.name)).toBeInTheDocument() - }) - }) -}) - -describe("ShippingForm return shipping", () => { - let form: UseFormReturn - - beforeEach(() => { - const order = fixtures.get("order") as unknown as Order - - const { result } = renderHook(() => - useForm({ - defaultValues: { - ...getDefaultClaimValues(order), - claim_type: { - type: "replace", - }, - }, - }) - ) - - form = result.current - - renderWithProviders( -
- -
- ) - }) - - it("should render correctly when type is replace", async () => { - expect(screen.getByText("Shipping for replacement items")) - expect(screen.queryByText("Shipping for return items")).toBeNull() - }) -}) diff --git a/packages/admin-ui/ui/src/domain/orders/details/detail-cards/summary.tsx b/packages/admin-ui/ui/src/domain/orders/details/detail-cards/summary.tsx index a022a9366a..618a9df801 100644 --- a/packages/admin-ui/ui/src/domain/orders/details/detail-cards/summary.tsx +++ b/packages/admin-ui/ui/src/domain/orders/details/detail-cards/summary.tsx @@ -3,8 +3,8 @@ import { Order, VariantInventory, } from "@medusajs/medusa" -import { DisplayTotal, PaymentDetails } from "../templates" import React, { useContext, useMemo } from "react" +import { DisplayTotal, PaymentDetails } from "../templates" import { ActionType } from "../../../../components/molecules/actionables" import Badge from "../../../../components/fundamentals/badge" @@ -15,11 +15,11 @@ import OrderLine from "../order-line" import { ReservationItemDTO } from "@medusajs/types" import ReserveItemsModal from "../reservation/reserve-items-modal" import { Response } from "@medusajs/medusa-js" -import StatusIndicator from "../../../../components/fundamentals/status-indicator" import { sum } from "lodash" -import { useFeatureFlag } from "../../../../providers/feature-flag-provider" import { useMedusa } from "medusa-react" +import StatusIndicator from "../../../../components/fundamentals/status-indicator" import useToggleState from "../../../../hooks/use-toggle-state" +import { useFeatureFlag } from "../../../../providers/feature-flag-provider" type SummaryCardProps = { order: Order @@ -168,7 +168,7 @@ const SummaryCard: React.FC = ({ order, reservations }) => { return ( { useHotkeys("esc", () => navigate("/a/orders")) useHotkeys("command+i", handleCopy) + const { getWidgets } = useWidgets() + const handleDeleteOrder = async () => { const shouldDelete = await dialog({ heading: "Cancel order", @@ -296,10 +301,22 @@ const OrderDetails = () => { ) : ( <> +
+ {getWidgets("order.details.before").map((widget, i) => { + return ( + + ) + })} +
-
+
{
@@ -525,9 +542,20 @@ const OrderDetails = () => {
-
- +
+ {getWidgets("order.details.after").map((widget, i) => { + return ( + + ) + })}
+ +
diff --git a/packages/admin-ui/ui/src/domain/orders/draft-orders/details.tsx b/packages/admin-ui/ui/src/domain/orders/draft-orders/details.tsx index e57c3b889d..f3e2b215b6 100644 --- a/packages/admin-ui/ui/src/domain/orders/draft-orders/details.tsx +++ b/packages/admin-ui/ui/src/domain/orders/draft-orders/details.tsx @@ -13,6 +13,7 @@ import Avatar from "../../../components/atoms/avatar" import BackButton from "../../../components/atoms/back-button" import CopyToClipboard from "../../../components/atoms/copy-to-clipboard" import Spinner from "../../../components/atoms/spinner" +import WidgetContainer from "../../../components/extensions/widget-container" import Button from "../../../components/fundamentals/button" import DetailsIcon from "../../../components/fundamentals/details-icon" import DollarSignIcon from "../../../components/fundamentals/icons/dollar-sign-icon" @@ -24,6 +25,7 @@ import ConfirmationPrompt from "../../../components/organisms/confirmation-promp import DeletePrompt from "../../../components/organisms/delete-prompt" import { AddressType } from "../../../components/templates/address-form" import useNotification from "../../../hooks/use-notification" +import { useWidgets } from "../../../providers/widget-provider" import { isoAlpha2Countries } from "../../../utils/countries" import { getErrorMessage } from "../../../utils/error-messages" import extractCustomerName from "../../../utils/extract-customer-name" @@ -119,6 +121,11 @@ const DraftOrderDetails = () => { }) } + const { getWidgets } = useWidgets() + + const afterWidgets = getWidgets("draft_order.details.after") + const beforeWidgets = getWidgets("draft_order.details.before") + const { cart } = draft_order || {} const { region } = cart || {} @@ -136,6 +143,21 @@ const DraftOrderDetails = () => { ) : (
+ {beforeWidgets?.length > 0 && ( +
+ {beforeWidgets.map((w, i) => { + return ( + + ) + })} +
+ )} + {
+ {afterWidgets?.length > 0 && ( +
+ {afterWidgets.map((w, i) => { + return ( + + ) + })} +
+ )} { const view = "drafts" const [showNewOrder, setShowNewOrder] = useState(false) + const { getWidgets } = useWidgets() + const actions = useMemo(() => { return [ { @@ -29,7 +33,17 @@ const DraftOrderIndex = () => { }, [view]) return ( -
+
+ {getWidgets("draft_order.list.before").map((Widget, i) => { + return ( + + ) + })}
{ > -
+ {getWidgets("draft_order.list.after").map((Widget, i) => { + return ( + + ) + })} + {showNewOrder && ( setShowNewOrder(false)} /> diff --git a/packages/admin-ui/ui/src/domain/orders/index.tsx b/packages/admin-ui/ui/src/domain/orders/index.tsx index 8dcf0b00ed..8a87f88a9d 100644 --- a/packages/admin-ui/ui/src/domain/orders/index.tsx +++ b/packages/admin-ui/ui/src/domain/orders/index.tsx @@ -3,6 +3,8 @@ import { Route, Routes, useNavigate } from "react-router-dom" import { useAdminCreateBatchJob } from "medusa-react" import Spacer from "../../components/atoms/spacer" +import RouteContainer from "../../components/extensions/route-container" +import WidgetContainer from "../../components/extensions/widget-container" import Button from "../../components/fundamentals/button" import ExportIcon from "../../components/fundamentals/icons/export-icon" import BodyCard from "../../components/organisms/body-card" @@ -12,6 +14,8 @@ import OrderTable from "../../components/templates/order-table" import useNotification from "../../hooks/use-notification" import useToggleState from "../../hooks/use-toggle-state" import { usePolling } from "../../providers/polling-provider" +import { useRoutes } from "../../providers/route-provider" +import { useWidgets } from "../../providers/widget-provider" import { getErrorMessage } from "../../utils/error-messages" import Details from "./details" import { transformFiltersAsExportContext } from "./utils" @@ -35,6 +39,8 @@ const OrderIndex = () => { state: exportModalOpen, } = useToggleState(false) + const { getWidgets } = useWidgets() + const actions = useMemo(() => { return [