diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..9e7e075 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(npx nx build:*)" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e417f11 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,71 @@ +# PageCreator - Project Guide + +## Project Overview +PageCreator is an NX monorepo CMS library for creating widget-based pages. It provides admin UI, user-facing rendering components, and a backend with models/controllers/routes. + +## Architecture + +### Monorepo Structure +``` +pagecreator/ +├── apps/ +│ ├── api/ # Express backend (demo server) +│ ├── pagecreator/ # Admin UI app (React/Vite) +│ ├── front/ # Public frontend (Next.js) +├── libs/ +│ ├── admin/ # Admin UI library (components, contexts, forms) +│ ├── user/ # User library (widget rendering components) +│ └── node/ # Backend library (models, services, controllers, routes) +``` + +### Tech Stack +- **Backend**: Express, MongoDB (Mongoose), Redis (caching) +- **Admin**: React 18, React Hook Form, React Beautiful DnD, React Select +- **User**: React 18, Swiper (carousel), React Tabs +- **Build**: NX, TypeScript + +### Library Exports +- `@nichekit/node` → Backend models, routes, controllers +- `@nichekit/admin` → Admin UI components (Widget, Page, Provider) +- `@nichekit/user` → User components (Widget, Page, getData) + +## Data Model +- **Page**: name, code, slug, widgets[] (Widget refs) +- **Widget**: name, code, widgetType, itemsType, items, tabs, collectionItems, layout config +- **Item**: title, subtitle, altText, link, img, srcset, itemType (Web/Mobile) +- **Tab**: name, widgetId, collectionItems[] +- **SrcSet**: width, height, screenSize, itemId + +### Widget Types: FixedCard, Carousel, Tabs, Text, HTML +### Item Types: Image (built-in), plus external collections via setConfig() + +## Key Patterns +- Config via `setConfig()`: collections, customWidgetTypes, languages, redis +- Redis caching: `widgetData_${code}`, `pageData_${code}` +- Admin state: React Context (WidgetContext, PageContext, ProviderContext) +- User lib: Props-based with callbacks (formatItem, onClick, formatHeader, formatFooter) +- CSS prefixes: `khb_` (admin), `kpc_` (user) +- Soft delete, unique code validation, multi-language support + +## Key Files +| Area | Path | +|------|------| +| Enums | `libs/node/src/types/enums.ts` | +| Types | `libs/node/src/types/common.ts` | +| Widget Model | `libs/node/src/models/Widget.ts` | +| Widget Controller | `libs/node/src/controllers/WidgetController.ts` | +| Data Service | `libs/node/src/services/dataService.ts` | +| Widget Form (Admin) | `libs/admin/src/lib/components/Widget/Form/WidgetForm.tsx` | +| Widget Context | `libs/admin/src/lib/context/WidgetContext.tsx` | +| Widget Router (User) | `libs/user/src/lib/components/widget/widget.tsx` | +| Page Component | `libs/user/src/lib/components/page/page.tsx` | +| User Types | `libs/user/src/lib/types/api.ts` | + +## Build Commands +```bash +npx nx build node # Backend library +npx nx build admin # Admin library +npx nx build user # User library +npx nx serve api # API server +npx nx serve pagecreator # Admin app +``` diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 6d27f6d..dac7849 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,5 +1,7 @@ import './db/db'; import './models/notification'; +import Blog from './models/blog'; +import BlogCategory from './models/blogCategory'; import express from 'express'; import cors from 'cors'; import path from 'path'; @@ -34,6 +36,12 @@ setConfig({ collectionName: 'project_assessment', searchColumns: ['assessmentNm', 'projectNm'], }, + { + title: 'Blogs', + collectionName: 'blog', + searchColumns: ['title', 'name', 'slug'], + match: { isPublished: true, isActive: true }, + }, ], // redis: { // HOST: 'localhost', @@ -66,8 +74,141 @@ app.get('/delete', (req, res) => { app.use(resize(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, './public'))); +// Seed test blogs +async function seedBlogs() { + try { + const count = await Blog.countDocuments(); + if (count === 0) { + await Blog.insertMany([ + { + title: 'Getting Started with React', + value: '123123123123', + name: 'Getting Started with React', + description: 'A beginner guide to building user interfaces with React.', + slug: 'getting-started-with-react', + coverImage: '/image/react_intro.png', + isPublished: true, + isActive: true, + isDeleted: false, + publishedAt: new Date('2025-08-10T10:00:00.000Z'), + viewCount: 250, + author: { id: '68c2d1d8ffb1adbf30004ded', nm: 'Ragnar Lothbrok', isActive: true }, + category: [{ id: '68c92a0426291fe3408a8141', nm: 'Technology', slug: 'technology', isActive: true }], + }, + { + title: 'Understanding MongoDB Aggregations', + value: '123123123123234', + name: 'Understanding MongoDB Aggregations', + description: 'Deep dive into MongoDB aggregation pipelines and their use cases.', + slug: 'understanding-mongodb-aggregations', + coverImage: '/image/mongodb_agg.png', + isPublished: true, + isActive: true, + isDeleted: false, + publishedAt: new Date('2025-09-01T08:30:00.000Z'), + viewCount: 180, + author: { id: '68c2d1d8ffb1adbf30004ded', nm: 'Ragnar Lothbrok', isActive: true }, + category: [{ id: '68c92a0426291fe3408a8142', nm: 'Database', slug: 'database', isActive: true }], + }, + { + title: 'Building REST APIs with Express', + value: '123123123123234234', + name: 'Building REST APIs with Express', + description: 'Learn how to create robust REST APIs using Express.js.', + slug: 'building-rest-apis-with-express', + coverImage: '/image/express_api.png', + isPublished: true, + isActive: true, + isDeleted: false, + publishedAt: new Date('2025-09-10T14:00:00.000Z'), + viewCount: 320, + author: { id: '68c2d1d8ffb1adbf30004ded', nm: 'Ragnar Lothbrok', isActive: true }, + category: [{ id: '68c92a0426291fe3408a8141', nm: 'Technology', slug: 'technology', isActive: true }], + }, + { + title: 'CSS Grid vs Flexbox', + value: '123123123123234234234234', + name: 'CSS Grid vs Flexbox', + description: 'Comparing CSS Grid and Flexbox for modern web layouts.', + slug: 'css-grid-vs-flexbox', + coverImage: '/image/css_layout.png', + isPublished: true, + isActive: true, + isDeleted: false, + publishedAt: new Date('2025-09-15T12:00:00.000Z'), + viewCount: 95, + author: { id: '68c2d1d8ffb1adbf30004ded', nm: 'Ragnar Lothbrok', isActive: true }, + category: [{ id: '68c92a0426291fe3408a8143', nm: 'Design', slug: 'design', isActive: true }], + }, + { + title: 'TypeScript Best Practices', + name: 'TypeScript Best Practices', + description: 'Tips and patterns for writing clean TypeScript code.', + slug: 'typescript-best-practices', + coverImage: '/image/typescript_tips.png', + isPublished: true, + isActive: true, + isDeleted: false, + publishedAt: new Date('2025-09-16T09:12:50.750Z'), + viewCount: 410, + author: { id: '68c2d1d8ffb1adbf30004ded', nm: 'Ragnar Lothbrok', isActive: true }, + category: [{ id: '68c92a0426291fe3408a8141', nm: 'Technology', slug: 'technology', isActive: true }], + }, + ]); + } + } catch (err) { + console.error('Error seeding blogs:', err); + } +} + +// Seed blog categories +async function seedBlogCategories() { + try { + const count = await BlogCategory.countDocuments(); + if (count === 0) { + await BlogCategory.insertMany([ + { + _id: new mongoose.Types.ObjectId('68c92a0426291fe3408a8141'), + nm: 'Technology', + slug: 'technology', + description: 'Articles about technology, programming, and software development', + isActive: true, + isDeleted: false, + createdBy: new mongoose.Types.ObjectId('68c2d1d8ffb1adbf30004ded'), + updatedBy: [new mongoose.Types.ObjectId('68c2d1d8ffb1adbf30004ded')], + }, + { + _id: new mongoose.Types.ObjectId('68c92a0426291fe3408a8142'), + nm: 'Database', + slug: 'database', + description: 'Database design, optimization, and best practices', + isActive: true, + isDeleted: false, + createdBy: new mongoose.Types.ObjectId('68c2d1d8ffb1adbf30004ded'), + updatedBy: [new mongoose.Types.ObjectId('68c2d1d8ffb1adbf30004ded')], + }, + { + _id: new mongoose.Types.ObjectId('68c92a0426291fe3408a8143'), + nm: 'Design', + slug: 'design', + description: 'UI/UX design, CSS, and frontend development', + isActive: true, + isDeleted: false, + createdBy: new mongoose.Types.ObjectId('68c2d1d8ffb1adbf30004ded'), + updatedBy: [new mongoose.Types.ObjectId('68c2d1d8ffb1adbf30004ded')], + }, + ]); + console.log('Blog categories seeded successfully'); + } + } catch (err) { + console.error('Error seeding blog categories:', err); + } +} + const port = process.env.port || 3333; -const server = app.listen(port, () => { +const server = app.listen(port, async () => { console.log('Listening at http://localhost:' + port); + await seedBlogCategories(); + await seedBlogs(); }); server.on('error', console.error); diff --git a/apps/api/src/models/blog.ts b/apps/api/src/models/blog.ts new file mode 100644 index 0000000..f6fb5ee --- /dev/null +++ b/apps/api/src/models/blog.ts @@ -0,0 +1,20 @@ +import { Schema, model } from 'mongoose'; +import mongoosePaginate from 'mongoose-paginate-v2'; + +const blogSchema = new Schema( + { + title: String, + name: String, + description: String, + slug: String, + coverImage: String, + isPublished: { type: Boolean, default: false }, + isActive: { type: Boolean, default: true }, + isDeleted: { type: Boolean, default: false }, + }, + { strict: false, timestamps: true } +); + +blogSchema.plugin(mongoosePaginate); +const Blog = model('blog', blogSchema); +export default Blog; diff --git a/apps/api/src/models/blogCategory.ts b/apps/api/src/models/blogCategory.ts new file mode 100644 index 0000000..d730f80 --- /dev/null +++ b/apps/api/src/models/blogCategory.ts @@ -0,0 +1,19 @@ +import { Schema, model } from 'mongoose'; +import mongoosePaginate from 'mongoose-paginate-v2'; + +const blogCategorySchema = new Schema( + { + nm: String, + slug: String, + description: String, + isActive: { type: Boolean, default: true }, + isDeleted: { type: Boolean, default: false }, + createdBy: Schema.Types.ObjectId, + updatedBy: [Schema.Types.ObjectId], + }, + { timestamps: true } +); + +blogCategorySchema.plugin(mongoosePaginate); +const BlogCategory = model('blogCategory', blogCategorySchema, 'blogCategory'); +export default BlogCategory; diff --git a/apps/pagecreator/src/main.tsx b/apps/pagecreator/src/main.tsx index a81df9b..62b790f 100644 --- a/apps/pagecreator/src/main.tsx +++ b/apps/pagecreator/src/main.tsx @@ -2,7 +2,9 @@ import * as ReactDOM from 'react-dom/client'; import App from './app/app'; -const root = ReactDOM.createRoot( - document.getElementById('root') as HTMLElement -); -root.render(); +const rootElement = document.getElementById('root') as HTMLElement | null; + +if (rootElement) { + const root = ReactDOM.createRoot(rootElement); + root.render( as unknown as Parameters[0]); +} diff --git a/libs/admin/package.json b/libs/admin/package.json index f9ce8f2..2068386 100644 --- a/libs/admin/package.json +++ b/libs/admin/package.json @@ -1,6 +1,6 @@ { "name": "@knovator/pagecreator-admin", - "version":"1.2.6", + "version": "1.7.4", "dependencies": { "classnames": "^2.3.1", "react-beautiful-dnd": "^13.1.0", @@ -35,4 +35,4 @@ "index.js", "index.cjs" ] -} +} \ No newline at end of file diff --git a/libs/admin/src/lib/api/list.ts b/libs/admin/src/lib/api/list.ts index 6a1cff9..fd570f4 100644 --- a/libs/admin/src/lib/api/list.ts +++ b/libs/admin/src/lib/api/list.ts @@ -45,6 +45,10 @@ const apiList = { url: `${prefix}/languages`, method: 'GET', }), + BLOG_CATEGORIES: ({ prefix }: API_INPUT_TYPE) => ({ + url: `${prefix}/blog-categories`, + method: 'GET', + }), // Image Upload API IMAGE_UPLOAD: ({ prefix }: API_INPUT_TYPE) => ({ url: `${prefix}/upload`, diff --git a/libs/admin/src/lib/components/Page/Form/PageForm.tsx b/libs/admin/src/lib/components/Page/Form/PageForm.tsx index 77ba2b6..5c8cdf9 100644 --- a/libs/admin/src/lib/components/Page/Form/PageForm.tsx +++ b/libs/admin/src/lib/components/Page/Form/PageForm.tsx @@ -16,7 +16,12 @@ import { import { CONSTANTS } from '../../../constants/common'; import { useProviderState } from '../../../context/ProviderContext'; -const PageForm = ({ formRef }: FormProps) => { +const PageForm = ({ + formRef, + onFilterClick, + filterQuery, + onPrimaryButtonClick, +}: FormProps) => { const { commonTranslations } = useProviderState(); const { data, @@ -109,6 +114,26 @@ const PageForm = ({ formRef }: FormProps) => { if (destination) onChangeWidgetSequence(source.index, destination.index); }; + const handlePageSubmit = (formData: Record) => { + const dataToSubmit = + typeof filterQuery !== 'undefined' + ? { ...formData, filterQuery } + : formData; + const submitPayload = + Array.isArray(selectedWidgets) && selectedWidgets.length > 0 + ? { + ...dataToSubmit, + widgets: selectedWidgets.map((item) => ({ + _id: item.value, + label: item.label, + code: item.code, + })), + } + : dataToSubmit; + onPrimaryButtonClick?.(undefined, submitPayload); + return onPageFormSubmit(dataToSubmit); + }; + // Schemas const pageFormSchema: SchemaType[] = [ { @@ -163,7 +188,7 @@ const PageForm = ({ formRef }: FormProps) => {
{ }} /> */} - + onFilterClick(data) : undefined} + items={selectedWidgets} + disableSettings={data?.canDel === false} + />
); }; diff --git a/libs/admin/src/lib/components/Page/Page/Page.tsx b/libs/admin/src/lib/components/Page/Page/Page.tsx index 2728307..afe3c0b 100644 --- a/libs/admin/src/lib/components/Page/Page/Page.tsx +++ b/libs/admin/src/lib/components/Page/Page/Page.tsx @@ -27,6 +27,8 @@ const Page = ({ // @ts-ignore permissions = {}, preConfirmDelete, + onPrimaryButtonClick, + onEditClick, }: PageProps) => { const { commonTranslations } = useProviderState(); const derivedPermissions = Object.assign(DEFAULT_PERMISSIONS, permissions); @@ -96,11 +98,11 @@ const Page = ({ {children ? ( children ) : ( - <> + <>
- +
@@ -116,9 +118,17 @@ const Page = ({ ? combinedTranslations.updatePage : '' } - footerContent={} + footerContent={ + + } > - + )} {itemData && ( diff --git a/libs/admin/src/lib/components/Page/PageFormActions/PageFormActions.tsx b/libs/admin/src/lib/components/Page/PageFormActions/PageFormActions.tsx index e559694..96cfaca 100644 --- a/libs/admin/src/lib/components/Page/PageFormActions/PageFormActions.tsx +++ b/libs/admin/src/lib/components/Page/PageFormActions/PageFormActions.tsx @@ -5,12 +5,16 @@ import { CALLBACK_CODES } from '../../../constants/common'; import { usePageState } from '../../../context/PageContext'; import { useProviderState } from '../../../context/ProviderContext'; -const PageFormActions = ({ formRef }: FormActionWrapperProps) => { +const PageFormActions = ({ + formRef, + onPrimaryButtonClick, +}: FormActionWrapperProps) => { const { onError, commonTranslations } = useProviderState(); const { closeForm, loading, canAdd, canUpdate, formState } = usePageState(); const onSubmitClick = ( e?: React.MouseEvent ) => { + onPrimaryButtonClick?.(e); if (!formRef) { return onError( CALLBACK_CODES.INTERNAL, diff --git a/libs/admin/src/lib/components/Page/Pagination/PagePagination.tsx b/libs/admin/src/lib/components/Page/Pagination/PagePagination.tsx index 8b41bfd..4070c40 100644 --- a/libs/admin/src/lib/components/Page/Pagination/PagePagination.tsx +++ b/libs/admin/src/lib/components/Page/Pagination/PagePagination.tsx @@ -10,7 +10,7 @@ const PagePagination = () => { return ( { +const PageTable = ({ + extraActions, + extraColumns, + onEditClick, +}: DerivedTableProps) => { const { commonTranslations } = useProviderState(); const { list, onChangeFormState, loading, loader, canUpdate, canDelete } = usePageState(); - const onUpdateClick = (item: CombineObjectType) => + const onUpdateClick = (item: CombineObjectType) => { + onEditClick?.(item); onChangeFormState('UPDATE', item); + }; const onDeleteClick = (item: CombineObjectType) => onChangeFormState('DELETE', item); diff --git a/libs/admin/src/lib/components/Widget/Form/WidgetForm.tsx b/libs/admin/src/lib/components/Widget/Form/WidgetForm.tsx index f610ce9..0197ddd 100644 --- a/libs/admin/src/lib/components/Widget/Form/WidgetForm.tsx +++ b/libs/admin/src/lib/components/Widget/Form/WidgetForm.tsx @@ -8,6 +8,7 @@ import ItemsAccordian from './ItemsAccordian'; import { useWidgetState } from '../../../context/WidgetContext'; import { useProviderState } from '../../../context/ProviderContext'; +import request from '../../../api'; import { capitalizeFirstLetter, changeToCode, @@ -33,13 +34,15 @@ const constants = { imageItemsTypeValue: 'Image', textWidgetTypeValue: 'Text', htmlWidgetTypeValue: 'HTML', + linksWidgetTypeValue: 'Links', + pagesItemsTypeValue: 'pages', tabsAccessor: 'tabs', webItems: 'webItems', mobileItems: 'mobileItems', tabCollectionItemsAccessor: 'collectionItems', }; -const WidgetForm = ({ formRef, customInputs }: FormProps) => { +const WidgetForm = ({ formRef, customInputs, onPrimaryButtonClick }: FormProps) => { const { register, formState: { errors }, @@ -51,10 +54,13 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { clearErrors, setError, getValues, - } = useForm({ + } = useForm({ shouldUnregister: false, + defaultValues: { + backgroundColor: '#ffffff', + }, }); - const { switchClass, commonTranslations } = useProviderState(); + const { switchClass, commonTranslations, baseUrl, token, widgetRoutesPrefix } = useProviderState(); const { data, canAdd, @@ -91,6 +97,12 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { const [tabCollectionItemsUpdated, setTabCollectionItemsUpdated] = useState< boolean[] >([]); + const [blogCategory, setBlogCategory] = useState(null); + const [blogLimit, setBlogLimit] = useState(undefined); + const [blogCategories, setBlogCategories] = useState([]); + const [blogCategoriesLoading, setBlogCategoriesLoading] = useState(false); + const pagesLoadedRef = useRef(false); + const blogCategoryInitialized = useRef(false); useEffect(() => { if (data && formState === 'UPDATE') { @@ -116,7 +128,8 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { } if ( data?.widgetType === constants.textWidgetTypeValue || - data?.widgetType === constants.htmlWidgetTypeValue + data?.widgetType === constants.htmlWidgetTypeValue || + data?.widgetType === constants.linksWidgetTypeValue ) { setItemsEnabled(false); } @@ -137,9 +150,116 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { } }, [data, reset]); + // Watch itemsType for blog category feature + const currentItemsType = watch(constants.itemTypeAccessor); + + // Fetch blog categories when itemsType is 'blogs' + useEffect(() => { + if (currentItemsType === 'blog' && blogCategories.length === 0) { + const fetchBlogCategories = async () => { + try { + setBlogCategoriesLoading(true); + const response = await request({ + baseUrl, + token, + method: 'GET', + url: `${widgetRoutesPrefix}/blog-categories`, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onError: (error: any) => console.error('Error fetching blog categories:', error), + }); + if (response?.code === 'SUCCESS' && Array.isArray(response.data?.docs)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const categories = response.data.docs.map((cat: any) => ({ + value: cat._id || cat.id, + label: cat.name || cat.nm || cat.label, + slug: cat.slug, + })); + setBlogCategories(categories); + } + } catch (error) { + console.error('Error fetching blog categories:', error); + } finally { + setBlogCategoriesLoading(false); + } + }; + fetchBlogCategories(); + } + }, [currentItemsType, baseUrl, token, widgetRoutesPrefix, blogCategories.length]); + + // Set blog category and limit when editing a widget + useEffect(() => { + if (formState === 'UPDATE' && data && currentItemsType === 'blog' && blogCategories.length > 0 && !blogCategoryInitialized.current) { + // Set blog category if it exists in the data + if (data.blogCategory) { + const savedCategory = blogCategories.find(cat => cat.value === data.blogCategory); + if (savedCategory) { + setBlogCategory(savedCategory); + blogCategoryInitialized.current = true; + } + } + // Set blog limit if it exists in the data + if (data.blogLimit) { + setBlogLimit(data.blogLimit); + } + } + }, [formState, data, currentItemsType, blogCategories]); + + // Clear collectionItems when using blog category/limit (server will handle fetching latest blogs) + useEffect(() => { + if (currentItemsType === 'blog') { + if (blogCategory || (blogLimit && blogLimit > 0)) { + // Clear selected collection items since server will fetch latest blogs based on category/limit + setSelectedCollectionItems([]); + setCollectionItemsUpdated(true); + } + } + }, [blogCategory, blogLimit, currentItemsType]); + + // Reset blog category and limit when itemsType changes away from 'blogs' + useEffect(() => { + if (currentItemsType !== 'blog') { + setBlogCategory(null); + setBlogLimit(undefined); + setBlogCategories([]); + blogCategoryInitialized.current = false; + } + }, [currentItemsType]); + + // Reset initialization flag when opening a different widget or changing form state + useEffect(() => { + blogCategoryInitialized.current = false; + }, [data?._id, formState]); + + // Watch blogLimit form value and update state + const watchedBlogLimit = watch('blogLimit'); + useEffect(() => { + if (watchedBlogLimit && currentItemsType === 'blog') { + const limit = parseInt(watchedBlogLimit) || 10; + setBlogLimit(limit); + } + }, [watchedBlogLimit, currentItemsType]); + + // Load pages data when Links widget type is selected + useEffect(() => { + if ( + selectedWidgetType?.value === constants.linksWidgetTypeValue && + selectedCollectionType?.value === constants.pagesItemsTypeValue && + !pagesLoadedRef.current + ) { + // Trigger initial load of pages + pagesLoadedRef.current = true; + getCollectionData(constants.pagesItemsTypeValue, ''); + } + // Reset ref when widget type changes away from Links + if (selectedWidgetType?.value !== constants.linksWidgetTypeValue) { + pagesLoadedRef.current = false; + } + }, [selectedWidgetType, selectedCollectionType, getCollectionData]); + const onChangeSearch = ( str?: string, - callback?: (options: OptionType[]) => void + callback?: (options: OptionType[]) => void, + collectionName?: string ): any => { let collectionItems: any[] = []; let valueToSet = ''; @@ -149,8 +269,8 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { ) { collectionItems = data[constants.tabsAccessor][activeTab] ? data[constants.tabsAccessor][activeTab][ - constants.collectionItemsAccessor - ] + constants.collectionItemsAccessor + ] : []; valueToSet = `${constants.tabsAccessor}.${activeTab}.${constants.tabCollectionItemsAccessor}`; } else if ( @@ -170,18 +290,21 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { if (callerRef.current) clearTimeout(callerRef.current); let item: any; + // Use passed collectionName or fall back to selectedCollectionType + const collectionToUse = collectionName || selectedCollectionType?.value; + callerRef.current = setTimeout(() => { - if (selectedCollectionType) + if (collectionToUse) getCollectionData( - selectedCollectionType.value, + collectionToUse, str, (options) => { if (typeof callback === 'function') callback( options.map((item: ObjectType) => ({ - value: item['_id'] || item['id'], - label: item['name'], ...item, + value: item['_id'] || item['id'], + label: item['name'] || item['title'], })) ); if (formState === 'UPDATE') { @@ -192,10 +315,10 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { ); return item ? { - label: item.name, - value: item._id || item.id, - ...item, - } + ...item, + value: item._id || item.id, + label: item.name || item.title, + } : {}; }) || []; selectedOptions = selectedOptions.filter((obj) => !!obj.value); @@ -232,16 +355,16 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { const derivedItemTypes = widgetType === constants.tabsWidgetTypeValue ? itemsTypes.filter( - (item) => item.label !== constants.imageItemsTypeValue - ) + (item) => item.label !== constants.imageItemsTypeValue + ) : itemsTypes; const firstItemType = derivedItemTypes[0]; - setValue(constants.itemTypeAccessor, firstItemType?.value); + setValue(constants.itemTypeAccessor, firstItemType?.value); return firstItemType; }, [itemsTypes, setValue] ); - + const getFirstWidgetTypeValue = useCallback(() => { return widgetTypes[0].value; @@ -261,17 +384,25 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { widgetType?.value === constants.htmlWidgetTypeValue ) { setItemsEnabled(false); + } else if (widgetType?.value === constants.linksWidgetTypeValue) { + setItemsEnabled(false); + setValue(constants.itemTypeAccessor, constants.pagesItemsTypeValue); + setValue(constants.collectionNameAccessor, constants.pagesItemsTypeValue); + const pagesOption = itemsTypes.find( + (item) => item.value === constants.pagesItemsTypeValue + ); + if (pagesOption) setSelectedCollectionType(pagesOption); } else { setItemsEnabled(true); } - + if ( widgetType?.value === constants.carouselWidgetTypeValue || widgetType?.value === constants.fixedCardWidgetTypeValue ) { setValue(constants.itemTypeAccessor, "Image"); } - + if (widgetType?.value === constants.tabsWidgetTypeValue) { const firstItemType = getFirstItemTypeValue(value[name]); if (firstItemType) { @@ -301,7 +432,7 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { (tabItem) => tabItem[constants.tabCollectionItemsAccessor] ) ); - } + } }, [getFirstItemTypeValue, itemsTypes, setValue, widgetTypes, selectedCollectionType] ); @@ -338,6 +469,25 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { if (!formData[constants.widgetTypeAccessor] && formState === 'ADD') { formData[constants.widgetTypeAccessor] = getFirstWidgetTypeValue(); } + + // Validate blogLimit is required when itemsType is 'blog' + const widgetTypeValue = formData[constants.widgetTypeAccessor] || selectedWidgetType?.value; + if ( + currentItemsType === 'blog' && + !itemsEnabled && + (widgetTypeValue === constants.carouselWidgetTypeValue || + widgetTypeValue === constants.fixedCardWidgetTypeValue) && + !!selectedCollectionType?.value + ) { + if (!formData['blogLimit'] || formData['blogLimit'] === '') { + setError('blogLimit', { + type: 'manual', + message: 'Number of blogs is required', + }); + return; + } + } + // setting tabs data if widgetType tab is selected const tabsData = getValues(constants.tabsAccessor); if (Array.isArray(tabsData) && tabsData.length > 0) { @@ -365,16 +515,21 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { formData[constants.widgetTypeAccessor] as string )?.value; } + // Force collectionName and itemsType for Links widget + if (formData[constants.widgetTypeAccessor] === constants.linksWidgetTypeValue) { + formData[constants.collectionNameAccessor] = constants.pagesItemsTypeValue; + formData[constants.itemTypeAccessor] = constants.pagesItemsTypeValue; + } // setting collectionName if widgetType is FixedCard or Carousel and FormState - if ( + else if ( formData[constants.itemTypeAccessor] !== constants.imageItemsTypeValue && formState === 'ADD' ) { formData[constants.collectionNameAccessor] = selectedCollectionType ? selectedCollectionType.value : getFirstItemTypeValue( - formData[constants.widgetTypeAccessor] as string - )?.value; + formData[constants.widgetTypeAccessor] as string + )?.value; } // setting colleciton items if collectionItems are there if ( @@ -409,10 +564,24 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { } return item; }); - onWidgetFormSubmit({ + // Clean up fields based on widget type + const currentWidgetType = formData['widgetType'] || selectedWidgetType?.value; + if (currentWidgetType !== constants.htmlWidgetTypeValue) { + delete formData['htmlContent']; + } + if (currentWidgetType !== constants.textWidgetTypeValue) { + delete formData['textContent']; + } + + const submitPayload = { ...formData, items, - }); + // Include blog category and limit if set + ...(blogCategory && { blogCategory: blogCategory.value }), + ...(blogLimit && { blogLimit }), + }; + onPrimaryButtonClick?.(undefined, submitPayload); + onWidgetFormSubmit(submitPayload); }; const onCollectionIndexChange = (result: DropResult) => { const { destination, source } = result; @@ -473,36 +642,36 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { }, Array.isArray(languages) && languages.length > 0 ? { - label: commonTranslations.title, - accessor: 'widgetTitles', - required: false, - type: - customInputs && customInputs['widgetTitles'] ? undefined : 'text', - info: widgetTranslations.widgetTitleInfo, - placeholder: commonTranslations.titlePlaceholder, - onInput: handleCapitalize, - Input: - customInputs && customInputs['widgetTitles'] - ? customInputs['widgetTitles'] - : undefined, - } + label: commonTranslations.title, + accessor: 'widgetTitles', + required: false, + type: + customInputs && customInputs['widgetTitles'] ? undefined : 'text', + info: widgetTranslations.widgetTitleInfo, + placeholder: commonTranslations.titlePlaceholder, + onInput: handleCapitalize, + Input: + customInputs && customInputs['widgetTitles'] + ? customInputs['widgetTitles'] + : undefined, + } : { - label: commonTranslations.title, - accessor: 'widgetTitle', - required: true, - type: - customInputs && customInputs['widgetTitle'] ? undefined : 'text', - onInput: handleCapitalize, - placeholder: commonTranslations.titlePlaceholder, - validations: { - required: commonTranslations.titleRequired, - }, - info: widgetTranslations.widgetTitleInfo, - Input: - customInputs && customInputs['widgetTitle'] - ? customInputs['widgetTitle'] - : undefined, + label: commonTranslations.title, + accessor: 'widgetTitle', + required: true, + type: + customInputs && customInputs['widgetTitle'] ? undefined : 'text', + onInput: handleCapitalize, + placeholder: commonTranslations.titlePlaceholder, + validations: { + required: commonTranslations.titleRequired, }, + info: widgetTranslations.widgetTitleInfo, + Input: + customInputs && customInputs['widgetTitle'] + ? customInputs['widgetTitle'] + : undefined, + }, { label: widgetTranslations.widgetType, required: true, @@ -548,6 +717,7 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { required: widgetTranslations.htmlContentRequired, }, show: selectedWidgetType?.value === constants.htmlWidgetTypeValue, + wrapperClassName: 'khb_html-content-field', Input: customInputs && customInputs['htmlContent'] ? customInputs['htmlContent'] @@ -566,23 +736,110 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { required: widgetTranslations.itemsTypePlaceholder, }, options: - selectedWidgetType?.value === constants.tabsWidgetTypeValue || - selectedWidgetType?.collectionsOnly - ? itemsTypes.filter( - (item) => item.label !== constants.imageItemsTypeValue - ) - : selectedWidgetType?.imageOnly + selectedWidgetType?.value === constants.linksWidgetTypeValue ? itemsTypes.filter( - (item) => item.label === constants.imageItemsTypeValue + (item) => item.value === constants.pagesItemsTypeValue + ) + : selectedWidgetType?.value === constants.tabsWidgetTypeValue || + selectedWidgetType?.collectionsOnly + ? itemsTypes.filter( + (item) => + item.label !== constants.imageItemsTypeValue && + item.value !== constants.pagesItemsTypeValue && + item.value !== 'blog' ) - : itemsTypes, + : selectedWidgetType?.imageOnly + ? itemsTypes.filter( + (item) => item.label === constants.imageItemsTypeValue + ) + : itemsTypes.filter( + (item) => item.value !== constants.pagesItemsTypeValue + ), }, { - label: widgetTranslations.color, - accessor: 'backgroundColor', - type: 'color', - className: 'khb_input-color', + label: 'Blog Category', + accessor: 'blogCategory', + type: 'ReactSelect', + selectedOptions: blogCategory ? [blogCategory] : [], + isMulti: false, + isSearchable: true, + isClearable: true, + onChange: (selected: OptionType | OptionType[] | null) => { + setBlogCategory(Array.isArray(selected) ? selected[0] : selected); + }, + loadOptions: (searchStr?: string, callback?: (options: OptionType[]) => void) => { + // Filter categories based on search string + if (!callback) return; + const filtered = searchStr + ? blogCategories.filter(cat => + cat.label.toLowerCase().includes(searchStr.toLowerCase()) + ) + : blogCategories; + callback(filtered); + }, + isLoading: blogCategoriesLoading, + show: + currentItemsType === 'blog' && + !itemsEnabled && + (selectedWidgetType?.value === constants.carouselWidgetTypeValue || + selectedWidgetType?.value === constants.fixedCardWidgetTypeValue || + !selectedWidgetType) && + !!selectedCollectionType?.value, + placeholder: 'Select blog category...', + customStyles: reactSelectStyles || {}, + selectKey: `blog-category-select-${blogCategories.length}`, }, + { + label: 'No. of Blogs', + accessor: 'blogLimit', + type: 'ReactSelect', + selectedOptions: blogLimit ? [{ value: blogLimit.toString(), label: blogLimit.toString() }] : [], + isMulti: false, + isSearchable: false, + required: true, + isClearable: false, + onChange: (selected: OptionType | OptionType[] | null) => { + const selectedValue = Array.isArray(selected) ? selected[0] : selected; + if (selectedValue) { + setBlogLimit(parseInt(selectedValue.value)); + setValue('blogLimit', selectedValue.value); + // Clear any existing error when a value is selected + clearErrors('blogLimit'); + } else { + // Set error if cleared + setError('blogLimit', { + type: 'manual', + message: 'Number of blogs is required', + }); + } + }, + loadOptions: (_searchStr?: string, callback?: (options: OptionType[]) => void) => { + if (!callback) return; + const options = [ + { value: '1', label: '1' }, + { value: '2', label: '2' }, + { value: '3', label: '3' }, + { value: '4', label: '4' }, + { value: '5', label: '5' }, + { value: '6', label: '6' }, + ]; + callback(options); + }, + show: + currentItemsType === 'blog' && + !itemsEnabled && + (selectedWidgetType?.value === constants.carouselWidgetTypeValue || + selectedWidgetType?.value === constants.fixedCardWidgetTypeValue || + !selectedWidgetType) && + !!selectedCollectionType?.value, + placeholder: 'Select number of blogs', + customStyles: reactSelectStyles || {}, + selectKey: `blog-limit-select-${blogLimit}`, + validations: { + required: 'Number of blogs is required', + }, + }, + { label: widgetTranslations.webPerRow, accessor: 'webPerRow', @@ -650,15 +907,25 @@ const WidgetForm = ({ formRef, customInputs }: FormProps) => { onChange: setSelectedCollectionItems, loadOptions: onChangeSearch, isLoading: collectionDataLoading, + disabled: currentItemsType === 'blog' && !!blogCategory, show: !itemsEnabled && + currentItemsType !== 'blog' && (selectedWidgetType?.value === constants.carouselWidgetTypeValue || - selectedWidgetType?.value === constants.fixedCardWidgetTypeValue || !selectedWidgetType) && !!selectedCollectionType?.value, + selectedWidgetType?.value === constants.fixedCardWidgetTypeValue || + selectedWidgetType?.value === constants.linksWidgetTypeValue || + !selectedWidgetType) && !!selectedCollectionType?.value, formatOptionLabel: formatOptionLabel, listCode: selectedCollectionType?.value, customStyles: reactSelectStyles || {}, selectKey: selectedCollectionType?.value, }, + { + label: widgetTranslations.color, + accessor: 'backgroundColor', + type: 'color', + className: 'khb_input-color', + }, ]; if (!canAdd || !canUpdate) return null; diff --git a/libs/admin/src/lib/components/Widget/Widget/Widget.tsx b/libs/admin/src/lib/components/Widget/Widget/Widget.tsx index 9afad61..f85f6da 100644 --- a/libs/admin/src/lib/components/Widget/Widget/Widget.tsx +++ b/libs/admin/src/lib/components/Widget/Widget/Widget.tsx @@ -32,6 +32,7 @@ const Widget = ({ imageMaxSize, translations, children, + onPrimaryButtonClick, }: WidgetProps) => { const { commonTranslations } = useProviderState(); const derivedPermissions = Object.assign(DEFAULT_PERMISSIONS, permissions); @@ -138,9 +139,17 @@ const Widget = ({ ? derivedT.updateWidgetTitle : '' } - footerContent={} + footerContent={ + + } > - + )} {itemData && ( diff --git a/libs/admin/src/lib/components/Widget/WidgetFormActions/WidgetFormActions.tsx b/libs/admin/src/lib/components/Widget/WidgetFormActions/WidgetFormActions.tsx index 6942216..91d4ea6 100644 --- a/libs/admin/src/lib/components/Widget/WidgetFormActions/WidgetFormActions.tsx +++ b/libs/admin/src/lib/components/Widget/WidgetFormActions/WidgetFormActions.tsx @@ -5,12 +5,13 @@ import { CALLBACK_CODES } from '../../../constants/common'; import { useWidgetState } from '../../../context/WidgetContext'; import { useProviderState } from '../../../context/ProviderContext'; -const WidgetFormActions = ({ formRef }: FormActionWrapperProps) => { +const WidgetFormActions = ({ formRef, onPrimaryButtonClick }: FormActionWrapperProps) => { const { onError, commonTranslations } = useProviderState(); const { closeForm, loading, canAdd, canUpdate, formState } = useWidgetState(); const onSubmitClick = ( e?: React.MouseEvent ) => { + onPrimaryButtonClick?.(e); if (!formRef) { return onError( CALLBACK_CODES.INTERNAL, diff --git a/libs/admin/src/lib/components/common/DNDItemsList/DNDItemsList.tsx b/libs/admin/src/lib/components/common/DNDItemsList/DNDItemsList.tsx index 3b7ca73..2cb68e9 100644 --- a/libs/admin/src/lib/components/common/DNDItemsList/DNDItemsList.tsx +++ b/libs/admin/src/lib/components/common/DNDItemsList/DNDItemsList.tsx @@ -1,17 +1,25 @@ import React from 'react'; import { DNDItemsListProps } from '../../../types'; import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'; +import Settings from '../../../icons/settings'; + +const DragDropContextWrapper = + DragDropContext as unknown as React.ComponentType; +const DroppableWrapper: any = Droppable; +const DraggableWrapper: any = Draggable; const DNDItemsList = ({ onDragEnd, items, formatItem, listCode, + onFilterClick, + disableSettings = false, }: DNDItemsListProps) => { return ( - - - {(droppableProvided) => ( + + + {(droppableProvided: any) => (
{items ? items.map((item, index) => ( - - {(provided) => ( -
- {typeof formatItem === 'function' && listCode ? ( - formatItem(listCode, item) - ) : ( + + {(provided: any) => ( +
+ {typeof formatItem === 'function' && listCode && listCode !== 'pages' && listCode !== 'blog' ? ( + formatItem(listCode, item) + ) : ( +

{item.label}

- )} -
- )} - - )) + {((item as { code?: string }).code === + 'BROWSE_JOBS' || + item.value === 'BROWSE_JOBS') && ( + + )} +
+ )} +
+ )} + + )) : null} {droppableProvided.placeholder}
)} -
-
+ + ); }; diff --git a/libs/admin/src/lib/components/common/Form/SimpleForm.tsx b/libs/admin/src/lib/components/common/Form/SimpleForm.tsx index 2355f81..d6a15d5 100644 --- a/libs/admin/src/lib/components/common/Form/SimpleForm.tsx +++ b/libs/admin/src/lib/components/common/Form/SimpleForm.tsx @@ -50,33 +50,48 @@ const SimpleForm = forwardRef( switch (schema.type) { case 'ReactSelect': input = ( - { - if (value) { - setValue( - schema.accessor, - Array.isArray(value) - ? value.map((item) => item.value) - : value.value - ); - if (schema.onChange) schema.onChange(value); - } - }} - selectedOptions={schema.selectedOptions} - required={schema.required} - isMulti={schema.isMulti} - isSearchable={schema.isSearchable} - isLoading={schema.isLoading} - placeholder={schema.placeholder} - wrapperClassName={schema.wrapperClassName} - formatOptionLabel={schema.formatOptionLabel} - listCode={schema.listCode} - customStyles={schema.customStyles} - loadOptions={schema.loadOptions} - selectKey={schema.selectKey} + ( + { + // Handle clear (null value) and selection + const fieldValue = value + ? Array.isArray(value) + ? value.map((item) => item.value) + : value.value + : null; + + field.onChange(fieldValue); + setValue(schema.accessor, fieldValue, { shouldValidate: true }); + if (schema.onChange) schema.onChange(value); + }} + selectedOptions={schema.selectedOptions} + required={schema.required} + isMulti={schema.isMulti} + isSearchable={schema.isSearchable} + isClearable={schema.isClearable} + isLoading={schema.isLoading} + placeholder={schema.placeholder} + wrapperClassName={schema.wrapperClassName} + formatOptionLabel={schema.formatOptionLabel} + listCode={schema.listCode} + customStyles={schema.customStyles} + loadOptions={schema.loadOptions} + selectKey={schema.selectKey} + /> + )} /> ); break; diff --git a/libs/admin/src/lib/components/common/Input/Input.tsx b/libs/admin/src/lib/components/common/Input/Input.tsx index 682e43c..ceec250 100644 --- a/libs/admin/src/lib/components/common/Input/Input.tsx +++ b/libs/admin/src/lib/components/common/Input/Input.tsx @@ -20,6 +20,8 @@ const Input = ({ onChange, wrapperClassName, }: InputProps) => { + const isTextarea = type === 'textarea'; + return (
{label && ( @@ -30,18 +32,33 @@ const Input = ({ ) : null} )} - + {isTextarea ? ( +