diff --git a/changelog.md b/changelog.md index 945dc4e090..1275bd3d5f 100644 --- a/changelog.md +++ b/changelog.md @@ -36,6 +36,9 @@ registerProductDiscoverabilityFilter({ hiddenTagValue: 'hidden' }); - Add filters to `Query.orders` to filter by payment and delivery providers - Allow to call `Query.deliveryInterfaces` without a type - Add support for SMS providers BudgetSMS and Bulkgate next to Twilio on our privacy-focused mission to always support European alternatives. +- Add `productTags` & `assortmentTags` field in `shopInfo.adminUiConfig` that will return existing tags used for products and assortments and can also be customized to include default tags using `UNCHAINED_ADMIN_UI_DEFAULT_PRODUCT_TAGS` and/or `UNCHAINED_ADMIN_UI_DEFAULT_ASSORTMENT_TAGS`. +- Add option to configure `adminUiConfig.customProperties` through `UNCHAINED_ADMIN_UI_CUSTOM_PROPERTIES` env in addition to platform configuration option that accepts a json file +- Add option to configure `adminUiConfig.singleSignOnURL` through `UNCHAINED_ADMIN_UI_SINGLE_SIGN_ON_URL` env in addition to platform configuration option ## Patch - Update to ESlint 9 diff --git a/examples/kitchensink-express/.env.defaults b/examples/kitchensink-express/.env.defaults index af9f0a30ae..61caf78b2d 100644 --- a/examples/kitchensink-express/.env.defaults +++ b/examples/kitchensink-express/.env.defaults @@ -9,3 +9,7 @@ UNCHAINED_SECRET=secret UNCHAINED_GRIDFS_PUT_UPLOAD_SECRET=secret UNCHAINED_TOKEN_SECRET=random-token-that-is-not-secret-at-all UNCHAINED_COOKIE_SAMESITE=none +UNCHAINED_ADMIN_UI_DEFAULT_PRODUCT_TAGS=new,featured,bestseller +UNCHAINED_ADMIN_UI_DEFAULT_ASSORTMENT_TAGS= +UNCHAINED_ADMIN_UI_SINGLE_SIGN_ON_URL= +UNCHAINED_ADMIN_UI_CUSTOM_PROPERTIES= \ No newline at end of file diff --git a/examples/kitchensink/.env.defaults b/examples/kitchensink/.env.defaults index af9f0a30ae..d3c5cd9ab2 100644 --- a/examples/kitchensink/.env.defaults +++ b/examples/kitchensink/.env.defaults @@ -9,3 +9,7 @@ UNCHAINED_SECRET=secret UNCHAINED_GRIDFS_PUT_UPLOAD_SECRET=secret UNCHAINED_TOKEN_SECRET=random-token-that-is-not-secret-at-all UNCHAINED_COOKIE_SAMESITE=none +UNCHAINED_ADMIN_UI_DEFAULT_PRODUCT_TAGS=new,featured,bestseller +UNCHAINED_ADMIN_UI_DEFAULT_ASSORTMENT_TAGS= +UNCHAINED_ADMIN_UI_SINGLE_SIGN_ON_URL= +UNCHAINED_ADMIN_UI_CUSTOM_PROPERTIES=./fragments.json \ No newline at end of file diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts index 83960d5f29..8b3f93dfaa 100644 --- a/packages/api/src/context.ts +++ b/packages/api/src/context.ts @@ -29,6 +29,8 @@ export interface CustomAdminUiProperties { export interface AdminUiConfig { customProperties?: CustomAdminUiProperties[]; singleSignOnURL?: string; + defaultProductTags?: string[]; + defaultAssortmentTags?: string[]; } export interface UnchainedHTTPServerContext { diff --git a/packages/api/src/resolvers/queries/shopInfo.ts b/packages/api/src/resolvers/queries/shopInfo.ts index 110d8b1004..2dbd987b6a 100644 --- a/packages/api/src/resolvers/queries/shopInfo.ts +++ b/packages/api/src/resolvers/queries/shopInfo.ts @@ -1,3 +1,4 @@ +import { readFile } from 'fs/promises'; import { Context } from '../../context.js'; import { log } from '@unchainedshop/logger'; @@ -10,14 +11,23 @@ export default function shopInfo( adminUiConfig?: Record; vapidPublicKey?: string; } { - const { adminUiConfig } = context; + const { adminUiConfig, modules } = context; log('query shopInfo', { userId: context.userId }); return { version: context.version, adminUiConfig: { - customProperties: adminUiConfig?.customProperties ?? [], - singleSignOnURL: adminUiConfig?.singleSignOnURL, + customProperties: async () => { + try { + const raw = await readFile(process.env.UNCHAINED_ADMIN_UI_CUSTOM_PROPERTIES, 'utf-8'); + const parsed = JSON.parse(raw); + return parsed; + } catch { + return adminUiConfig?.customProperties ?? []; + } + }, + singleSignOnURL: + process.env.UNCHAINED_ADMIN_UI_SINGLE_SIGN_ON_URL || adminUiConfig?.singleSignOnURL, externalLinks: () => { try { const parsed = JSON.parse(process.env.EXTERNAL_LINKS); @@ -26,6 +36,30 @@ export default function shopInfo( return []; } }, + productTags: async () => { + const existingProductTags = await modules.products.existingTags(); + const envTags = (process.env.UNCHAINED_ADMIN_UI_DEFAULT_PRODUCT_TAGS || '') + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + const normalizedDefaultTags = envTags?.length + ? envTags + : (adminUiConfig?.defaultProductTags || []).filter(Boolean); + const normalizedTags = Array.from(new Set(normalizedDefaultTags.concat(existingProductTags))); + return normalizedTags; + }, + assortmentTags: async () => { + const existingAssortmentTags = await modules.assortments.existingTags(); + const envTags = (process.env.UNCHAINED_ADMIN_UI_DEFAULT_ASSORTMENT_TAGS || '') + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + const normalizedDefaultTags = envTags?.length + ? envTags + : (adminUiConfig?.defaultAssortmentTags || []).filter(Boolean); + const normalizedTags = Array.from(new Set(normalizedDefaultTags.concat(existingAssortmentTags))); + return normalizedTags; + }, }, vapidPublicKey: process.env?.PUSH_NOTIFICATION_PUBLIC_KEY, }; diff --git a/packages/api/src/resolvers/type/shop-types.ts b/packages/api/src/resolvers/type/shop-types.ts index 7b430b5abd..75e778e747 100644 --- a/packages/api/src/resolvers/type/shop-types.ts +++ b/packages/api/src/resolvers/type/shop-types.ts @@ -5,7 +5,6 @@ import { checkAction } from '../../acl.js'; import { allRoles, actions } from '../../roles/index.js'; type HelperType = (root: never, params: never, context: Context) => Promise; - export interface ShopHelperTypes { _id: () => string; country: HelperType; diff --git a/packages/api/src/schema/types/shop.ts b/packages/api/src/schema/types/shop.ts index 78c46e78c5..fd73fdffe2 100644 --- a/packages/api/src/schema/types/shop.ts +++ b/packages/api/src/schema/types/shop.ts @@ -25,6 +25,8 @@ export default [ customProperties: [AdminUiConfigCustomEntityInterface!]! externalLinks: [AdminUiLink!]! singleSignOnURL: String + productTags: [String!]! + assortmentTags: [String!]! } type Shop @cacheControl(maxAge: 180) { diff --git a/packages/core-assortments/src/module/configureAssortmentsModule.ts b/packages/core-assortments/src/module/configureAssortmentsModule.ts index eea96ec234..3229247bae 100644 --- a/packages/core-assortments/src/module/configureAssortmentsModule.ts +++ b/packages/core-assortments/src/module/configureAssortmentsModule.ts @@ -514,6 +514,13 @@ export const configureAssortmentsModule = async ({ links: assortmentLinks, products: assortmentProducts, texts: assortmentTexts, + existingTags: async (): Promise => { + const tags = await Assortments.distinct('tags', { + tags: { $exists: true }, + deleted: { $exists: false }, + }); + return tags.sort(); + }, }; }; diff --git a/packages/core-products/src/module/configureProductsModule.ts b/packages/core-products/src/module/configureProductsModule.ts index e86df8ec43..5716728b99 100644 --- a/packages/core-products/src/module/configureProductsModule.ts +++ b/packages/core-products/src/module/configureProductsModule.ts @@ -600,6 +600,13 @@ export const configureProductsModule = async ({ }, texts: productTexts, + existingTags: async (): Promise => { + const tags = await Products.distinct('tags', { + tags: { $exists: true }, + status: { $ne: ProductStatus.DELETED }, + }); + return tags.sort(); + }, }; }; diff --git a/packages/core/src/modules.ts b/packages/core/src/modules.ts index 076c998cbb..f72efe0939 100644 --- a/packages/core/src/modules.ts +++ b/packages/core/src/modules.ts @@ -154,6 +154,7 @@ const initModules = async ( const products = await configureProductsModule({ db, migrationRepository, + options: options.products, }); const quotations = await configureQuotationsModule({ db,