From dba496c714bd651d67433ee01894873343b77995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kan=20Str=C3=B6berg?= Date: Tue, 1 Apr 2025 14:24:11 +0200 Subject: [PATCH 1/9] feat(cms): group collections in admin panel closed COD-306 --- .../collections/categories/categories.collection.ts | 2 ++ apps/cms/src/collections/media/media.collection.ts | 3 +++ apps/cms/src/collections/pages/pages.collection.ts | 2 ++ apps/cms/src/collections/posts/posts.collection.ts | 2 ++ .../cms/src/collections/tenants/tenants.collection.ts | 3 ++- apps/cms/src/collections/users/users.collection.ts | 2 ++ libs/app-cms/util/definitions/src/index.ts | 1 + libs/app-cms/util/definitions/src/lib/admin-groups.ts | 11 +++++++++++ .../util/plugins/src/lib/plugins/get-forms-plugin.ts | 9 +++++++++ 9 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 libs/app-cms/util/definitions/src/lib/admin-groups.ts diff --git a/apps/cms/src/collections/categories/categories.collection.ts b/apps/cms/src/collections/categories/categories.collection.ts index 882b7d9d7..8ea4878b4 100644 --- a/apps/cms/src/collections/categories/categories.collection.ts +++ b/apps/cms/src/collections/categories/categories.collection.ts @@ -3,6 +3,7 @@ import type { CollectionConfig } from 'payload'; import { getEnv } from '@codeware/app-cms/feature/env-loader'; import { slugField } from '@codeware/app-cms/ui/fields'; import { verifyApiKeyAccess } from '@codeware/app-cms/util/access'; +import { adminGroups } from '@codeware/app-cms/util/definitions'; const env = getEnv(); @@ -12,6 +13,7 @@ const env = getEnv(); const categories: CollectionConfig = { slug: 'categories', admin: { + group: adminGroups.content, defaultColumns: ['name', 'slug', 'tenant'], useAsTitle: 'name' }, diff --git a/apps/cms/src/collections/media/media.collection.ts b/apps/cms/src/collections/media/media.collection.ts index 1e518b1e5..4bcf6b2b9 100644 --- a/apps/cms/src/collections/media/media.collection.ts +++ b/apps/cms/src/collections/media/media.collection.ts @@ -3,6 +3,8 @@ import { fileURLToPath } from 'url'; import type { CollectionConfig } from 'payload'; +import { adminGroups } from '@codeware/app-cms/util/definitions'; + const filename = fileURLToPath(import.meta.url); const dirname = path.dirname(filename); @@ -12,6 +14,7 @@ const dirname = path.dirname(filename); const media: CollectionConfig = { slug: 'media', admin: { + group: adminGroups.fileArea, defaultColumns: ['filename', 'mimeType', 'tenant', 'createdAt'], description: { en: 'Media files currently only support images and can be used in posts and pages.', diff --git a/apps/cms/src/collections/pages/pages.collection.ts b/apps/cms/src/collections/pages/pages.collection.ts index cce6b6ae8..0b77724a5 100644 --- a/apps/cms/src/collections/pages/pages.collection.ts +++ b/apps/cms/src/collections/pages/pages.collection.ts @@ -4,6 +4,7 @@ import { getEnv } from '@codeware/app-cms/feature/env-loader'; import { slugField } from '@codeware/app-cms/ui/fields'; import { seoTab } from '@codeware/app-cms/ui/tabs'; import { verifyApiKeyAccess } from '@codeware/app-cms/util/access'; +import { adminGroups } from '@codeware/app-cms/util/definitions'; import { populatePublishedAtHook } from '@codeware/app-cms/util/hooks'; const env = getEnv(); @@ -14,6 +15,7 @@ const env = getEnv(); const pages: CollectionConfig<'pages'> = { slug: 'pages', admin: { + group: adminGroups.content, defaultColumns: ['name', 'slug', 'tenant', 'updatedAt'], useAsTitle: 'name', description: { diff --git a/apps/cms/src/collections/posts/posts.collection.ts b/apps/cms/src/collections/posts/posts.collection.ts index 87af89173..15d9d0e56 100644 --- a/apps/cms/src/collections/posts/posts.collection.ts +++ b/apps/cms/src/collections/posts/posts.collection.ts @@ -7,6 +7,7 @@ import { getEnv } from '@codeware/app-cms/feature/env-loader'; import { slugField } from '@codeware/app-cms/ui/fields'; import { seoTab } from '@codeware/app-cms/ui/tabs'; import { verifyApiKeyAccess } from '@codeware/app-cms/util/access'; +import { adminGroups } from '@codeware/app-cms/util/definitions'; import { filterByTenantScope } from '@codeware/app-cms/util/filters'; import { updatePublishedAtHook } from './hooks/update-published-at.hook'; @@ -19,6 +20,7 @@ const env = getEnv(); const posts: CollectionConfig<'posts'> = { slug: 'posts', admin: { + group: adminGroups.content, defaultColumns: ['title', 'tenant', 'updatedAt'], useAsTitle: 'title', description: { diff --git a/apps/cms/src/collections/tenants/tenants.collection.ts b/apps/cms/src/collections/tenants/tenants.collection.ts index b0e8affd1..96f3794c6 100644 --- a/apps/cms/src/collections/tenants/tenants.collection.ts +++ b/apps/cms/src/collections/tenants/tenants.collection.ts @@ -5,7 +5,7 @@ import { authenticatedAccess, systemUserAccess } from '@codeware/app-cms/util/access'; -import { hasRole } from '@codeware/app-cms/util/misc'; +import { adminGroups } from '@codeware/app-cms/util/definitions'; import { enforceApiKeyHook } from './hooks/enforce-api-key.hook'; @@ -28,6 +28,7 @@ const tenants: CollectionConfig = { beforeChange: [enforceApiKeyHook] }, admin: { + group: adminGroups.settings, useAsTitle: 'name', description: { en: 'A workspace is like an organization or a company and is often called a "tenant". The content is scoped to the members of the workspace.', diff --git a/apps/cms/src/collections/users/users.collection.ts b/apps/cms/src/collections/users/users.collection.ts index 2fcdcd4a1..8fb906103 100644 --- a/apps/cms/src/collections/users/users.collection.ts +++ b/apps/cms/src/collections/users/users.collection.ts @@ -4,6 +4,7 @@ import { systemUserAccess, systemUserOrTenantAdminAccess } from '@codeware/app-cms/util/access'; +import { adminGroups } from '@codeware/app-cms/util/definitions'; import { adminAccessToAllDocTenants } from './access/admin-access-to-all-doc-tenants'; import { tenantsArrayField } from './fields/tenants-array.field'; @@ -16,6 +17,7 @@ const users: CollectionConfig<'users'> = { slug: 'users', auth: { maxLoginAttempts: 5, lockTime: 1000 * 60 * 60 * 24 }, admin: { + group: adminGroups.settings, useAsTitle: 'name' }, labels: { diff --git a/libs/app-cms/util/definitions/src/index.ts b/libs/app-cms/util/definitions/src/index.ts index 1fa38a4de..f8fdc802c 100644 --- a/libs/app-cms/util/definitions/src/index.ts +++ b/libs/app-cms/util/definitions/src/index.ts @@ -1,3 +1,4 @@ +export { adminGroups } from './lib/admin-groups'; export { codeLanguages, languageMap, diff --git a/libs/app-cms/util/definitions/src/lib/admin-groups.ts b/libs/app-cms/util/definitions/src/lib/admin-groups.ts new file mode 100644 index 000000000..3dafc9a31 --- /dev/null +++ b/libs/app-cms/util/definitions/src/lib/admin-groups.ts @@ -0,0 +1,11 @@ +/** + * Admin groups for rendering payload collections and globals together in the admin UI. + * + * @see https://payloadcms.com/docs/configuration/collections#admin-options + */ +export const adminGroups: Record = { + content: { en: 'Content', sv: 'Innehåll' }, + fileArea: { en: 'File Area', sv: 'Filarea' }, + forms: { en: 'Form Builder', sv: 'Formulärbyggare' }, + settings: { en: 'Settings', sv: 'Inställningar' } +}; diff --git a/libs/app-cms/util/plugins/src/lib/plugins/get-forms-plugin.ts b/libs/app-cms/util/plugins/src/lib/plugins/get-forms-plugin.ts index bceae7395..004e531f0 100644 --- a/libs/app-cms/util/plugins/src/lib/plugins/get-forms-plugin.ts +++ b/libs/app-cms/util/plugins/src/lib/plugins/get-forms-plugin.ts @@ -1,3 +1,4 @@ +import { adminGroups } from '@codeware/app-cms/util/definitions'; import { formBuilderPlugin } from '@payloadcms/plugin-form-builder'; import { customizedFields } from './forms/customized-fields'; @@ -11,7 +12,15 @@ export const getFormsPlugin = () => { payment: false, state: false }, + formOverrides: { + admin: { + group: adminGroups['forms'] + } + }, formSubmissionOverrides: { + admin: { + group: adminGroups['forms'] + }, hooks: { beforeValidate: [ensureTenant] } From 0a87a2f6fb1d1f5c81420b72884fd76e213eef2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kan=20Str=C3=B6berg?= Date: Tue, 1 Apr 2025 21:16:05 +0200 Subject: [PATCH 2/9] feat(cms): create site settings collection with multi tenancy closed COD-301 --- .../site-settings/site-settings.collection.ts | 64 +++++++++++++++++++ apps/cms/src/payload.config.ts | 3 +- .../lib/plugins/get-multi-tenant-plugin.ts | 3 +- libs/shared/util/payload-api/src/index.ts | 1 + .../payload-api/src/lib/get-site-settings.ts | 25 ++++++++ .../payload-types/src/lib/payload-types.ts | 38 +++++++++++ 6 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 apps/cms/src/collections/site-settings/site-settings.collection.ts create mode 100644 libs/shared/util/payload-api/src/lib/get-site-settings.ts diff --git a/apps/cms/src/collections/site-settings/site-settings.collection.ts b/apps/cms/src/collections/site-settings/site-settings.collection.ts new file mode 100644 index 000000000..f282ea627 --- /dev/null +++ b/apps/cms/src/collections/site-settings/site-settings.collection.ts @@ -0,0 +1,64 @@ +import type { CollectionConfig } from 'payload'; + +import { getEnv } from '@codeware/app-cms/feature/env-loader'; +import { + systemUserOrTenantAdminAccess, + verifyApiKeyAccess +} from '@codeware/app-cms/util/access'; +import { adminGroups } from '@codeware/app-cms/util/definitions'; + +const env = getEnv(); + +/** + * Site settings collection. + */ +const siteSettings: CollectionConfig = { + slug: 'site-settings', + admin: { + group: adminGroups.settings + }, + access: { + read: verifyApiKeyAccess({ + secret: env.SIGNATURE_SECRET + }), + update: systemUserOrTenantAdminAccess + }, + labels: { + singular: { en: 'Site Settings', sv: 'Webbplatsinställningar' }, + plural: { en: 'Site Settings', sv: 'Webbplatsinställningar' } + }, + fields: [ + { + type: 'tabs', + tabs: [ + { + name: 'general', + fields: [ + { + name: 'appName', + type: 'text', + label: { en: 'Application name', sv: 'Namnet på applikationen' }, + required: true + }, + { + name: 'landingPage', + type: 'relationship', + relationTo: 'pages', + label: { en: 'Landing page', sv: 'Startsida' }, + admin: { + description: { + en: 'The page that will be used as the landing page for the application.', + sv: 'Sidan som kommer att användas som startsida för applikationen.' + } + }, + index: true, + required: true + } + ] + } + ] + } + ] +}; + +export default siteSettings; diff --git a/apps/cms/src/payload.config.ts b/apps/cms/src/payload.config.ts index 3b469f42a..ca1be5fbe 100644 --- a/apps/cms/src/payload.config.ts +++ b/apps/cms/src/payload.config.ts @@ -21,6 +21,7 @@ import categories from './collections/categories/categories.collection'; import media from './collections/media/media.collection'; import pages from './collections/pages/pages.collection'; import posts from './collections/posts/posts.collection'; +import siteSettings from './collections/site-settings/site-settings.collection'; import tenants from './collections/tenants/tenants.collection'; import users from './collections/users/users.collection'; import { migrations } from './migrations'; @@ -45,7 +46,7 @@ export default buildConfig({ // Declare blocks globally and reference then by slug elsewhere // https://payloadcms.com/docs/fields/blocks#block-references blocks: [codeBlock, contentBlock, formBlock, mediaBlock], - collections: [categories, media, pages, posts, tenants, users], + collections: [categories, media, pages, posts, siteSettings, tenants, users], cors: env.CORS_URLS === '*' ? '*' : env.CORS_URLS.split(',').filter(Boolean), csrf: env.CSRF_URLS ? env.CSRF_URLS.split(',').filter(Boolean) : undefined, db: postgresAdapter({ diff --git a/libs/app-cms/util/plugins/src/lib/plugins/get-multi-tenant-plugin.ts b/libs/app-cms/util/plugins/src/lib/plugins/get-multi-tenant-plugin.ts index 984478599..3972447e6 100644 --- a/libs/app-cms/util/plugins/src/lib/plugins/get-multi-tenant-plugin.ts +++ b/libs/app-cms/util/plugins/src/lib/plugins/get-multi-tenant-plugin.ts @@ -17,7 +17,8 @@ export const getMultiTenantPlugin = () => { 'form-submissions': {}, media: {}, pages: {}, - posts: {} + posts: {}, + 'site-settings': { isGlobal: true } }, tenantsArrayField: { includeDefaultField: false diff --git a/libs/shared/util/payload-api/src/index.ts b/libs/shared/util/payload-api/src/index.ts index 3af2bd65b..ac4f4cc53 100644 --- a/libs/shared/util/payload-api/src/index.ts +++ b/libs/shared/util/payload-api/src/index.ts @@ -10,3 +10,4 @@ export type { export { findBySlug } from './lib/find-by-slug'; export { getShallow } from './lib/get-shallow'; export { post } from './lib/post'; +export { getSiteSettings } from './lib/get-site-settings'; diff --git a/libs/shared/util/payload-api/src/lib/get-site-settings.ts b/libs/shared/util/payload-api/src/lib/get-site-settings.ts new file mode 100644 index 000000000..1e91fd14c --- /dev/null +++ b/libs/shared/util/payload-api/src/lib/get-site-settings.ts @@ -0,0 +1,25 @@ +import type { SiteSetting } from '@codeware/shared/util/payload-types'; + +import { type RequestBaseOptions, invokeRequest } from './utils/invoke-request'; + +/** + * Get the site settings. + * + * @param options - The options to get the site settings with. + * @returns The site settings or `null` if the site settings are not found. + * @throws A formatted error message when the request fails. + */ +export const getSiteSettings = async ( + options: RequestBaseOptions +): Promise => { + const response = await invokeRequest('site-settings', { + ...options, + method: 'GET' + }); + + if ('error' in response) { + throw new Error(`Error fetching site settings: ${response.error}`); + } + + return response.data?.[0] ?? null; +}; diff --git a/libs/shared/util/payload-types/src/lib/payload-types.ts b/libs/shared/util/payload-types/src/lib/payload-types.ts index 1e2b74d82..0fa20d5ef 100644 --- a/libs/shared/util/payload-types/src/lib/payload-types.ts +++ b/libs/shared/util/payload-types/src/lib/payload-types.ts @@ -92,6 +92,7 @@ export interface Config { media: Media; pages: Page; posts: Post; + 'site-settings': SiteSetting; tenants: Tenant; users: User; forms: Form; @@ -120,6 +121,7 @@ export interface Config { media: MediaSelect | MediaSelect; pages: PagesSelect | PagesSelect; posts: PostsSelect | PostsSelect; + 'site-settings': SiteSettingsSelect | SiteSettingsSelect; tenants: TenantsSelect | TenantsSelect; users: UsersSelect | UsersSelect; forms: FormsSelect | FormsSelect; @@ -788,6 +790,23 @@ export interface Category { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "site-settings". + */ +export interface SiteSetting { + id: number; + tenant?: (number | null) | Tenant; + general: { + appName: string; + /** + * The page that will be used as the landing page for the application. + */ + landingPage: number | Page; + }; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "form-submissions". @@ -829,6 +848,10 @@ export interface PayloadLockedDocument { relationTo: 'posts'; value: number | Post; } | null) + | ({ + relationTo: 'site-settings'; + value: number | SiteSetting; + } | null) | ({ relationTo: 'tenants'; value: number | Tenant; @@ -1050,6 +1073,21 @@ export interface PostsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "site-settings_select". + */ +export interface SiteSettingsSelect { + tenant?: T; + general?: + | T + | { + appName?: T; + landingPage?: T; + }; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "tenants_select". From 6d74a1a35f09b84a659419d39b0795c12a3211d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kan=20Str=C3=B6berg?= Date: Tue, 1 Apr 2025 21:44:00 +0200 Subject: [PATCH 3/9] feat(cms): create navigation collection with multi tenancy closed COD-256 --- apps/cms/src/app/(payload)/admin/importMap.js | 3 + .../navigation/navigation.collection.tsx | 95 ++++++++++++++++++ .../components/NavigationArrayRowLabel.tsx | 63 ++++++++++++ .../src/components/array-row-label.type.ts | 10 ++ apps/cms/src/payload.config.ts | 12 ++- .../lib/plugins/get-multi-tenant-plugin.ts | 6 +- libs/shared/util/payload-api/src/index.ts | 8 ++ .../src/lib/find-navigation-doc.ts | 75 +++++++++++++++ .../src/lib/get-navigation-tree.ts | 96 +++++++++++++++++++ .../payload-types/src/lib/custom-types.ts | 6 ++ .../payload-types/src/lib/payload-types.ts | 49 ++++++++++ 11 files changed, 419 insertions(+), 4 deletions(-) create mode 100644 apps/cms/src/collections/navigation/navigation.collection.tsx create mode 100644 apps/cms/src/components/NavigationArrayRowLabel.tsx create mode 100644 apps/cms/src/components/array-row-label.type.ts create mode 100644 libs/shared/util/payload-api/src/lib/find-navigation-doc.ts create mode 100644 libs/shared/util/payload-api/src/lib/get-navigation-tree.ts diff --git a/apps/cms/src/app/(payload)/admin/importMap.js b/apps/cms/src/app/(payload)/admin/importMap.js index 511a14e21..ea894dd55 100644 --- a/apps/cms/src/app/(payload)/admin/importMap.js +++ b/apps/cms/src/app/(payload)/admin/importMap.js @@ -35,6 +35,7 @@ import { default as default_7925a79d2af6389df70d2dd269ffbfbb } from '@codeware/a import { default as default_06af4458abd1296f9d6bccce90425927 } from '@codeware/app-cms/ui/fields/code/Code.client'; import { default as default_52b6c8f3cfeb54cb642a26fe54c075b9 } from '@codeware/apps/cms/components/ArrayRowLabel'; import { default as default_42ab7a6f795fd44e8c166a2bb6b2adc0 } from '@codeware/apps/cms/components/Logo.client'; +import { default as default_d497a38447405736d600359900364450 } from '@codeware/apps/cms/components/NavigationArrayRowLabel'; export const importMap = { '@payloadcms/plugin-multi-tenant/client#TenantField': @@ -81,6 +82,8 @@ export const importMap = { BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, '@payloadcms/richtext-lexical/client#ItalicFeatureClient': ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@codeware/apps/cms/components/NavigationArrayRowLabel#default': + default_d497a38447405736d600359900364450, '@payloadcms/plugin-seo/client#OverviewComponent': OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860, '@payloadcms/plugin-seo/client#MetaTitleComponent': diff --git a/apps/cms/src/collections/navigation/navigation.collection.tsx b/apps/cms/src/collections/navigation/navigation.collection.tsx new file mode 100644 index 000000000..380ff8c91 --- /dev/null +++ b/apps/cms/src/collections/navigation/navigation.collection.tsx @@ -0,0 +1,95 @@ +import type { CollectionConfig, Condition } from 'payload'; + +import { getEnv } from '@codeware/app-cms/feature/env-loader'; +import { + systemUserOrTenantAdminAccess, + verifyApiKeyAccess +} from '@codeware/app-cms/util/access'; +import { adminGroups } from '@codeware/app-cms/util/definitions'; +import type { Navigation } from '@codeware/shared/util/payload-types'; + +const env = getEnv(); + +/** + * Whether a custom label source is selected. + */ +const isCustomLabelSource: Condition< + Navigation, + NonNullable[number] +> = (_, siblingData) => siblingData.labelSource === 'custom'; + +/** + * Navigation collection. + */ +const navigation: CollectionConfig = { + slug: 'navigation', + admin: { + group: adminGroups.settings + }, + access: { + read: verifyApiKeyAccess({ secret: env.SIGNATURE_SECRET }), + update: systemUserOrTenantAdminAccess + }, + labels: { + singular: { en: 'Navigation', sv: 'Navigation' }, + plural: { en: 'Navigation', sv: 'Navigation' } + }, + fields: [ + { + name: 'items', + type: 'array', + label: { en: 'Navigation Tree', sv: 'Navigationsträd' }, + fields: [ + { + name: 'reference', + type: 'relationship', + label: { + en: 'Navigate to document', + sv: 'Navigera till dokument' + }, + relationTo: ['pages', 'posts'], + required: true + }, + { + name: 'labelSource', + type: 'radio', + label: false, + admin: { + layout: 'horizontal' + }, + defaultValue: 'document', + options: [ + { + label: { + en: 'Use document name as link label', + sv: 'Använd dokumentets namn som länktext' + }, + value: 'document' + }, + { + label: { en: 'Custom link label', sv: 'Anpassad länktext' }, + value: 'custom' + } + ] + }, + { + name: 'customLabel', + type: 'text', + label: { en: 'Link label', sv: 'Länktext' }, + admin: { + condition: isCustomLabelSource + }, + required: true + } + ], + admin: { + initCollapsed: true, + components: { + RowLabel: '@codeware/apps/cms/components/NavigationArrayRowLabel' + } + } + } + ] +}; + +export default navigation; diff --git a/apps/cms/src/components/NavigationArrayRowLabel.tsx b/apps/cms/src/components/NavigationArrayRowLabel.tsx new file mode 100644 index 000000000..0024e3a36 --- /dev/null +++ b/apps/cms/src/components/NavigationArrayRowLabel.tsx @@ -0,0 +1,63 @@ +import type { Navigation } from '@codeware/shared/util/payload-types'; + +import type { ArrayRowLabel } from './array-row-label.type'; + +/** + * Custom array row label for the navigation array field. + * + * Displays the navigation item label instead of the default row number. + */ +export const NavigationArrayRowLabel: React.FC = async ( + props +) => { + const { payload, data, rowLabel, rowNumber } = props; + const { items } = data as Navigation; + + // Get current nav item + const navItem = (items ?? []).at((rowNumber ?? 0) - 1); + + if (!navItem?.reference) { + return rowLabel; + } + + const { + customLabel, + labelSource, + reference: { relationTo, value } + } = navItem; + + // Check custom label + if (labelSource === 'custom') { + return customLabel; + } + + // Check reference to pages + if (relationTo === 'pages') { + if (typeof value === 'object') { + return value.name; + } + const page = await payload.findByID({ + collection: 'pages', + id: value, + disableErrors: true + }); + return page?.name ?? `Page #${value}`; + } + + // Check reference to posts + if (relationTo === 'posts') { + if (typeof value === 'object') { + return value.title; + } + const post = await payload.findByID({ + collection: 'posts', + id: value, + disableErrors: true + }); + return post?.title ?? `Post #${value}`; + } + + throw new Error('Invalid reference relation type'); +}; + +export default NavigationArrayRowLabel; diff --git a/apps/cms/src/components/array-row-label.type.ts b/apps/cms/src/components/array-row-label.type.ts new file mode 100644 index 000000000..369fef69f --- /dev/null +++ b/apps/cms/src/components/array-row-label.type.ts @@ -0,0 +1,10 @@ +import type { RowLabelProps } from '@payloadcms/ui'; +import type { ArrayFieldServerProps } from 'payload'; + +/** + * Custom type for the array row label component. + * + * Couldn't find a dedicated type? + */ +export type ArrayRowLabel = ArrayFieldServerProps & + Pick & { rowLabel: string }; diff --git a/apps/cms/src/payload.config.ts b/apps/cms/src/payload.config.ts index ca1be5fbe..200e18248 100644 --- a/apps/cms/src/payload.config.ts +++ b/apps/cms/src/payload.config.ts @@ -19,6 +19,7 @@ import { getPugins } from '@codeware/app-cms/util/plugins'; import categories from './collections/categories/categories.collection'; import media from './collections/media/media.collection'; +import navigation from './collections/navigation/navigation.collection'; import pages from './collections/pages/pages.collection'; import posts from './collections/posts/posts.collection'; import siteSettings from './collections/site-settings/site-settings.collection'; @@ -46,7 +47,16 @@ export default buildConfig({ // Declare blocks globally and reference then by slug elsewhere // https://payloadcms.com/docs/fields/blocks#block-references blocks: [codeBlock, contentBlock, formBlock, mediaBlock], - collections: [categories, media, pages, posts, siteSettings, tenants, users], + collections: [ + categories, + media, + navigation, + pages, + posts, + siteSettings, + tenants, + users + ], cors: env.CORS_URLS === '*' ? '*' : env.CORS_URLS.split(',').filter(Boolean), csrf: env.CSRF_URLS ? env.CSRF_URLS.split(',').filter(Boolean) : undefined, db: postgresAdapter({ diff --git a/libs/app-cms/util/plugins/src/lib/plugins/get-multi-tenant-plugin.ts b/libs/app-cms/util/plugins/src/lib/plugins/get-multi-tenant-plugin.ts index 3972447e6..447facb5a 100644 --- a/libs/app-cms/util/plugins/src/lib/plugins/get-multi-tenant-plugin.ts +++ b/libs/app-cms/util/plugins/src/lib/plugins/get-multi-tenant-plugin.ts @@ -2,8 +2,8 @@ import { hasRole } from '@codeware/app-cms/util/misc'; import type { Config } from '@codeware/shared/util/payload-types'; import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'; -export const getMultiTenantPlugin = () => { - return multiTenantPlugin({ +export const getMultiTenantPlugin = () => + multiTenantPlugin({ // Default values, but specified for clarity cleanupAfterTenantDelete: true, debug: false, @@ -15,6 +15,7 @@ export const getMultiTenantPlugin = () => { categories: {}, forms: {}, 'form-submissions': {}, + navigation: { isGlobal: true }, media: {}, pages: {}, posts: {}, @@ -26,4 +27,3 @@ export const getMultiTenantPlugin = () => { tenantSelectorLabel: { en: 'Workspace scope', sv: 'Vald arbetsyta' }, userHasAccessToAllTenants: (user) => hasRole(user, 'system-user') }); -}; diff --git a/libs/shared/util/payload-api/src/index.ts b/libs/shared/util/payload-api/src/index.ts index ac4f4cc53..a5503b75c 100644 --- a/libs/shared/util/payload-api/src/index.ts +++ b/libs/shared/util/payload-api/src/index.ts @@ -8,6 +8,14 @@ export type { RequestBaseOptions } from './lib/utils/invoke-request'; export { findBySlug } from './lib/find-by-slug'; +export { + findNavigationDoc, + type NavigationDoc +} from './lib/find-navigation-doc'; export { getShallow } from './lib/get-shallow'; export { post } from './lib/post'; +export { + getNavigationTree, + type NavigationItem +} from './lib/get-navigation-tree'; export { getSiteSettings } from './lib/get-site-settings'; diff --git a/libs/shared/util/payload-api/src/lib/find-navigation-doc.ts b/libs/shared/util/payload-api/src/lib/find-navigation-doc.ts new file mode 100644 index 000000000..dab6a1e29 --- /dev/null +++ b/libs/shared/util/payload-api/src/lib/find-navigation-doc.ts @@ -0,0 +1,75 @@ +import type { + Media, + NavigationReferenceCollection, + Page, + Post +} from '@codeware/shared/util/payload-types'; + +import { type RequestBaseOptions, invokeRequest } from './utils/invoke-request'; + +// Limit what can be exposed client side +export type NavigationDoc = + | ({ + collection: 'pages'; + } & Pick) + | ({ + collection: 'posts'; + } & Pick & { + heroImage?: Media | null | undefined; + }); + +/** + * Find a navigation document by the URL collection and slug parameters. + * + * @param collection - The collection to find the document in (default: `pages`). + * @param slug - The slug of the document to find in the collection. + * @param options - The options to find the document with. + * @returns The document for the slug or `null` if the document is not found. + * @throws A formatted error message when the request fails. + */ +export const findNavigationDoc = async ( + collection: NavigationReferenceCollection | string | undefined, + slug: string, + options: RequestBaseOptions +): Promise => { + // Default to pages if not provided + const lookupCollection = (collection || + 'pages') as NavigationReferenceCollection; + + const response = await invokeRequest(lookupCollection, { + ...options, + method: 'GET', + query: `where[slug][equals]=${slug}` + }); + + if ('error' in response) { + throw new Error( + `Error fetching '${slug}' from '${lookupCollection}': ${response.error}` + ); + } + + const data = response.data?.[0]; + + if (data && lookupCollection === 'pages') { + const page = data as Page; + return { + collection: lookupCollection, + header: page.header, + layout: page.layout, + name: page.name + }; + } + + if (data && lookupCollection === 'posts') { + const post = data as Post; + return { + collection: lookupCollection, + content: post.content, + heroImage: + typeof post.heroImage === 'object' ? post.heroImage : undefined, + title: post.title + }; + } + + return null; +}; diff --git a/libs/shared/util/payload-api/src/lib/get-navigation-tree.ts b/libs/shared/util/payload-api/src/lib/get-navigation-tree.ts new file mode 100644 index 000000000..f9720d129 --- /dev/null +++ b/libs/shared/util/payload-api/src/lib/get-navigation-tree.ts @@ -0,0 +1,96 @@ +import type { NavigationReferenceCollection } from '@codeware/shared/util/payload-types'; + +import { type RequestBaseOptions, invokeRequest } from './utils/invoke-request'; + +export type NavigationItem = { + /** + * The collection for the navigation item. + */ + collection: NavigationReferenceCollection; + + /** + * Unique identifier for the navigation item. + */ + key: string; + + /** + * Navigation label in the language declared in the request. + */ + label: string; + + /** + * The URL of the navigation item as a `collection/slug` string. + * + * Use `findNavigationDoc` to fetch the document from the CMS. + */ + url: string; +}; + +/** + * Get the site navigation tree. + * + * The router setup must be able to detect `/collection/slug` URLs, + * where `collection` should be optional. + * + * Example: + * ```ts + * '/about-us' + * '/articles' + * '/posts/my-first-post' + * '/media/ref-doc-123.pdf' + * ``` + * + * When the document data for the current route is requested, + * use `findNavigationDoc` function which will match the signature. + * + * @param options - The options to get the site navigation tree with. + * @returns The site navigation tree. + * @throws A formatted error message when the request fails. + */ +export const getNavigationTree = async ( + options: RequestBaseOptions +): Promise> => { + const response = await invokeRequest('navigation', { + ...options, + method: 'GET' + }); + + if ('error' in response) { + throw new Error(`Error fetching navigation: ${response.error}`); + } + + const items = response.data[0].items ?? []; + + return items.reduce((acc, { customLabel, id, labelSource, reference }) => { + if (typeof reference.value === 'number') { + return acc; + } + + const { relationTo, value } = reference; + + const key = id ?? String(value.id); + + const label = + labelSource === 'custom' && customLabel + ? customLabel + : relationTo === 'pages' + ? value.name + : value.title; + + // Create URL where 'pages' is the default collection and not provided + const url = + relationTo === 'pages' + ? String(value.slug) + : `${relationTo}/${value.slug}`; + + return [ + ...acc, + { + collection: relationTo, + key, + label, + url + } + ]; + }, [] as Array); +}; diff --git a/libs/shared/util/payload-types/src/lib/custom-types.ts b/libs/shared/util/payload-types/src/lib/custom-types.ts index e56b0946e..bfaf1d820 100644 --- a/libs/shared/util/payload-types/src/lib/custom-types.ts +++ b/libs/shared/util/payload-types/src/lib/custom-types.ts @@ -6,6 +6,7 @@ import type { ContentBlock, Form, FormSubmission, + Navigation, Tenant, TenantsArrayField } from './payload-types'; @@ -67,3 +68,8 @@ export type FormFieldForBlockType = FormField & { export type FormSubmissionData = NonNullable< NonNullable >; + +/** Navigation reference collection */ +export type NavigationReferenceCollection = NonNullable< + NonNullable[number] +>['reference']['relationTo']; diff --git a/libs/shared/util/payload-types/src/lib/payload-types.ts b/libs/shared/util/payload-types/src/lib/payload-types.ts index 0fa20d5ef..5a6989588 100644 --- a/libs/shared/util/payload-types/src/lib/payload-types.ts +++ b/libs/shared/util/payload-types/src/lib/payload-types.ts @@ -90,6 +90,7 @@ export interface Config { collections: { categories: Category; media: Media; + navigation: Navigation; pages: Page; posts: Post; 'site-settings': SiteSetting; @@ -119,6 +120,7 @@ export interface Config { collectionsSelect: { categories: CategoriesSelect | CategoriesSelect; media: MediaSelect | MediaSelect; + navigation: NavigationSelect | NavigationSelect; pages: PagesSelect | PagesSelect; posts: PostsSelect | PostsSelect; 'site-settings': SiteSettingsSelect | SiteSettingsSelect; @@ -790,6 +792,32 @@ export interface Category { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "navigation". + */ +export interface Navigation { + id: number; + tenant?: (number | null) | Tenant; + items?: + | { + reference: + | { + relationTo: 'pages'; + value: number | Page; + } + | { + relationTo: 'posts'; + value: number | Post; + }; + labelSource?: ('document' | 'custom') | null; + customLabel?: string | null; + id?: string | null; + }[] + | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "site-settings". @@ -840,6 +868,10 @@ export interface PayloadLockedDocument { relationTo: 'media'; value: number | Media; } | null) + | ({ + relationTo: 'navigation'; + value: number | Navigation; + } | null) | ({ relationTo: 'pages'; value: number | Page; @@ -1028,6 +1060,23 @@ export interface MediaSelect { }; }; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "navigation_select". + */ +export interface NavigationSelect { + tenant?: T; + items?: + | T + | { + reference?: T; + labelSource?: T; + customLabel?: T; + id?: T; + }; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "pages_select". From 332bf961242254007c016be1cde918bd4b576a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kan=20Str=C3=B6berg?= Date: Tue, 1 Apr 2025 22:36:51 +0200 Subject: [PATCH 4/9] fix(cms): render create user workspaces for tenant admins closed COD-307 --- apps/cms/src/app/(payload)/admin/importMap.js | 11 ++-- .../users/fields/tenants-array.field.ts | 4 +- ...yRowLabel.tsx => TenantsArrayRowLabel.tsx} | 13 ++-- apps/cms/src/payload.config.ts | 4 +- .../lib/system-user-or-tenant-admin.access.ts | 65 ++++++++++++++++--- libs/app-cms/util/plugins/src/index.ts | 2 +- .../util/plugins/src/lib/get-plugins.ts | 2 +- 7 files changed, 74 insertions(+), 27 deletions(-) rename apps/cms/src/components/{ArrayRowLabel.tsx => TenantsArrayRowLabel.tsx} (66%) diff --git a/apps/cms/src/app/(payload)/admin/importMap.js b/apps/cms/src/app/(payload)/admin/importMap.js index ea894dd55..890d91980 100644 --- a/apps/cms/src/app/(payload)/admin/importMap.js +++ b/apps/cms/src/app/(payload)/admin/importMap.js @@ -1,11 +1,13 @@ import { TenantField as TenantField_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'; import { TenantSelector as TenantSelector_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'; +import { GlobalViewRedirect as GlobalViewRedirect_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'; import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'; import { OverviewComponent as OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'; import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'; import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'; import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'; import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'; +import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'; import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'; import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'; import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'; @@ -24,7 +26,6 @@ import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93 import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'; import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'; import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'; -import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'; import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'; import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'; import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'; @@ -33,9 +34,9 @@ import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc056 import { default as default_83b0dfab156f3636ed94b94854d15ad5 } from '@codeware/app-cms/ui/components/RedirectNotifier'; import { default as default_7925a79d2af6389df70d2dd269ffbfbb } from '@codeware/app-cms/ui/components/VerifyTenantDomain'; import { default as default_06af4458abd1296f9d6bccce90425927 } from '@codeware/app-cms/ui/fields/code/Code.client'; -import { default as default_52b6c8f3cfeb54cb642a26fe54c075b9 } from '@codeware/apps/cms/components/ArrayRowLabel'; import { default as default_42ab7a6f795fd44e8c166a2bb6b2adc0 } from '@codeware/apps/cms/components/Logo.client'; import { default as default_d497a38447405736d600359900364450 } from '@codeware/apps/cms/components/NavigationArrayRowLabel'; +import { default as default_dec1059b7bb8eb8da3a9f0fc400fffbd } from '@codeware/apps/cms/components/TenantsArrayRowLabel'; export const importMap = { '@payloadcms/plugin-multi-tenant/client#TenantField': @@ -98,10 +99,12 @@ export const importMap = { BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, '@codeware/app-cms/ui/fields/code/Code.client#default': default_06af4458abd1296f9d6bccce90425927, - '@codeware/apps/cms/components/ArrayRowLabel#default': - default_52b6c8f3cfeb54cb642a26fe54c075b9, + '@codeware/apps/cms/components/TenantsArrayRowLabel#default': + default_dec1059b7bb8eb8da3a9f0fc400fffbd, '@codeware/apps/cms/components/Logo.client#default': default_42ab7a6f795fd44e8c166a2bb6b2adc0, + '@payloadcms/plugin-multi-tenant/rsc#GlobalViewRedirect': + GlobalViewRedirect_d6d5f193a167989e2ee7d14202901e62, '@codeware/app-cms/ui/components/VerifyTenantDomain#default': default_7925a79d2af6389df70d2dd269ffbfbb, '@codeware/app-cms/ui/components/RedirectNotifier#default': diff --git a/apps/cms/src/collections/users/fields/tenants-array.field.ts b/apps/cms/src/collections/users/fields/tenants-array.field.ts index bf1251f5e..d3ea403d3 100644 --- a/apps/cms/src/collections/users/fields/tenants-array.field.ts +++ b/apps/cms/src/collections/users/fields/tenants-array.field.ts @@ -57,7 +57,7 @@ export const tenantsArrayField = (): Field => { plural: { en: 'Workspaces', sv: 'Arbetsytor' } }, access: { - create: systemUserAccess, + create: systemUserOrTenantAdminAccess, update: systemUserOrTenantAdminAccess }, admin: { @@ -70,7 +70,7 @@ export const tenantsArrayField = (): Field => { // Hide the top level label since we have a similar label in the tab Label: undefined, // Custom tenant/role header - RowLabel: '@codeware/apps/cms/components/ArrayRowLabel' + RowLabel: '@codeware/apps/cms/components/TenantsArrayRowLabel' }, initCollapsed: true } diff --git a/apps/cms/src/components/ArrayRowLabel.tsx b/apps/cms/src/components/TenantsArrayRowLabel.tsx similarity index 66% rename from apps/cms/src/components/ArrayRowLabel.tsx rename to apps/cms/src/components/TenantsArrayRowLabel.tsx index 13afd0371..c2bccbd32 100644 --- a/apps/cms/src/components/ArrayRowLabel.tsx +++ b/apps/cms/src/components/TenantsArrayRowLabel.tsx @@ -1,19 +1,16 @@ -import type { RowLabelProps } from '@payloadcms/ui'; -import type { ArrayFieldServerProps } from 'payload'; import React from 'react'; +import { ArrayRowLabel } from './array-row-label.type'; + /** * Custom array row label for the tenants array field. * * Displays the tenant name instead of the default row number. */ -export const ArrayRowLabel: React.FC< - // Couldn't find a dedicated type? - ArrayFieldServerProps & - Pick & { rowLabel: string } -> = async (props) => { +export const TenantsArrayRowLabel: React.FC = async (props) => { const { payload, path, formState, rowLabel, rowNumber } = props; + // TODO: Can this be type safe? const tenantPath = `${path}.${Number(rowNumber ?? 0) - 1}.tenant`; const tenantId = formState[tenantPath]?.value as number; @@ -32,4 +29,4 @@ export const ArrayRowLabel: React.FC< return
{tenant.name}
; }; -export default ArrayRowLabel; +export default TenantsArrayRowLabel; diff --git a/apps/cms/src/payload.config.ts b/apps/cms/src/payload.config.ts index 200e18248..2bbebd85b 100644 --- a/apps/cms/src/payload.config.ts +++ b/apps/cms/src/payload.config.ts @@ -15,7 +15,7 @@ import { } from '@codeware/app-cms/ui/blocks'; import { defaultLexical } from '@codeware/app-cms/ui/fields'; import { getEmailAdapter } from '@codeware/app-cms/util/email'; -import { getPugins } from '@codeware/app-cms/util/plugins'; +import { getPlugins } from '@codeware/app-cms/util/plugins'; import categories from './collections/categories/categories.collection'; import media from './collections/media/media.collection'; @@ -68,7 +68,7 @@ export default buildConfig({ }), editor: defaultLexical, email: getEmailAdapter(env), - plugins: getPugins(env), + plugins: getPlugins(env), secret: env.PAYLOAD_SECRET_KEY, // i18n support i18n: { fallbackLanguage: 'sv' }, diff --git a/libs/app-cms/util/access/src/lib/system-user-or-tenant-admin.access.ts b/libs/app-cms/util/access/src/lib/system-user-or-tenant-admin.access.ts index 700ca39f5..253ad1692 100644 --- a/libs/app-cms/util/access/src/lib/system-user-or-tenant-admin.access.ts +++ b/libs/app-cms/util/access/src/lib/system-user-or-tenant-admin.access.ts @@ -1,15 +1,62 @@ import { getUserTenantIDs, hasRole } from '@codeware/app-cms/util/misc'; -import type { PayloadRequest } from 'payload'; +import type { AccessArgs, AccessResult, FieldAccess } from 'payload'; + +type FieldAccessArgs = Parameters[0]; +type FieldAccessResponse = ReturnType; /** * Access control supporting both collection and field level. * - * Allows access if the user has the `system-user` role - * or is a tenant admin for any of the tenants it belongs to. + * Always allow access to system users. + * + * Allow access to tenant admins for the tenant the document belongs to. + * This is determined by looking for the `tenant` property in the document data. + * + * Otherwise, allow access if the user is a tenant admin for any tenant. */ -export const systemUserOrTenantAdminAccess = < - T extends { req: PayloadRequest } ->({ - req: { user } -}: T): boolean => - hasRole(user, 'system-user') || getUserTenantIDs(user, 'admin').length > 0; +export function systemUserOrTenantAdminAccess(args: AccessArgs): AccessResult; +export function systemUserOrTenantAdminAccess( + args: FieldAccessArgs +): FieldAccessResponse; +export function systemUserOrTenantAdminAccess( + args: AccessArgs | FieldAccessArgs +): AccessResult | FieldAccessResponse { + const { + req: { user } + } = args; + + // System user always has access + if (hasRole(user, 'system-user')) { + return true; + } + + // Get tenant IDs where the user is a tenant admin + const tenantIDs = getUserTenantIDs(user, 'admin'); + + // If tenant property exists in data or doc, we can verify tenant admin access for the document + if ( + // doc is original document data for field access on update + ('doc' in args && args.doc?.tenant) || + // data is only null on list requests + (args.data && args.data?.tenant) + ) { + // Fields access doesn't support query constraints + // https://payloadcms.com/docs/access-control/fields + if ('doc' in args) { + const tenant = args.doc?.tenant ?? args.data?.['tenant']; + if (!tenant) { + throw new Error('Expected to find tenant in fields doc or data'); + } + return tenantIDs.includes(tenant); + } + + return { + tenant: { + in: tenantIDs + } + }; + } + + // Otherwise, just check if the user is a tenant admin for any tenant + return tenantIDs.length > 0; +} diff --git a/libs/app-cms/util/plugins/src/index.ts b/libs/app-cms/util/plugins/src/index.ts index 6ba81a336..8b23f10a9 100644 --- a/libs/app-cms/util/plugins/src/index.ts +++ b/libs/app-cms/util/plugins/src/index.ts @@ -1 +1 @@ -export { getPugins } from './lib/get-plugins'; +export { getPlugins } from './lib/get-plugins'; diff --git a/libs/app-cms/util/plugins/src/lib/get-plugins.ts b/libs/app-cms/util/plugins/src/lib/get-plugins.ts index 72f1f1f10..132d2fb37 100644 --- a/libs/app-cms/util/plugins/src/lib/get-plugins.ts +++ b/libs/app-cms/util/plugins/src/lib/get-plugins.ts @@ -12,7 +12,7 @@ import { getSeoPlugin } from './plugins/get-seo-plugin'; * @param env - The environment variables * @returns Array of plugins */ -export const getPugins = (env: Env): Array => [ +export const getPlugins = (env: Env): Array => [ getFormsPlugin(), getMultiTenantPlugin(), getSeoPlugin(), From 74eb6ff440021242f9085b4775fcc7efe64dd991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kan=20Str=C3=B6berg?= Date: Tue, 1 Apr 2025 23:19:33 +0200 Subject: [PATCH 5/9] feat(web): get landing page and app name from cms site settings closed COD-302,COD-304 --- apps/web/app/root.tsx | 21 ++++++++++--- apps/web/app/routes/_index.tsx | 41 ++++++------------------- apps/web/app/utils/use-site-settings.ts | 23 ++++++++++++++ apps/web/tests/routes/_index.spec.tsx | 20 +++++++++++- 4 files changed, 67 insertions(+), 38 deletions(-) create mode 100644 apps/web/app/utils/use-site-settings.ts diff --git a/apps/web/app/root.tsx b/apps/web/app/root.tsx index bba0ff39e..81159fd6c 100644 --- a/apps/web/app/root.tsx +++ b/apps/web/app/root.tsx @@ -3,8 +3,12 @@ import { type PayloadValue } from '@codeware/shared/ui/payload-components'; import { CdwrCloud } from '@codeware/shared/ui/react-components'; -import { getShallow } from '@codeware/shared/util/payload-api'; -import type { Page, Post } from '@codeware/shared/util/payload-types'; +import { getShallow, getSiteSettings } from '@codeware/shared/util/payload-api'; +import type { + Page, + Post, + SiteSetting +} from '@codeware/shared/util/payload-types'; import type { LinksFunction, LoaderFunctionArgs, @@ -37,9 +41,9 @@ import { type Theme, getTheme } from './utils/theme.server'; export type PageDetails = Pick & { slug: string }; export type PostDetails = Pick & { slug: string }; -export const meta: MetaFunction = () => [ +export const meta: MetaFunction = ({ data }) => [ { - title: 'Codeware Web' + title: data?.siteSettings.general.appName } ]; @@ -66,6 +70,7 @@ export async function loader({ context, request }: LoaderFunctionArgs) { let displayError = ''; let pages: Array = []; let posts: Array = []; + let siteSettings = {} as SiteSetting; // Fetch layout data but don't propagate the exception to the error boundary try { @@ -74,6 +79,11 @@ export async function loader({ context, request }: LoaderFunctionArgs) { context, request.headers ); + const gss = await getSiteSettings(requestOptions); + if (!gss) { + throw new Error('Site settings not found'); + } + siteSettings = gss; pages = await getShallow('pages', requestOptions); posts = await getShallow('posts', requestOptions); } catch (e) { @@ -107,7 +117,8 @@ export async function loader({ context, request }: LoaderFunctionArgs) { userPrefs: { theme: theme } - } + }, + siteSettings }; } catch (error) { console.error('Failed to load root data:\n', error); diff --git a/apps/web/app/routes/_index.tsx b/apps/web/app/routes/_index.tsx index 9cf105375..28605e360 100644 --- a/apps/web/app/routes/_index.tsx +++ b/apps/web/app/routes/_index.tsx @@ -1,10 +1,8 @@ import { RenderBlocks } from '@codeware/shared/ui/payload-components'; -import { findBySlug } from '@codeware/shared/util/payload-api'; -import { type LoaderFunctionArgs, json } from '@remix-run/node'; -import { MetaFunction, useLoaderData, useRouteError } from '@remix-run/react'; +import { type MetaFunction, useRouteError } from '@remix-run/react'; import { Container } from '../components/container'; -import { getPayloadRequestOptions } from '../utils/get-payload-request-options'; +import { useSiteSettings } from '../utils/use-site-settings'; type LoaderError = { message: string; @@ -12,46 +10,25 @@ type LoaderError = { }; // TODO: How to use it properly? -export const meta: MetaFunction = ({ data }) => { - const title = data?.page ? data.page.name : 'Page Not Found'; - return [{ title }]; +export const meta: MetaFunction = () => { + const { landingPage } = useSiteSettings(); + return [{ title: landingPage.name }]; }; -/** - * Fetch page data for home page. - */ -export async function loader({ context, request }: LoaderFunctionArgs) { - const page = await findBySlug( - 'pages', - 'home', - getPayloadRequestOptions('GET', context, request.headers) - ); - - if (!page) { - const error: LoaderError = { - message: 'Page failed to load', - status: 404 - }; - throw Response.json(error); - } - - return json({ page }); -} - export default function Index() { - const { page } = useLoaderData(); + const { landingPage } = useSiteSettings(); return ( - {page.header && ( + {landingPage.header && (

- {page.header} + {landingPage.header}

)}
- +
); diff --git a/apps/web/app/utils/use-site-settings.ts b/apps/web/app/utils/use-site-settings.ts new file mode 100644 index 000000000..ff755f703 --- /dev/null +++ b/apps/web/app/utils/use-site-settings.ts @@ -0,0 +1,23 @@ +import { useRouteLoaderData } from '@remix-run/react'; +import invariant from 'tiny-invariant'; + +import { type loader as rootLoader } from '../root'; + +/** + * @returns the site settings from the root loader + */ +export function useSiteSettings() { + const data = useRouteLoaderData('root'); + invariant(data?.siteSettings, 'No site settings found in root loader'); + const { + general: { landingPage } + } = data.siteSettings; + invariant( + typeof landingPage !== 'number', + 'No landing page found in site settings' + ); + + return { + landingPage + }; +} diff --git a/apps/web/tests/routes/_index.spec.tsx b/apps/web/tests/routes/_index.spec.tsx index 456b92880..23859d9d9 100644 --- a/apps/web/tests/routes/_index.spec.tsx +++ b/apps/web/tests/routes/_index.spec.tsx @@ -1,13 +1,31 @@ +import { SiteSetting } from '@codeware/shared/util/payload-types'; +import * as RemixReact from '@remix-run/react'; import { createRemixStub } from '@remix-run/testing'; import { render, screen, waitFor } from '@testing-library/react'; import Index, { ErrorBoundary } from '../../app/routes/_index'; it('renders loader data', async () => { + vi.spyOn(RemixReact, 'useRouteLoaderData').mockImplementation((routeId) => { + if (routeId === 'root') { + return { + siteSettings: { + general: { + appName: 'Test App', + landingPage: { + header: 'Welcome home!', + name: 'home' + } + } + } as Partial + }; + } + return undefined; + }); + const RemixStub = createRemixStub([ { path: '/', - loader: () => ({ page: { title: 'Home', header: 'Welcome home!' } }), Component: Index } ]); From 4f6fc6c0e1a85b019a5337a928bf68deff3f531b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kan=20Str=C3=B6berg?= Date: Tue, 1 Apr 2025 23:36:48 +0200 Subject: [PATCH 6/9] feat(web): get navigation menu from cms closed COD-300 --- .../web/app/components/desktop-navigation.tsx | 15 +++---- apps/web/app/components/footer.tsx | 17 +++---- apps/web/app/components/mobile-navigation.tsx | 14 +++--- apps/web/app/components/render-pages-doc.tsx | 26 +++++++++++ apps/web/app/components/render-posts-doc.tsx | 29 ++++++++++++ apps/web/app/root.tsx | 40 +++++------------ .../{$slug.tsx => ($collection).$slug.tsx} | 44 +++++++++---------- apps/web/app/utils/filter-pages.ts | 13 ------ apps/web/app/utils/request-info.ts | 1 - .../{pages.ts => use-navigation-tree.ts} | 8 ++-- 10 files changed, 113 insertions(+), 94 deletions(-) create mode 100644 apps/web/app/components/render-pages-doc.tsx create mode 100644 apps/web/app/components/render-posts-doc.tsx rename apps/web/app/routes/{$slug.tsx => ($collection).$slug.tsx} (66%) delete mode 100644 apps/web/app/utils/filter-pages.ts rename apps/web/app/utils/{pages.ts => use-navigation-tree.ts} (52%) diff --git a/apps/web/app/components/desktop-navigation.tsx b/apps/web/app/components/desktop-navigation.tsx index 43ddf7181..f5b2bd246 100644 --- a/apps/web/app/components/desktop-navigation.tsx +++ b/apps/web/app/components/desktop-navigation.tsx @@ -1,8 +1,7 @@ import { cn } from '@codeware/shared/util/ui'; import { NavLink } from '@remix-run/react'; -import { filterPages } from '../utils/filter-pages'; -import { usePages } from '../utils/pages'; +import { useNavigationTree } from '../utils/use-navigation-tree'; function NavItem({ href, @@ -40,19 +39,17 @@ function NavItem({ export function DesktopNavigation( props: React.ComponentPropsWithoutRef<'nav'> ) { - const pages = usePages(); - const pagesExceptHome = filterPages(pages, ['home']); - - if (pagesExceptHome.length === 0) { + const navigationTree = useNavigationTree(); + if (navigationTree.length === 0) { return null; } return (