diff --git a/admin/src/api.ts b/admin/src/api.ts index 995f897..be2689a 100644 --- a/admin/src/api.ts +++ b/admin/src/api.ts @@ -2,18 +2,21 @@ import axios from 'axios'; import { PLUGIN_ID } from './pluginId'; const api = { - getCollections: async () => { - return await axios.get(`/${PLUGIN_ID}/collections`); - }, - getExtensions: async () => { - return await axios.get(`/${PLUGIN_ID}/extensions`); - }, - getSettings: async () => { - return await axios.get(`/${PLUGIN_ID}/settings`); - }, - setSettings: async (data: any) => { - return axios.post(`/${PLUGIN_ID}/settings`, data); - }, + getCollections: async () => { + return await axios.get(`/${PLUGIN_ID}/collections`); + }, + getExtensions: async () => { + return await axios.get(`/${PLUGIN_ID}/extensions`); + }, + getSettings: async () => { + return await axios.get(`/${PLUGIN_ID}/settings`); + }, + getCollectionFilters: async (contentType: string) => { + return await axios.get(`/${PLUGIN_ID}/collection-filters?contentType=${contentType}`); + }, + setSettings: async (data: any) => { + return axios.post(`/${PLUGIN_ID}/settings`, data); + }, }; export default api; diff --git a/admin/src/components/Initializer.ts b/admin/src/components/Initializer.ts new file mode 100644 index 0000000..6b9b2f4 --- /dev/null +++ b/admin/src/components/Initializer.ts @@ -0,0 +1,17 @@ +import { useEffect, useRef } from 'react'; +import { PLUGIN_ID } from '../pluginId'; + +interface InitializerProps { + setPlugin?: (pluginId: string) => void; +} + +const Initializer = ({ setPlugin }: InitializerProps): null => { + const ref = useRef(setPlugin); + useEffect(() => { + if (setPlugin) { + ref.current(PLUGIN_ID); + } + }, []); + return null; +}; +export { Initializer }; diff --git a/admin/src/components/Settings/GeneralSettings.tsx b/admin/src/components/Settings/GeneralSettings.tsx index a7080fd..de1c389 100644 --- a/admin/src/components/Settings/GeneralSettings.tsx +++ b/admin/src/components/Settings/GeneralSettings.tsx @@ -1,277 +1,427 @@ import React, { useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; -import { Grid, Typography } from '@strapi/design-system'; -import { Field, SingleSelect, SingleSelectOption, Toggle } from '@strapi/design-system'; -import { Struct } from '@strapi/types'; +import { + Grid, + Typography, + Field, + SingleSelect, + SingleSelectOption, + Toggle, + TextInput, + Button, +} from '@strapi/design-system'; +import { Struct } from '@strapi/strapi'; import api from '../../api'; import { getTranslation } from '../../utils/getTranslation'; import { useSettings } from '../../context/Settings'; -import { ExtensionType } from '../../../../types'; - +import { CollectionFilter, ExtensionType, SettingsType } from '../../../../types'; const GeneralSettings = () => { - const { formatMessage } = useIntl(); - const { updateField, settings } = useSettings(); + const { formatMessage } = useIntl(); + const { updateField, settings } = useSettings(); + const [collections, setCollections] = useState>([]); + const [extensions, setExtensions] = useState>([]); + const [fields, setFields] = useState>([]); + const [filters, setFilters] = useState([]); + const [collectionFields, setCollectionFields] = useState([]); + + useEffect(() => { + const fetchData = async () => { + try { + const [collectionsRes, extensionsRes] = await Promise.all([ + api.getCollections(), + api.getExtensions(), + ]); + setCollections(collectionsRes.data); + setExtensions(extensionsRes.data); + } catch (error) { + console.error('Error fetching collections/extensions:', error); + } + }; + + fetchData(); + }, []); + + useEffect(() => { + if (!settings.collection || !collections.length) return; + + const collection = collections.find((x) => x.uid === settings.collection); + if (!collection) return; + + const baseFields = Object.entries(collection.attributes).map(([id, attr]) => ({ + id, + type: attr.type, + })); + + const extensionFields = extensions.reduce((acc, el) => { + // @ts-ignore + acc.push(...el.startFields, ...el.endFields); + return acc; + }, []); + + setFields([...baseFields, ...extensionFields]); + + api.getCollectionFilters(settings.collection).then((response) => { + setCollectionFields(response.data || []); + }); + }, [settings.collection, collections, extensions]); + + useEffect(() => { + setFilters(settings.collectionFilters || []); + }, [settings.collectionFilters]); - const [collections, setCollections] = useState>([]); - const [extensions, setExtensions] = useState>([]); - const [fields, setFields] = useState>([]); + const addFilter = () => { + setFilters((prev) => { + const updated = [...prev, { field: '', operator: 'eq', value: '' }]; + updateField({ collectionFilters: updated }); + return updated; + }); + }; - useEffect(() => { - const fetchData = async () => { - try { - const [collectionsRes, extensionsRes] = await Promise.all([ - api.getCollections(), - api.getExtensions(), - ]); + const updateFilter = (index: number, key: string, value: string) => { + const newFilters = [...filters]; + newFilters[index] = { ...newFilters[index], [key]: value }; + setFilters(newFilters); + updateField({ collectionFilters: newFilters.length ? newFilters : [] }); + }; - setCollections(collectionsRes.data); - setExtensions(extensionsRes.data); - } catch (error) { - console.error('Error fetching data:', error); - } - }; + const removeFilter = (index: number) => { + const newFilters = filters.filter((_, i) => i !== index); + setFilters(newFilters); + updateField({ collectionFilters: newFilters.length ? newFilters : [] }); + }; - fetchData().catch(); - }, []); + return ( + + + + {formatMessage({ + id: getTranslation('view.settings.section.general.title'), + defaultMessage: 'General settings!!', + })} + + + + + + {formatMessage({ + id: getTranslation('view.settings.section.general.collection.label'), + defaultMessage: 'Choose your collection', + })} + + updateField({ collection: e })} + value={settings.collection} + onCloseAutoFocus={(e) => e.preventDefault()} + > + {collections.map((x) => ( + + {x.collectionName} + + ))} + + + + + + + {formatMessage({ + id: getTranslation('view.settings.section.general.title.label'), + defaultMessage: 'Choose your title field', + })} + + updateField({ titleField: e })} + value={settings.titleField} + onCloseAutoFocus={(e) => e.preventDefault()} + > + + {formatMessage({ + id: getTranslation('view.settings.section.general.title.none'), + defaultMessage: 'No title field', + })} + + {fields + .filter((x) => x.type === 'string') + .map((x) => ( + + {x.id} + + ))} + + + - useEffect(() => { - if (settings.collection && collections.length) { - const collection = collections.find((x) => x.uid === settings.collection); - if (!collection) return; + + + + {formatMessage({ + id: getTranslation('view.settings.section.general.start.label'), + defaultMessage: 'Choose your start field', + })} + + updateField({ startField: e })} + value={settings.startField} + > + + {formatMessage({ + id: getTranslation('view.settings.section.general.start.none'), + defaultMessage: 'No start field', + })} + + {fields + .filter((x) => x.type === 'datetime') + .map((x) => ( + + {x.id} + + ))} + + + - const fields = Object.entries(collection.attributes) - .map((x) => ({ - id: x[0], - type: x[1].type, - })) - .concat( - extensions.reduce((acc, el) => { - // TODO: Fix TS error - // @ts-ignore - acc.push(...el.startFields, ...el.endFields); - return acc; - }, []) - ); + + + + {formatMessage({ + id: getTranslation('view.settings.section.general.end.label'), + defaultMessage: 'Choose your end field', + })} + + updateField({ endField: e })} + value={settings.endField} + > + + {formatMessage({ + id: getTranslation('view.settings.section.general.end.none'), + defaultMessage: 'No end field', + })} + + {fields + .filter((x) => x.type === 'datetime') + .map((x) => ( + + {x.id} + + ))} + + + - setFields(fields); - } - }, [settings, collections, extensions]); + + + + {formatMessage({ + id: getTranslation('view.settings.section.general.duration.label'), + defaultMessage: 'Choose your default event duration', + })} + + updateField({ defaultDuration: Number(e) })} + value={settings.defaultDuration} + onCloseAutoFocus={(e) => e.preventDefault()} // Add this + > + + {formatMessage({ + id: getTranslation('view.settings.section.general.duration.30m'), + defaultMessage: '30 Minutes', + })} + + + {formatMessage({ + id: getTranslation('view.settings.section.general.duration.1h'), + defaultMessage: '1 Hour', + })} + + + {formatMessage({ + id: getTranslation('view.settings.section.general.duration.1.5h'), + defaultMessage: '1.5 Hours', + })} + + + {formatMessage({ + id: getTranslation('view.settings.section.general.duration.2h'), + defaultMessage: '2 Hours', + })} + + + + - return ( - - - - {formatMessage({ - id: getTranslation('view.settings.section.general.title'), - defaultMessage: 'General settings', - })} - - - - - - {formatMessage({ - id: getTranslation('view.settings.section.general.collection.label'), - defaultMessage: 'Choose your collection', - })} - - updateField({ collection: e })} - value={settings.collection} - > - {collections.map((x) => ( - - {x.collectionName} - - ))} - - - - - - - {formatMessage({ - id: getTranslation('view.settings.section.general.title.label'), - defaultMessage: 'Choose your title field', - })} - - updateField({ titleField: e })} - value={settings.titleField} - > - - {formatMessage({ - id: getTranslation('view.settings.section.general.title.none'), - defaultMessage: 'No title field', - })} - - {fields - .filter((x) => x.type === 'string') - .map((x) => ( - - {x.id} - - ))} - - - + + + + {formatMessage({ + id: getTranslation('view.settings.section.general.color.label'), + defaultMessage: 'Choose your color field', + })} + + updateField({ colorField: e })} + value={settings.colorField} + onCloseAutoFocus={(e) => e.preventDefault()} // Add this + > + + {formatMessage({ + id: getTranslation('view.settings.section.general.color.none'), + defaultMessage: 'No color field', + })} + + {fields + .filter((x) => x.type === 'string') + .map((x) => ( + + {x.id} + + ))} + + + - - - - {formatMessage({ - id: getTranslation('view.settings.section.general.start.label'), - defaultMessage: 'Choose your start field', - })} - - updateField({ startField: e })} - value={settings.startField} - > - - {formatMessage({ - id: getTranslation('view.settings.section.general.start.none'), - defaultMessage: 'No start field', - })} - - {fields - .filter((x) => x.type === 'datetime') - .map((x) => ( - - {x.id} - - ))} - - - + + + + {formatMessage({ + id: getTranslation('view.settings.section.general.drafts.label'), + defaultMessage: 'Display drafts', + })} + + { + updateField({ + drafts: e.target.checked, + }); + }} + /> + + + + + {formatMessage({ + id: 'view.settings.section.general.filters-title', + defaultMessage: 'Collection Filters', + })} + + + {filters.map((filter, index) => ( + + + + + {formatMessage({ + id: 'view.settings.section.general.filters-field', + defaultMessage: 'Field', + })} + + updateFilter(index, 'field', e)} + onCloseAutoFocus={(e) => e.preventDefault()} + > + + {formatMessage({ + id: 'view.settings.section.general.filters-field-placeholder', + defaultMessage: 'Select field', + })} + + {collectionFields.map((field) => ( + + {field.name} + + ))} + + + - - - - {formatMessage({ - id: getTranslation('view.settings.section.general.end.label'), - defaultMessage: 'Choose your end field', - })} - - updateField({ endField: e })} - value={settings.endField} - > - - {formatMessage({ - id: getTranslation('view.settings.section.general.end.none'), - defaultMessage: 'No end field', - })} - - {fields - .filter((x) => x.type === 'datetime') - .map((x) => ( - - {x.id} - - ))} - - - + + + + {formatMessage({ + id: 'view.settings.section.general.filters-operator', + defaultMessage: 'Operator', + })} + + updateFilter(index, 'operator', e)} + disabled={!filter.field} + onCloseAutoFocus={(e) => e.preventDefault()} + > + {filter.field && + collectionFields + .find((f) => f.name === filter.field) + ?.filterOperators.map((op) => ( + + {formatMessage({ + id: `operator.${op}`, + defaultMessage: op, + })} + + ))} + + + - - - - {formatMessage({ - id: getTranslation('view.settings.section.general.duration.label'), - defaultMessage: 'Choose your default event duration', - })} - - updateField({ defaultDuration: Number(e) })} - value={settings.defaultDuration} - > - - {formatMessage({ - id: getTranslation('view.settings.section.general.duration.30m'), - defaultMessage: '30 Minutes', - })} - - - {formatMessage({ - id: getTranslation('view.settings.section.general.duration.1h'), - defaultMessage: '1 Hour', - })} - - - {formatMessage({ - id: getTranslation('view.settings.section.general.duration.1.5h'), - defaultMessage: '1.5 Hours', - })} - - - {formatMessage({ - id: getTranslation('view.settings.section.general.duration.2h'), - defaultMessage: '2 Hours', - })} - - - - + + + + {formatMessage({ + id: 'view.settings.section.general.filters-value', + defaultMessage: 'Value', + })} + + updateFilter(index, 'value', e.target.value)} + placeholder={formatMessage({ + id: 'view.settings.section.general.filters-value-placeholder', + defaultMessage: 'Enter value', + })} + /> + + - - - - {formatMessage({ - id: getTranslation('view.settings.section.general.color.label'), - defaultMessage: 'Choose your color field', - })} - - updateField({ colorField: e })} - value={settings.colorField} - > - - {formatMessage({ - id: getTranslation('view.settings.section.general.color.none'), - defaultMessage: 'No color field', - })} - - {fields - .filter((x) => x.type === 'string') - .map((x) => ( - - {x.id} - - ))} - - - + + + + + ))} - - - - {formatMessage({ - id: getTranslation('view.settings.section.general.drafts.label'), - defaultMessage: 'Display drafts', - })} - - { - updateField({ - drafts: e.target.checked, - }); - }} - /> - - - - ); + + + + + ); }; export default GeneralSettings; diff --git a/admin/src/index.ts b/admin/src/index.ts index 8d40d77..e394bf9 100644 --- a/admin/src/index.ts +++ b/admin/src/index.ts @@ -1,84 +1,92 @@ +// @ts-ignore + import { PLUGIN_ID } from './pluginId'; import { Initializer } from './components/Initializer'; import { PluginIcon } from './components/PluginIcon'; -export default { - register(app: any) { - app.addMenuLink({ - to: `plugins/${PLUGIN_ID}`, - icon: PluginIcon, - intlLabel: { - id: `${PLUGIN_ID}.plugin.name`, - defaultMessage: PLUGIN_ID, - }, - Component: () => { - return import('./pages/App'); - }, - }); +interface App { + addMenuLink(options: any): void; + createSettingSection(section: any, links: any[]): void; + registerPlugin(plugin: { + id: string; + initializer: React.ComponentType; + isReady: boolean; + name: string; + }): void; + locales?: string[]; +} - app.createSettingSection( - { - id: PLUGIN_ID, - intlLabel: { - id: `${PLUGIN_ID}.plugin.name`, - defaultMessage: `${PLUGIN_ID} Settings`, - }, - }, - [ - { - intlLabel: { - id: `${PLUGIN_ID}.plugin.name`, - defaultMessage: 'Settings', - }, - id: 'settings', - to: `${PLUGIN_ID}`, - Component: () => { - return import('./pages/SettingsPage'); - }, - }, - ] - ); +export default { + register(app: App) { + app.addMenuLink({ + to: `plugins/${PLUGIN_ID}`, + icon: PluginIcon, + intlLabel: { + id: `${PLUGIN_ID}.plugin.name`, + defaultMessage: PLUGIN_ID, + }, + Component: () => import('./pages/App'), + }); - app.registerPlugin({ - id: PLUGIN_ID, - initializer: Initializer, - isReady: false, - name: PLUGIN_ID, - }); - }, + app.createSettingSection( + { + id: PLUGIN_ID, + intlLabel: { + id: `${PLUGIN_ID}.plugin.name`, + defaultMessage: `${PLUGIN_ID} Settings`, + }, + }, + [ + { + intlLabel: { + id: `${PLUGIN_ID}.plugin.name`, + defaultMessage: 'Settings', + }, + id: 'settings', + to: `${PLUGIN_ID}`, + Component: () => import('./pages/SettingsPage'), + }, + ] + ); + app.registerPlugin({ + id: PLUGIN_ID, + initializer: Initializer, + isReady: false, + name: PLUGIN_ID, + }); + }, - async registerTrads(app: any) { - const { locales } = app; + async registerTrads(app: App) { + const { locales } = app; - const importedTranslations = await Promise.all( - (locales as string[]).map((locale) => { - return import(`./translations/${locale}.json`) - .then(({ default: data }) => { - return { - data: prefixPluginTranslations(data, PLUGIN_ID), - locale, - }; - }) - .catch(() => { - return { - data: {}, - locale, - }; - }); - }) - ); + const importedTranslations = await Promise.all( + (locales ?? []).map(async (locale) => { + try { + const { default: data } = await import(`./translations/${locale}.json`); + return { + data: prefixPluginTranslations(data, PLUGIN_ID), + locale, + }; + } catch { + return { + data: {}, + locale, + }; + } + }) + ); - return importedTranslations; - }, + return importedTranslations; + }, }; type TradOptions = Record; const prefixPluginTranslations = (trad: TradOptions, pluginId: string): TradOptions => { - if (!pluginId) { - throw new TypeError("pluginId can't be empty"); - } - return Object.keys(trad).reduce((acc, current) => { - acc[`${pluginId}.${current}`] = trad[current]; - return acc; - }, {} as TradOptions); + if (!pluginId) { + throw new TypeError("pluginId can't be empty"); + } + return Object.keys(trad).reduce((acc, current) => { + acc[`${pluginId}.${current}`] = trad[current]; + return acc; + }, {} as TradOptions); }; diff --git a/admin/src/pages/App.tsx b/admin/src/pages/App.tsx index dc81889..feb84b2 100644 --- a/admin/src/pages/App.tsx +++ b/admin/src/pages/App.tsx @@ -1,3 +1,4 @@ +// @ts-ignore-next-line import { Page } from '@strapi/strapi/admin'; import { Routes, Route } from 'react-router-dom'; @@ -5,14 +6,14 @@ import { CalendarPage } from './CalendarPage'; import { SettingsProvider } from '../context/Settings'; const App = () => { - return ( - - - } /> - } /> - - - ); + return ( + + + } /> + } /> + + + ); }; export default App; diff --git a/admin/src/pages/CalendarPage.tsx b/admin/src/pages/CalendarPage.tsx index 67ac626..33ed667 100644 --- a/admin/src/pages/CalendarPage.tsx +++ b/admin/src/pages/CalendarPage.tsx @@ -1,4 +1,5 @@ import React from 'react'; +// @ts-ignore-next-line import { Layouts } from '@strapi/admin/strapi-admin'; import { Cog, Plus } from '@strapi/icons'; import tinyColor from 'tinycolor2'; @@ -17,92 +18,95 @@ import Illo from '../components/Calendar/Illo'; import { useSettings } from '../context/Settings'; const CalendarPage = () => { - const theme = useTheme(); - - const { settings, loading } = useSettings(); - const { formatMessage } = useIntl(); - - if (loading) return ; - if (!settings.collection) { - return ( - <> - - - } - content={formatMessage({ - id: getTranslation('view.calendar.state.empty.configure-settings.message'), - defaultMessage: 'Please configure the settings before accessing the calendar', - })} - action={ - } - > - {formatMessage({ - id: getTranslation('view.calendar.state.empty.configure-settings.action'), - defaultMessage: 'Settings', - })} - - } - /> - - - ); - } - - const { monthView, weekView, workWeekView, dayView, defaultView, todayButton } = settings; - - // Define the views to be displayed - let views = ''; - if (monthView) views += 'dayGridMonth,'; - if (weekView) views += 'timeGridWeek,'; - if (workWeekView) views += 'workWeek,'; - if (dayView) views += 'dayView,'; - views = views.slice(0, -1); - - // Define the buttons to be displayed - let left = 'prev,next' + (todayButton ? ' today' : ''); - - // Define initial view - const initialView = - defaultView === 'Month' - ? 'dayGridMonth' - : defaultView === 'Week' - ? 'timeGridWeek' - : defaultView === 'Work-Week' - ? 'workWeek' - : defaultView === 'Day' - ? 'dayView' - : 'dayGridMonth'; - - const primaryAction = settings.createButton ? ( - } - href={`/admin/content-manager/collection-types/${settings.collection}/create`} - > - {formatMessage( - { id: getTranslation('view.calendar.action.create-entry'), defaultMessage: 'Create New' }, - { collection: settings.collection?.split('.')[1] } - )} - - ) : ( -
- ); - - // Override Styles - const primaryColor = settings.primaryColor; - const lightPrimaryColor = tinyColor(primaryColor).lighten().toString(); - - const sty = ` + const theme = useTheme(); + + const { settings, loading } = useSettings(); + const { formatMessage } = useIntl(); + + if (loading) return ; + if (!settings.collection) { + return ( + <> + + + } + content={formatMessage({ + id: getTranslation('view.calendar.state.empty.configure-settings.message'), + defaultMessage: 'Please configure the settings before accessing the calendar', + })} + action={ + // @ts-ignore-next-line + } + > + {formatMessage({ + id: getTranslation('view.calendar.state.empty.configure-settings.action'), + defaultMessage: 'Settings', + })} + + } + /> + + + ); + } + + const { monthView, weekView, workWeekView, dayView, defaultView, todayButton } = settings; + + // Define the views to be displayed + let views = ''; + if (monthView) views += 'dayGridMonth,'; + if (weekView) views += 'timeGridWeek,'; + if (workWeekView) views += 'workWeek,'; + if (dayView) views += 'dayView,'; + views = views.slice(0, -1); + + // Define the buttons to be displayed + let left = 'prev,next' + (todayButton ? ' today' : ''); + + // Define initial view + const initialView = + defaultView === 'Month' + ? 'dayGridMonth' + : defaultView === 'Week' + ? 'timeGridWeek' + : defaultView === 'Work-Week' + ? 'workWeek' + : defaultView === 'Day' + ? 'dayView' + : 'dayGridMonth'; + + const primaryAction = settings.createButton ? ( + // @ts-ignore-next-line + } + href={`/admin/content-manager/collection-types/${settings.collection}/create`} + > + {formatMessage( + { id: getTranslation('view.calendar.action.create-entry'), defaultMessage: 'Create New' }, + { collection: settings.collection?.split('.')[1] } + )} + + ) : ( +
+ ); + + // Override Styles + const primaryColor = settings.primaryColor; + const lightPrimaryColor = tinyColor(primaryColor).lighten().toString(); + + const sty = ` :root { --fc-page-bg-color: transparent; --fc-button-bg-color: ${primaryColor}; @@ -148,70 +152,70 @@ const CalendarPage = () => { } `; - return ( - <> - - - - - - - - - ); + return ( + <> + + + + + + + + + ); }; export { CalendarPage }; diff --git a/admin/src/pages/SettingsPage.tsx b/admin/src/pages/SettingsPage.tsx index fd03d52..fe55c41 100644 --- a/admin/src/pages/SettingsPage.tsx +++ b/admin/src/pages/SettingsPage.tsx @@ -1,5 +1,7 @@ import React from 'react'; +// @ts-ignore-next-line import { Layouts } from '@strapi/admin/strapi-admin'; +// @ts-ignore-next-line import { useNotification } from '@strapi/strapi/admin'; import { Box, Button, Divider, Loader } from '@strapi/design-system'; import { useIntl } from 'react-intl'; @@ -12,126 +14,127 @@ import { SettingsProvider, useSettings } from '../context/Settings'; import { getTranslation } from '../utils/getTranslation'; const SettingsPage = () => { - const { loading, saving, saveSettings, settings } = useSettings(); - const { toggleNotification } = useNotification(); - const { formatMessage } = useIntl(); + const { loading, saving, saveSettings, settings } = useSettings(); + const { toggleNotification } = useNotification(); + const { formatMessage } = useIntl(); - const submit = async () => { - // Verify critical settings - if (settings.defaultView === 'Month' && !settings.monthView) { - return toggleNotification({ - type: 'warning', - message: formatMessage({ - id: getTranslation('warning.missing.month-view'), - defaultMessage: 'Month view must be enabled', - }), - }); - } - if (settings.defaultView === 'Week' && !settings.weekView) { - return toggleNotification({ - type: 'warning', - message: formatMessage({ - id: getTranslation('warning.missing.week-view'), - defaultMessage: 'Week view must be enabled', - }), - }); - } - if (settings.defaultView === 'Work-Week' && !settings.workWeekView) { - return toggleNotification({ - type: 'warning', - message: formatMessage({ - id: getTranslation('warning.missing.work-week-view'), - defaultMessage: 'Work Week view must be enabled', - }), - }); - } - if (settings.defaultView === 'Day' && !settings.dayView) { - return toggleNotification({ - type: 'warning', - message: formatMessage({ - id: getTranslation('warning.missing.day-view'), - defaultMessage: 'Day view must be enabled', - }), - }); - } - if (!settings.monthView && !settings.weekView && !settings.workWeekView && !settings.dayView) { - return toggleNotification({ - type: 'warning', - message: formatMessage({ - id: getTranslation('warning.missing.view'), - defaultMessage: 'At least one view must be enabled', - }), - }); - } + const submit = async () => { + // Verify critical settings + if (settings.defaultView === 'Month' && !settings.monthView) { + return toggleNotification({ + type: 'warning', + message: formatMessage({ + id: getTranslation('warning.missing.month-view'), + defaultMessage: 'Month view must be enabled', + }), + }); + } + if (settings.defaultView === 'Week' && !settings.weekView) { + return toggleNotification({ + type: 'warning', + message: formatMessage({ + id: getTranslation('warning.missing.week-view'), + defaultMessage: 'Week view must be enabled', + }), + }); + } + if (settings.defaultView === 'Work-Week' && !settings.workWeekView) { + return toggleNotification({ + type: 'warning', + message: formatMessage({ + id: getTranslation('warning.missing.work-week-view'), + defaultMessage: 'Work Week view must be enabled', + }), + }); + } + if (settings.defaultView === 'Day' && !settings.dayView) { + return toggleNotification({ + type: 'warning', + message: formatMessage({ + id: getTranslation('warning.missing.day-view'), + defaultMessage: 'Day view must be enabled', + }), + }); + } + if (!settings.monthView && !settings.weekView && !settings.workWeekView && !settings.dayView) { + return toggleNotification({ + type: 'warning', + message: formatMessage({ + id: getTranslation('warning.missing.view'), + defaultMessage: 'At least one view must be enabled', + }), + }); + } - saveSettings().then(() => { - return toggleNotification({ - type: 'success', - message: formatMessage({ - id: getTranslation('success.update'), - defaultMessage: 'Settings successfully updated', - }), - }); - }); - }; + saveSettings().then(() => { + return toggleNotification({ + type: 'success', + message: formatMessage({ + id: getTranslation('success.update'), + defaultMessage: 'Settings successfully updated', + }), + }); + }); + }; - return ( - <> - submit()} - startIcon={} - disabled={saving} - loading={saving} - > - {formatMessage({ - id: getTranslation('view.settings.action.save'), - defaultMessage: 'Save', - })} - - ) - } - /> + return ( + <> + submit()} + startIcon={} + disabled={saving} + loading={saving} + > + {formatMessage({ + id: getTranslation('view.settings.action.save'), + defaultMessage: 'Save', + })} + + ) + } + /> - {loading ? ( - - ) : ( - - - - - - - - )} - - ); + {loading ? ( + + ) : ( + + + + + + + + )} + + ); }; const SettingsApp = () => { - return ( - - - - ); + return ( + + + + ); }; export default SettingsApp; diff --git a/admin/src/theme.d.ts b/admin/src/theme.d.ts new file mode 100644 index 0000000..b31466a --- /dev/null +++ b/admin/src/theme.d.ts @@ -0,0 +1,13 @@ +import 'styled-components'; + +declare module 'styled-components' { + export interface DefaultTheme { + colors: { + neutral1000: string; + primary600?: string; + }; + spaces?: { + [key: string]: string; + }; + } +} diff --git a/admin/src/translations/de.json b/admin/src/translations/de.json index 11fa4aa..3404d14 100644 --- a/admin/src/translations/de.json +++ b/admin/src/translations/de.json @@ -34,6 +34,12 @@ "view.settings.section.general.display-drafts.label": "Entwürfe anzeigen", "view.settings.section.general.display-drafts.off": "Aus", "view.settings.section.general.display-drafts.on": "An", + "view.settings.section.general.filters-title": "Sammlungsfilter", + "view.settings.section.general.filters-field": "Feld", + "view.settings.section.general.filters-operator": "Operator", + "view.settings.section.general.filters-value": "Wert", + "view.settings.section.general.filters-add": "Filter hinzufügen", + "view.settings.section.general.filters-remove": "Entfernen", "view.settings.section.calendar.title": "Kalender-Einstellungen", "view.settings.section.calendar.primary-color.title": "Haupt-Farbe", "view.settings.section.calendar.event-color.title": "Event-Farbe", diff --git a/admin/src/translations/en.json b/admin/src/translations/en.json index 9859dd7..d84a828 100644 --- a/admin/src/translations/en.json +++ b/admin/src/translations/en.json @@ -34,6 +34,12 @@ "view.settings.section.general.display-drafts.label": "Display Drafts", "view.settings.section.general.display-drafts.off": "Disabled", "view.settings.section.general.display-drafts.on": "Enabled", + "view.settings.section.general.filters-title": "Collection Filters", + "view.settings.section.general.filters-field": "Field", + "view.settings.section.general.filters-operator": "Operator", + "view.settings.section.general.filters-value": "Value", + "view.settings.section.general.filters-add": "Add Filter", + "view.settings.section.general.filters-remove": "Remove", "view.settings.section.calendar.title": "Calendar settings", "view.settings.section.calendar.primary-color.title": "Primary Color", "view.settings.section.calendar.event-color.title": "Event Color", diff --git a/admin/src/translations/es.json b/admin/src/translations/es.json index aee0d66..cbe3bff 100644 --- a/admin/src/translations/es.json +++ b/admin/src/translations/es.json @@ -34,6 +34,12 @@ "view.settings.section.general.display-drafts.label": "Mostrar borradores", "view.settings.section.general.display-drafts.off": "Desactivado", "view.settings.section.general.display-drafts.on": "Activado", + "view.settings.section.general.filters-title": "Filtros de colección", + "view.settings.section.general.filters-field": "Campo", + "view.settings.section.general.filters-operator": "Operador", + "view.settings.section.general.filters-value": "Valor", + "view.settings.section.general.filters-add": "Agregar filtro", + "view.settings.section.general.filters-remove": "Eliminar", "view.settings.section.calendar.title": "Configuración de calendario", "view.settings.section.calendar.primary-color.title": "Color principal", "view.settings.section.calendar.event-color.title": "Color del evento", diff --git a/admin/src/utils/defaultSettings.ts b/admin/src/utils/defaultSettings.ts index 245a27a..24f739b 100644 --- a/admin/src/utils/defaultSettings.ts +++ b/admin/src/utils/defaultSettings.ts @@ -1,24 +1,31 @@ import { SettingsType } from '../../../types'; const defaultSettings: SettingsType = { - collection: null, - startField: null, - endField: null, - titleField: null, - colorField: null, - defaultDuration: 30, - drafts: true, - startHour: '9:00', - endHour: '18:00', - defaultView: 'Month', - monthView: true, - weekView: true, - workWeekView: true, - dayView: true, - todayButton: true, - createButton: true, - primaryColor: '#4945ff', - eventColor: '#4945ff', + collection: null, + startField: null, + endField: null, + titleField: null, + colorField: null, + defaultDuration: 30, + drafts: true, + startHour: '9:00', + endHour: '18:00', + defaultView: 'Month', + monthView: true, + weekView: true, + workWeekView: true, + dayView: true, + todayButton: true, + createButton: true, + primaryColor: '#4945ff', + eventColor: '#4945ff', + collectionFilters: [ + { + field: null, + operator: null, + value: null, + }, + ], }; export default defaultSettings; diff --git a/admin/tsconfig.json b/admin/tsconfig.json index 102f3ca..ea9f171 100644 --- a/admin/tsconfig.json +++ b/admin/tsconfig.json @@ -1,8 +1,11 @@ { - "extends": "@strapi/typescript-utils/tsconfigs/admin", - "include": ["./src", "./custom.d.ts"], - "compilerOptions": { - "rootDir": "../", - "baseUrl": "." - } + "extends": "../tsconfig.json", + "include": ["./src/**/*"], + "compilerOptions": { + "rootDir": "../", + "baseUrl": ".", + "strictNullChecks": false, + "types": ["node", "styled-components"], + "typeRoots": ["../node_modules/@types", "./src"] + } } diff --git a/package.json b/package.json index 81ed1dc..6b07c8a 100644 --- a/package.json +++ b/package.json @@ -1,89 +1,104 @@ { - "name": "@offset-dev/strapi-calendar", - "version": "1.0.0", - "description": "Visualize your Strapi content in month, week or daily view", - "keywords": [], - "homepage": "https://github.com/offset-dev/strapi-calendar#readme", - "bugs": { - "url": "https://github.com/offset-dev/strapi-calendar/issues" - }, - "repository": { - "type": "git", - "url": "git+ssh://git@github.com/offset-dev/strapi-calendar.git" - }, - "license": "MIT", - "author": "Luis Rodriguez", - "type": "commonjs", - "exports": { - "./package.json": "./package.json", - "./strapi-admin": { - "types": "./dist/admin/src/index.d.ts", - "source": "./admin/src/index.ts", - "import": "./dist/admin/index.mjs", - "require": "./dist/admin/index.js", - "default": "./dist/admin/index.js" - }, - "./strapi-server": { - "types": "./dist/server/src/index.d.ts", - "source": "./server/src/index.ts", - "import": "./dist/server/index.mjs", - "require": "./dist/server/index.js", - "default": "./dist/server/index.js" - } - }, - "files": [ - "dist" - ], - "scripts": { - "prettier": "prettier --check ./", - "build": "strapi-plugin build", - "test:ts:back": "run -T tsc -p server/tsconfig.json", - "test:ts:front": "run -T tsc -p admin/tsconfig.json", - "verify": "strapi-plugin verify", - "watch": "strapi-plugin watch", - "watch:link": "strapi-plugin watch:link" - }, - "dependencies": { - "@fullcalendar/core": "^6.1.15", - "@fullcalendar/daygrid": "^6.1.15", - "@fullcalendar/list": "^6.1.15", - "@fullcalendar/react": "^6.1.15", - "@fullcalendar/timegrid": "^6.1.15", - "@strapi/design-system": "^2.0.0-rc.11", - "@strapi/icons": "^2.0.0-rc.11", - "moment": "^2.30.1", - "react-color": "^2.19.3", - "react-intl": "^6.6.8", - "tinycolor2": "^1.6.0", - "validate-color": "^2.2.1" - }, - "devDependencies": { - "@strapi/sdk-plugin": "^5.2.6", - "@strapi/strapi": "^5.0.0", - "@strapi/typescript-utils": "^5.0.0", - "@types/react": "^18.3.8", - "@types/react-color": "^3.0.12", - "@types/react-dom": "^18.3.0", - "@types/tinycolor2": "^1.4.6", - "prettier": "^3.3.3", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^6.26.2", - "styled-components": "^6.1.13", - "typescript": "^5.6.2" - }, - "peerDependencies": { - "@strapi/sdk-plugin": "^5.2.6", - "@strapi/strapi": "^5.0.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^6.26.2", - "styled-components": "^6.1.13" - }, - "strapi": { - "kind": "plugin", - "name": "strapi-calendar", - "displayName": "Strapi Calendar", - "description": "Visualize your Strapi content in month, week or daily view" - } + "name": "@offset-dev/strapi-calendar", + "version": "1.0.0", + "description": "Visualize your Strapi content in month, week or daily view", + "keywords": [], + "homepage": "https://github.com/offset-dev/strapi-calendar#readme", + "bugs": { + "url": "https://github.com/offset-dev/strapi-calendar/issues" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/offset-dev/strapi-calendar.git" + }, + "license": "MIT", + "author": "Luis Rodriguez", + "type": "commonjs", + "exports": { + "./package.json": "./package.json", + "./strapi-admin": { + "types": "./dist/admin/src/index.d.ts", + "source": "./admin/src/index.ts", + "import": "./dist/admin/index.mjs", + "require": "./dist/admin/index.js", + "default": "./dist/admin/index.js" + }, + "./strapi-server": { + "types": "./dist/server/src/index.d.ts", + "source": "./server/src/index.ts", + "import": "./dist/server/index.mjs", + "require": "./dist/server/index.js", + "default": "./dist/server/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "prettier": "prettier --check ./", + "build": "strapi-plugin build", + "test:ts:back": "run -T tsc -p server/tsconfig.json", + "test:ts:front": "run -T tsc -p admin/tsconfig.json", + "verify": "strapi-plugin verify", + "watch": "strapi-plugin watch", + "watch:link": "strapi-plugin watch:link" + }, + "dependencies": { + "@fullcalendar/core": "^6.1.17", + "@fullcalendar/daygrid": "^6.1.17", + "@fullcalendar/list": "^6.1.17", + "@fullcalendar/react": "^6.1.17", + "@fullcalendar/timegrid": "^6.1.17", + "@strapi/design-system": "2.0.0-rc.23", + "@strapi/icons": "2.0.0-rc.23", + "axios": "^1.9.0", + "moment": "^2.30.1", + "react-color": "^2.19.3", + "react-intl": "^6.8.9", + "tinycolor2": "^1.6.0", + "validate-color": "^2.2.4" + }, + "devDependencies": { + "@strapi/sdk-plugin": "^5.3.2", + "@strapi/strapi": "^5.13.0", + "@strapi/types": "^5.13.0", + "@strapi/typescript-utils": "^5.13.0", + "@types/koa": "^2.15.0", + "@types/koa__router": "^12.0.4", + "@types/node": "^22.15.17", + "@types/react": "^18.3.21", + "@types/react-color": "^3.0.13", + "@types/react-dom": "^18.3.7", + "@types/styled-components": "^5.1.34", + "@types/tinycolor2": "^1.4.6", + "deepmerge": "^4.3.1", + "prettier": "^3.5.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.0", + "styled-components": "^6.1.18", + "typescript": "^5.8.3" + }, + "peerDependencies": { + "@strapi/sdk-plugin": "^5.2.6", + "@strapi/strapi": "^5.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "styled-components": "^6.1.13" + }, + "strapi": { + "kind": "plugin", + "name": "strapi-calendar", + "displayName": "Strapi Calendar", + "description": "Visualize your Strapi content in month, week or daily view" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "@swc/core", + "core-js-pure", + "esbuild", + "sharp" + ] + } } diff --git a/server/src/controllers/controller.ts b/server/src/controllers/controller.ts index 6a24200..e115add 100644 --- a/server/src/controllers/controller.ts +++ b/server/src/controllers/controller.ts @@ -1,51 +1,101 @@ +// @ts-ignore import type { Core } from '@strapi/strapi'; import { PLUGIN_ID } from '../../../admin/src/pluginId'; +import type { IPluginController } from '../../../types'; -const controller = ({ strapi }: { strapi: Core.Strapi }) => ({ - async getData(ctx) { - ctx.body = await strapi - .plugin(PLUGIN_ID) - .service('service') - .getData(ctx.query.start, ctx.query.end); - }, - async getCollections(ctx) { - try { - ctx.body = await strapi.plugin(PLUGIN_ID).service('service').getCollections(); - } catch (err) { - ctx.throw(500, err); - } - }, - async getExtensions(ctx) { - try { - ctx.body = await strapi.plugin(PLUGIN_ID).service('service').getExtensions(); - } catch (err) { - ctx.throw(500, err); - } - }, - async getSettings(ctx) { - try { - ctx.body = await strapi.plugin(PLUGIN_ID).service('service').getSettings(); - } catch (err) { - ctx.throw(500, err); - } - }, - async setSettings(ctx) { - const { body } = ctx.request; - try { - await strapi.plugin(PLUGIN_ID).service('service').setSettings(body); - ctx.body = await strapi.plugin(PLUGIN_ID).service('service').getSettings(); - } catch (err) { - ctx.throw(500, err); - } - }, - async clearSettings(ctx) { - try { - await strapi.plugin(PLUGIN_ID).service('service').clearSettings(); - ctx.body = 'Settings have been reset'; - } catch (err) { - ctx.throw(500, err); - } - }, +const createController = ({ strapi }: { strapi: Core.Strapi }): IPluginController => ({ + async getData(ctx: any): Promise { + ctx.body = await strapi + .plugin(PLUGIN_ID) + .service('service') + .getData(ctx.query.start, ctx.query.end); + }, + async getCollections(ctx: any): Promise { + try { + ctx.body = await strapi.plugin(PLUGIN_ID).service('service').getCollections(); + } catch (err) { + ctx.throw(500, err); + } + }, + async getCollectionFilters(ctx: any): Promise { + try { + const { contentType } = ctx.query; + + if (!contentType) { + ctx.throw(400, 'Content type is required'); + return; + } + + ctx.body = await strapi + .plugin(PLUGIN_ID) + .service('service') + .getCollectionFilters(contentType); + } catch (err) { + ctx.throw(500, err); + } + }, + async getExtensions(ctx: any): Promise { + try { + const extensions = await strapi.plugin(PLUGIN_ID).service('service').getExtensions(); + + ctx.body = extensions.map((ext) => ({ + ...ext, + hasFilters: ext.filterFields && ext.filterFields.length > 0, + filterFields: ext.filterFields || [], + })); + } catch (err) { + ctx.throw(500, err); + } + }, + async getSettings(ctx: any): Promise { + try { + const settings = await strapi.plugin(PLUGIN_ID).service('service').getSettings(); + ctx.body = { + ...settings, + }; + } catch (err) { + ctx.throw(500, err); + } + }, + async setSettings(ctx: any): Promise { + try { + // @ts-ignore-next-line + const { body } = ctx.request; + if (body.collectionFilter) { + const validFilters = await this.validateFilters(body.collectionFilter, body.collection); + body.collectionFilter = validFilters; + } + + await strapi.plugin(PLUGIN_ID).service('service').setSettings(body); + + ctx.body = await strapi.plugin(PLUGIN_ID).service('service').getSettings(); + } catch (err) { + ctx.throw(500, err); + } + }, + async validateFilters(filters: any[], contentType: string): Promise { + if (!contentType || !filters?.length) return filters; + const model = strapi.getModel(contentType as any); + if (!model) return []; + + const filterableFields = await strapi + .plugin(PLUGIN_ID) + .service('service') + .getCollectionFilters(contentType); + + return filters.filter((filter) => { + const fieldInfo = filterableFields.find((f) => f.name === filter.field); + return fieldInfo && fieldInfo.filterOperators.includes(filter.operator); + }); + }, + async clearSettings(ctx: any): Promise { + try { + await strapi.plugin(PLUGIN_ID).service('service').clearSettings(); + ctx.body = 'Settings have been reset'; + } catch (err) { + ctx.throw(500, err); + } + }, }); -export default controller; +export default createController; diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 9a386e9..54950c9 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -1,5 +1,8 @@ -import controller from './controller'; +import createController from './controller'; +import type { Core } from '@strapi/strapi'; export default { - controller, + controller: createController, +} as { + controller: (deps: { strapi: Core.Strapi }) => ReturnType; }; diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 75a082b..b19dee6 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -1,56 +1,65 @@ export default [ - { - method: 'GET', - path: '/', - handler: 'controller.getData', - config: { - policies: [], - auth: false, - }, - }, - { - method: 'GET', - path: '/collections', - handler: 'controller.getCollections', - config: { - policies: [], - auth: false, - }, - }, - { - method: 'GET', - path: '/extensions', - handler: 'controller.getExtensions', - config: { - policies: [], - auth: false, - }, - }, - { - method: 'GET', - path: '/settings', - handler: 'controller.getSettings', - config: { - policies: [], - auth: false, - }, - }, - { - method: 'POST', - path: '/settings', - handler: 'controller.setSettings', - config: { - policies: [], - auth: false, - }, - }, - { - method: 'GET', - path: '/clear-settings', - handler: 'controller.clearSettings', - config: { - policies: [], - auth: false, - }, - }, + { + method: 'GET', + path: '/', + handler: 'controller.getData', + config: { + policies: [], + auth: false, + }, + }, + { + method: 'GET', + path: '/collections', + handler: 'controller.getCollections', + config: { + policies: [], + auth: false, + }, + }, + { + method: 'GET', + path: '/extensions', + handler: 'controller.getExtensions', + config: { + policies: [], + auth: false, + }, + }, + { + method: 'GET', + path: '/settings', + handler: 'controller.getSettings', + config: { + policies: [], + auth: false, + }, + }, + { + method: 'POST', + path: '/settings', + handler: 'controller.setSettings', + config: { + policies: [], + auth: false, + }, + }, + { + method: 'GET', + path: '/clear-settings', + handler: 'controller.clearSettings', + config: { + policies: [], + auth: false, + }, + }, + { + method: 'GET', + path: '/collection-filters', + handler: 'controller.getCollectionFilters', + config: { + policies: [], + auth: false, + }, + }, ]; diff --git a/server/src/services/extensions.ts b/server/src/services/extensions.ts index af66cad..747a050 100644 --- a/server/src/services/extensions.ts +++ b/server/src/services/extensions.ts @@ -1,70 +1,99 @@ import { ExtensionType, ExtensionsMapType } from '../../../types'; const extensionSystem = { - extensions: {} as ExtensionsMapType, + extensions: {} as ExtensionsMapType, - /** - * Retrieves all registered extensions. - * - * @returns {ExtensionsMapType} The registered extensions. - */ - getRegisteredExtensions: (): ExtensionsMapType => { - return extensionSystem.extensions; - }, + /** + * Retrieves all registered extensions. + * + * @returns {ExtensionsMapType} The registered extensions. + */ + getRegisteredExtensions: (): ExtensionsMapType => { + return extensionSystem.extensions; + }, - /** - * Registers a single extension by its id and details. - * - * @param {string} id - The unique identifier for the extension. - * @param {string} name - The name of the extension. - * @param {string[]} startFields - The start fields handled by the extension. - * @param {string[]} endFields - The end fields handled by the extension. - * @param {Function} [startHandler] - Optional start handler function. - * @param {Function} [endHandler] - Optional end handler function. - */ - registerExtension: ( - id: string, - name: string, - startFields: string[], - endFields: string[], - startHandler?: Function, - endHandler?: Function - ): void => { - extensionSystem.extensions[id] = { - name, - startFields, - endFields, - startHandler, - endHandler, - }; - }, + /** + * Registers a single extension by its id and details. + * + * @param {string} id - The unique identifier for the extension. + * @param {string} name - The name of the extension. + * @param {string[]} startFields - The start fields handled by the extension. + * @param {string[]} endFields - The end fields handled by the extension. + * @param {Function} [startHandler] - Optional start handler function. + * @param {Function} [endHandler] - Optional end handler function. + * @param {Function} [filterHandler] - Optional filter handler function. + */ + registerExtension: ( + id: string, + name: string, + startFields: string[], + endFields: string[], + filterFields: string[], + startHandler?: Function, + endHandler?: Function, + filterHandler?: Function + ): void => { + extensionSystem.extensions[id] = { + name, + startFields, + endFields, + filterFields, + startHandler, + endHandler, + filterHandler, + }; + }, - /** - * Registers multiple extensions at once. - * - * @param {ExtensionType[]} extensions - The array of extensions to register. - */ - registerExtensions: (extensions: ExtensionType[]): void => { - extensions.forEach((extension: ExtensionType) => { - extensionSystem.registerExtension( - extension.id, - extension.name, - extension.startFields, - extension.endFields, - extension.startHandler, - extension.endHandler - ); - }); - }, + /** + * Registers multiple extensions at once. + * + * @param {ExtensionType[]} extensions - The array of extensions to register. + */ + registerExtensions: (extensions: ExtensionType[]): void => { + extensions.forEach((extension: ExtensionType) => { + extensionSystem.registerExtension( + extension.id, + extension.name, + extension.startFields, + extension.endFields, + extension.filterFields, + extension.startHandler, + extension.endHandler, + extension.filterHandler + ); + }); + }, - /** - * Deregisters an extension by its name. - * - * @param {string} name - The name of the extension to remove. - */ - deregisterExtension: (name: string): void => { - delete extensionSystem.extensions[name]; - }, + /** + * Deregisters an extension by its name. + * + * @param {string} name - The name of the extension to remove. + */ + deregisterExtension: (name: string): void => { + delete extensionSystem.extensions[name]; + }, + /** + * Gets all extensions that support filtering for a specific field. + * + * @param {string} field - The field name to check. + * @returns {ExtensionsMapType} Filtered extensions map. + */ + getCollectionFiltersForField: (field: string): ExtensionsMapType => { + return Object.entries(extensionSystem.extensions) + .filter(([_, ext]) => ext.filterFields?.includes(field)) + .reduce((acc, [id, ext]) => ({ ...acc, [id]: ext }), {}); + }, + + /** + * Gets all filterable fields across all extensions. + * + * @returns {string[]} Array of filterable field names. + */ + getCollectionFilters: (): string[] => { + return Array.from( + new Set(Object.values(extensionSystem.extensions).flatMap((ext) => ext.filterFields || [])) + ); + }, }; export default extensionSystem; diff --git a/server/src/services/service.ts b/server/src/services/service.ts index 85b563b..22cc6ba 100644 --- a/server/src/services/service.ts +++ b/server/src/services/service.ts @@ -4,99 +4,161 @@ import merge from 'deepmerge'; import extensionSystem from './extensions'; import { createDefaultConfig, getPluginStore, initHandlers } from '../utils'; -import { SettingsType } from '../../../types'; +import { SettingsType, CollectionFilter } from '../../../types'; const service = ({ strapi }: { strapi: Core.Strapi }) => ({ - getData: async (start: string, end: string): Promise => { - const pluginStore = getPluginStore(); - let config: SettingsType | null = await pluginStore.get({ key: 'settings' }); - if (!config) return []; - - const [startHandler, endHandler] = initHandlers( - config.startField, - config.endField, - extensionSystem.getRegisteredExtensions() - ); - - let data: Record = {}; - if (startHandler) { - data = await startHandler(start, end, strapi, config); - } - if (endHandler) { - data = merge(await endHandler(strapi, config, data), data); - } - - // Filter out drafts if not configured to show them - const dataFiltered = Object.values(data).filter((x) => { - if (config.drafts) return true; - return x.publishedAt; - }); - - // Map data into the required format - return dataFiltered.map((x) => ({ - id: x.documentId, - title: config.titleField ? x[config.titleField] : config.startField, - start: x[config.startField], - end: config.endField - ? x[config.endField] - : moment(x[config.startField]).add(config.defaultDuration, 'minutes'), - backgroundColor: - config.colorField && x[config.colorField] ? x[config.colorField] : config.eventColor, - borderColor: - config.colorField && x[config.colorField] ? x[config.colorField] : config.eventColor, - url: `/admin/content-manager/collection-types/${config.collection}/${x.documentId}`, - })); - }, - - /** - * Retrieves all content types that are collection types. - */ - getCollections: async (): Promise => { - const types = strapi.contentTypes; - return Object.values(types).filter((type) => type.kind === 'collectionType' && type.apiName); - }, - - /** - * Retrieves all registered extensions. - */ - getExtensions: async (): Promise => { - return Object.entries(extensionSystem.getRegisteredExtensions()).map(([id, extension]) => ({ - id, - name: extension.name, - startFields: extension.startFields, - endFields: extension.endFields, - })); - }, - - /** - * Retrieves the current settings from the plugin store, or creates default settings if none exist. - */ - getSettings: async (): Promise => { - const pluginStore = getPluginStore(); - let config = await pluginStore.get({ key: 'settings' }); - if (!config) { - config = await createDefaultConfig(); - } - return config; - }, - - /** - * Saves the provided settings to the plugin store. - */ - setSettings: async (settings: SettingsType): Promise => { - const pluginStore = getPluginStore(); - await pluginStore.set({ key: 'settings', value: settings }); - return pluginStore.get({ key: 'settings' }); - }, - - /** - * Clears the current settings from the plugin store. - */ - clearSettings: async (): Promise => { - const pluginStore = getPluginStore(); - await pluginStore.set({ key: 'settings', value: null }); - return pluginStore.get({ key: 'settings' }); - }, + getData: async (start: string, end: string): Promise => { + const pluginStore = getPluginStore(); + let config: SettingsType | null = await pluginStore.get({ key: 'settings' }); + if (!config) return []; + + const [startHandler, endHandler] = initHandlers( + config.startField, + config.endField, + extensionSystem.getRegisteredExtensions() + ); + + let data: Record = {}; + if (startHandler) { + data = await startHandler(start, end, strapi, config); + } + if (endHandler) { + data = merge(await endHandler(strapi, config, data), data); + } + + // Filter out drafts if not configured to show them + const dataFiltered = Object.values(data).filter((x) => { + if (config.drafts) return true; + return x.publishedAt; + }); + + // Map data into the required format + return dataFiltered.map((x) => ({ + id: x.documentId, + title: config.titleField ? x[config.titleField] : config.startField, + start: x[config.startField], + end: config.endField + ? x[config.endField] + : moment(x[config.startField]).add(config.defaultDuration, 'minutes'), + backgroundColor: + config.colorField && x[config.colorField] ? x[config.colorField] : config.eventColor, + borderColor: + config.colorField && x[config.colorField] ? x[config.colorField] : config.eventColor, + url: `/admin/content-manager/collection-types/${config.collection}/${x.documentId}`, + })); + }, + + /** + * Retrieves all content types that are collection types. + */ + getCollections: async (): Promise => { + const types = strapi.contentTypes; + return Object.values(types).filter((type) => type.kind === 'collectionType' && type.apiName); + }, + + /** + * Get filterable fields for a content type + */ + getCollectionFilters: async (contentType: string): Promise => { + const model = strapi.getModel(contentType as any); + if (!model || !model.attributes) return []; + + const attributes = model.attributes; + + return Object.entries(attributes) + .filter(([_, attr]) => service({ strapi }).isFieldFilterable(attr)) // now valid + .map(([key, attr]) => ({ + name: key, + type: attr.type, + filterOperators: service({ strapi }).getSupportedOperators(attr.type), + })); + }, + + /** + * Retrieves all registered extensions. + */ + getExtensions: async (): Promise => { + return Object.entries(extensionSystem.getRegisteredExtensions()).map(([id, extension]) => ({ + id, + name: extension.name, + startFields: extension.startFields, + endFields: extension.endFields, + })); + }, + + /** + * Retrieves the current settings from the plugin store, or creates default settings if none exist. + */ + getSettings: async (): Promise => { + const pluginStore = getPluginStore(); + let config = await pluginStore.get({ key: 'settings' }); + if (!config) { + config = await createDefaultConfig(); + } + return config; + }, + + /** + * Saves the provided settings to the plugin store. + */ + setSettings: async (settings: SettingsType): Promise => { + const pluginStore = getPluginStore(); + await pluginStore.set({ key: 'settings', value: settings }); + return pluginStore.get({ key: 'settings' }); + }, + + /** + * Clears the current settings from the plugin store. + */ + clearSettings: async (): Promise => { + const pluginStore = getPluginStore(); + await pluginStore.set({ key: 'settings', value: null }); + return pluginStore.get({ key: 'settings' }); + }, + + /** + * Check if a field is filterable + */ + isFieldFilterable: (attribute: any) => { + const nonFilterableTypes = ['json', 'component', 'dynamiczone', 'media']; + return !nonFilterableTypes.includes(attribute.type); + }, + + /** + * Get supported operators for a field type + */ + getSupportedOperators: (fieldType: string) => { + const operators = { + string: ['eq', 'ne', 'contains', 'notContains', 'startsWith', 'endsWith'], + number: ['eq', 'ne', 'lt', 'lte', 'gt', 'gte'], + date: ['eq', 'ne', 'lt', 'lte', 'gt', 'gte'], + boolean: ['eq', 'ne'], + enumeration: ['eq', 'ne'], + }; + + return operators[fieldType] || operators.string; + }, + + /** + * Build Strapi query from filters + */ + buildQueryFromFilters: (filters: any[] = []) => { + const query: any = { where: {} }; + + filters.forEach((filter) => { + if (!filter.field || !filter.operator) return; + + if (filter.operator === 'eq') { + query.where[filter.field] = filter.value; + } else { + query.where[filter.field] = { + [filter.operator]: filter.value, + }; + } + }); + + return query; + }, }); export default service; diff --git a/server/src/utils.ts b/server/src/utils.ts index b03cff0..8977f83 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -8,20 +8,20 @@ import { SettingsType } from '../../types'; * Retrieves the plugin store for this plugin. */ export const getPluginStore = (): any => { - return strapi.store({ - environment: '', - type: 'plugin', - name: PLUGIN_ID, - }); + return strapi.store({ + environment: '', + type: 'plugin', + name: PLUGIN_ID, + }); }; /** * Creates the default plugin configuration in the store if not already set. */ export const createDefaultConfig = async (): Promise => { - const pluginStore = getPluginStore(); - await pluginStore.set({ key: 'settings', value: defaultSettings }); - return pluginStore.get({ key: 'settings' }); + const pluginStore = getPluginStore(); + await pluginStore.set({ key: 'settings', value: defaultSettings }); + return pluginStore.get({ key: 'settings' }); }; /** @@ -33,47 +33,72 @@ export const createDefaultConfig = async (): Promise => { * @param {object} extensions - Registered extensions to override handlers. * @returns {[Function | undefined, Function | undefined]} Array containing startHandler and endHandler functions. */ + export const initHandlers = ( - start: string, - end: string, - extensions: Record + start: string, + end: string, + extensions: Record ): [Function | undefined, Function | undefined] => { - // Default start handler - let startHandler: Function = async ( - startDate: string, - endDate: string, - strapi: any, - config: SettingsType - ) => - ( - await strapi.documents(config.collection).findMany({ - filters: { - $and: [ - { - [config.startField]: { - $gte: moment(startDate).startOf('day').format(), - $lte: moment(endDate).endOf('day').format(), - }, - }, - ], - }, - }) - ).reduce((acc: Record, el: any) => { - acc[el.id] = el; - return acc; - }, {}); + let startHandler: Function = async ( + startDate: string, + endDate: string, + strapi: any, + config: SettingsType + ) => { + function parseValue(value: string): any { + if (value === 'true') return true; + if (value === 'false') return false; + if (!isNaN(Number(value))) return Number(value); + const isoDate = Date.parse(value); + if (!isNaN(isoDate)) return new Date(isoDate).toISOString(); + return value; + } + + function buildFilters( + collectionFilters: Array<{ field: string; operator: string; value: string }> + ) { + const filters: any = {}; + if (collectionFilters) { + collectionFilters.forEach(({ field, operator, value }) => { + filters[field] = { + [`$${operator}`]: parseValue(value), + }; + }); + } + return filters; + } + const filters = buildFilters(config.collectionFilters); + + const results = await strapi.entityService.findMany(config.collection, { + filters: { + $and: [ + { + [config.startField]: { + $gte: moment(startDate).startOf('day').toISOString(), + $lte: moment(endDate).endOf('day').toISOString(), + }, + }, + filters, + ], + }, + }); + + return results.reduce((acc: Record, el: any) => { + acc[el.id] = el; + return acc; + }, {}); + }; - let endHandler: Function | undefined; + let endHandler: Function | undefined; - // Override handlers if matching extension is found - Object.entries(extensions).forEach(([id, extension]) => { - if (id && start.startsWith(id)) { - startHandler = extension.startHandler; - } - if (id && end.startsWith(id)) { - endHandler = extension.endHandler; - } - }); + Object.entries(extensions).forEach(([id, extension]) => { + if (id && start.startsWith(id)) { + startHandler = extension.startHandler; + } + if (id && end.startsWith(id)) { + endHandler = extension.endHandler; + } + }); - return [startHandler, endHandler]; + return [startHandler, endHandler]; }; diff --git a/server/tsconfig.json b/server/tsconfig.json index 9cfefe4..e6e6dae 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,8 +1,8 @@ { - "extends": "@strapi/typescript-utils/tsconfigs/server", - "include": ["./src"], - "compilerOptions": { - "rootDir": "../", - "baseUrl": "." - } + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "../", + "types": ["node", "koa", "koa__router"] + }, + "include": ["./src/**/*"] } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a0e7f2d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Node", + "strictNullChecks": false, + "declaration": true, + "emitDeclarationOnly": true, + "isolatedModules": false, + "jsx": "react-jsx", + "allowJs": true, + "checkJs": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": [], + "paths": { + "@/*": ["admin/src/*"], + "@strapi/*": ["node_modules/@strapi/*"] + }, + "typeRoots": ["./node_modules/@types"], + "baseUrl": ".", + "preserveSymlinks": true, + "skipLibCheck": true + }, + "include": ["admin/src/**/*", "server/**/*", "types"], + "exclude": ["node_modules"] +} diff --git a/types.ts b/types.ts index 439a035..205813e 100644 --- a/types.ts +++ b/types.ts @@ -1,50 +1,88 @@ +import '@strapi/strapi'; +import { Context } from 'koa'; + +declare module '@strapi/strapi' { + interface Strapi { + plugin(name: string): { + service(name: string): any; + controller(name: string): any; + }; + } +} + +export interface IPluginController { + getData(ctx: Context): Promise; + getCollections(ctx: Context): Promise; + getCollectionFilters(ctx: any): Promise; + getExtensions(ctx: Context): Promise; + getSettings(ctx: Context): Promise; + setSettings(ctx: Context): Promise; + clearSettings(ctx: Context): Promise; + validateFilters?(filters: any[], contentType: string): Promise; +} + // Admin Types export type SettingsType = { - collection: null | string; - startField: null | string; - endField: null | string; - titleField: null | string; - colorField: null | string; - defaultDuration: number; - drafts: boolean; - startHour: string; - endHour: string; - defaultView: string; - monthView: boolean; - weekView: boolean; - workWeekView: boolean; - dayView: boolean; - todayButton: boolean; - createButton: boolean; - primaryColor: string; - eventColor: string; + collection: null | string; + startField: null | string; + endField: null | string; + titleField: null | string; + colorField: null | string; + defaultDuration: number; + drafts: boolean; + startHour: string; + endHour: string; + defaultView: string; + monthView: boolean; + weekView: boolean; + workWeekView: boolean; + dayView: boolean; + todayButton: boolean; + createButton: boolean; + primaryColor: string; + eventColor: string; + collectionFilters: Array<{ + field: null | string; + operator: null | string; + value: null | string; + }>; }; export type SettingsContextType = { - settings: SettingsType; - updateField: (setting: Partial) => void; - saveSettings: () => Promise; - loading: boolean; - saving: boolean; + settings: SettingsType; + updateField: (setting: Partial) => void; + saveSettings: () => Promise; + loading: boolean; + saving: boolean; }; // Server Types export type ExtensionType = { - id: string; - name: string; - startFields: string[]; - endFields: string[]; - startHandler?: Function; - endHandler?: Function; + id: string; + name: string; + startFields: string[]; + endFields: string[]; + filterFields?: string[]; + startHandler?: Function; + endHandler?: Function; + filterHandler?: Function; }; export type ExtensionsMapType = Record< - string, - { - name: string; - startFields: string[]; - endFields: string[]; - startHandler?: Function; - endHandler?: Function; - } + string, + { + name: string; + startFields: string[]; + endFields: string[]; + filterFields?: string[]; + startHandler?: Function; + endHandler?: Function; + filterHandler?: Function; + } >; + +export interface CollectionFilter { + name: string; + type: string; + filterOperators: string[]; +}