diff --git a/apps/meteor/client/views/admin/EditableSettingsContext.ts b/apps/meteor/client/views/admin/EditableSettingsContext.ts index 0af5851e823a0..cbfb86259fec5 100644 --- a/apps/meteor/client/views/admin/EditableSettingsContext.ts +++ b/apps/meteor/client/views/admin/EditableSettingsContext.ts @@ -1,68 +1,127 @@ -import type { ISettingBase, ISettingColor, ISetting } from '@rocket.chat/core-typings'; -import type { SettingsContextQuery } from '@rocket.chat/ui-contexts'; -import { createContext, useContext, useMemo, useSyncExternalStore } from 'react'; +import type { ISetting } from '@rocket.chat/core-typings'; +import { createContext, useContext } from 'react'; +import { create, type StoreApi, type UseBoundStore } from 'zustand'; +import { useShallow } from 'zustand/shallow'; -export type EditableSetting = (ISettingBase | ISettingColor) & { +export type EditableSetting = ISetting & { disabled: boolean; changed: boolean; invisible: boolean; }; -type EditableSettingsContextQuery = SettingsContextQuery & { - changed?: boolean; +export const compareSettings = (a: EditableSetting, b: EditableSetting): number => { + const sorter = a.sorter - b.sorter; + if (sorter !== 0) return sorter; + + const tab = (a.tab ?? '').localeCompare(b.tab ?? ''); + if (tab !== 0) return tab; + + const i18nLabel = a.i18nLabel.localeCompare(b.i18nLabel); + + return i18nLabel; }; +type EditableSettingsContextQuery = + | { + group: ISetting['_id']; + } + | { + group: ISetting['_id']; + section: string; + tab?: ISetting['_id']; + } + | { + group: ISetting['_id']; + changed: true; + }; + +export interface IEditableSettingsState { + state: EditableSetting[]; + initialState: ISetting[]; + sync(newInitialState: ISetting[]): void; + mutate(changes: Partial[]): void; +} + export type EditableSettingsContextValue = { - readonly queryEditableSetting: ( - _id: ISetting['_id'], - ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => EditableSetting | undefined]; - readonly queryEditableSettings: ( - query: EditableSettingsContextQuery, - ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => EditableSetting[]]; - readonly queryGroupSections: ( - _id: ISetting['_id'], - tab?: ISetting['_id'], - ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => string[]]; - readonly queryGroupTabs: ( - _id: ISetting['_id'], - ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ISetting['_id'][]]; - readonly dispatch: (changes: Partial[]) => void; + useEditableSettingsStore: UseBoundStore>; }; export const EditableSettingsContext = createContext({ - queryEditableSetting: () => [(): (() => void) => (): void => undefined, (): undefined => undefined], - queryEditableSettings: () => [(): (() => void) => (): void => undefined, (): EditableSetting[] => []], - queryGroupSections: () => [(): (() => void) => (): void => undefined, (): string[] => []], - queryGroupTabs: () => [(): (() => void) => (): void => undefined, (): ISetting['_id'][] => []], - dispatch: () => undefined, + useEditableSettingsStore: create()(() => ({ + state: [], + initialState: [], + sync: () => undefined, + mutate: () => undefined, + })), }); export const useEditableSetting = (_id: ISetting['_id']): EditableSetting | undefined => { - const { queryEditableSetting } = useContext(EditableSettingsContext); + const { useEditableSettingsStore } = useContext(EditableSettingsContext); - const [subscribe, getSnapshot] = useMemo(() => queryEditableSetting(_id), [queryEditableSetting, _id]); - return useSyncExternalStore(subscribe, getSnapshot); + return useEditableSettingsStore((state) => state.state.find((x) => x._id === _id)); }; -export const useEditableSettings = (query?: EditableSettingsContextQuery): EditableSetting[] => { - const { queryEditableSettings } = useContext(EditableSettingsContext); - const [subscribe, getSnapshot] = useMemo(() => queryEditableSettings(query ?? {}), [queryEditableSettings, query]); - return useSyncExternalStore(subscribe, getSnapshot); +export const useEditableSettings = (query: EditableSettingsContextQuery): EditableSetting[] => { + const { useEditableSettingsStore } = useContext(EditableSettingsContext); + + return useEditableSettingsStore( + useShallow((state) => + state.state + .filter((x) => { + if ('changed' in query) { + return x.group === query.group && x.changed; + } + + if ('section' in query) { + return ( + x.group === query.group && + (query.section ? x.section === query.section : !x.section) && + (query.tab ? x.tab === query.tab : !x.tab) + ); + } + + return x.group === query.group; + }) + .sort(compareSettings), + ), + ); }; export const useEditableSettingsGroupSections = (_id: ISetting['_id'], tab?: ISetting['_id']): string[] => { - const { queryGroupSections } = useContext(EditableSettingsContext); + const { useEditableSettingsStore } = useContext(EditableSettingsContext); - const [subscribe, getSnapshot] = useMemo(() => queryGroupSections(_id, tab), [queryGroupSections, _id, tab]); - return useSyncExternalStore(subscribe, getSnapshot); + return useEditableSettingsStore( + useShallow((state) => + Array.from( + new Set( + state.state + .filter((x) => x.group === _id && (tab !== undefined ? x.tab === tab : !x.tab)) + .sort(compareSettings) + .map(({ section }) => section || ''), + ), + ), + ), + ); }; export const useEditableSettingsGroupTabs = (_id: ISetting['_id']): ISetting['_id'][] => { - const { queryGroupTabs } = useContext(EditableSettingsContext); + const { useEditableSettingsStore } = useContext(EditableSettingsContext); - const [subscribe, getSnapshot] = useMemo(() => queryGroupTabs(_id), [queryGroupTabs, _id]); - return useSyncExternalStore(subscribe, getSnapshot); + return useEditableSettingsStore( + useShallow((state) => + Array.from( + new Set( + state.state + .filter((x) => x.group === _id) + .sort(compareSettings) + .map(({ tab }) => tab || ''), + ), + ), + ), + ); }; -export const useEditableSettingsDispatch = (): ((changes: Partial[]) => void) => - useContext(EditableSettingsContext).dispatch; +export const useEditableSettingsDispatch = (): ((changes: Partial[]) => void) => { + const { useEditableSettingsStore } = useContext(EditableSettingsContext); + return useEditableSettingsStore((state) => state.mutate); +}; diff --git a/apps/meteor/client/views/admin/settings/EditableSettingsProvider.tsx b/apps/meteor/client/views/admin/settings/EditableSettingsProvider.tsx index 677332261697d..8fd724ee1b361 100644 --- a/apps/meteor/client/views/admin/settings/EditableSettingsProvider.tsx +++ b/apps/meteor/client/views/admin/settings/EditableSettingsProvider.tsx @@ -1,209 +1,110 @@ import type { ISetting } from '@rocket.chat/core-typings'; -import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import type { SettingsContextQuery } from '@rocket.chat/ui-contexts'; +import { createFilterFromQuery } from '@rocket.chat/mongo-adapter'; import { useSettings } from '@rocket.chat/ui-contexts'; -import { Mongo } from 'meteor/mongo'; -import { Tracker } from 'meteor/tracker'; -import type { FilterOperators } from 'mongodb'; -import type { MutableRefObject, ReactNode } from 'react'; -import { useEffect, useMemo, useRef } from 'react'; +import type { ReactNode } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { create } from 'zustand'; -import { createReactiveSubscriptionFactory } from '../../../lib/createReactiveSubscriptionFactory'; -import type { EditableSetting, EditableSettingsContextValue } from '../EditableSettingsContext'; +import type { EditableSetting, IEditableSettingsState } from '../EditableSettingsContext'; import { EditableSettingsContext } from '../EditableSettingsContext'; -const defaultQuery: SettingsContextQuery = {}; -const defaultOmit: Array = []; +const defaultOmit: Array = ['Cloud_Workspace_AirGapped_Restrictions_Remaining_Days']; + +const performSettingQuery = ( + query: + | string + | { + _id: string; + value: unknown; + } + | { + _id: string; + value: unknown; + }[] + | undefined, + settings: ISetting[], +) => { + if (!query) { + return true; + } + + const queries = [].concat(typeof query === 'string' ? JSON.parse(query) : query); + return queries.every((query) => settings.some(createFilterFromQuery(query))); +}; type EditableSettingsProviderProps = { children?: ReactNode; - query?: SettingsContextQuery; - omit?: Array; }; -const EditableSettingsProvider = ({ children, query = defaultQuery, omit = defaultOmit }: EditableSettingsProviderProps) => { - const settingsCollectionRef = useRef>(null) as MutableRefObject>; - const persistedSettings = useSettings(query); - - const getSettingsCollection = useEffectEvent(() => { - if (!settingsCollectionRef.current) { - settingsCollectionRef.current = new Mongo.Collection(null); - } - - return settingsCollectionRef.current; - }) as () => Mongo.Collection; - - useEffect(() => { - const settingsCollection = getSettingsCollection(); - - settingsCollection.remove({ _id: { $nin: persistedSettings.map(({ _id }) => _id) } }); - for (const { _id, ...fields } of persistedSettings) { - settingsCollection.upsert(_id, { $set: { ...fields }, $unset: { changed: true } }); - } - // TODO: Remove option to omit settings from admin pages manually - // This is a very wacky workaround due to lack of support to omit settings from the - // admin settings page while keeping them public. - if (omit.length > 0) { - settingsCollection.remove({ _id: { $in: omit } }); - } - }, [getSettingsCollection, persistedSettings, omit]); - - const queryEditableSetting = useMemo(() => { - const validateSettingQueries = ( - query: undefined | string | FilterOperators | FilterOperators[], - settingsCollection: Mongo.Collection, - ): boolean => { - if (!query) { - return true; - } - - const queries = [].concat(typeof query === 'string' ? JSON.parse(query) : query); - return queries.every((query) => settingsCollection.find(query).count() > 0); - }; - - return createReactiveSubscriptionFactory((_id: ISetting['_id']): EditableSetting | undefined => { - const settingsCollection = getSettingsCollection(); - const editableSetting = settingsCollection.findOne(_id); - - if (!editableSetting) { - return undefined; - } - - return { - ...editableSetting, - disabled: editableSetting.blocked || !validateSettingQueries(editableSetting.enableQuery, settingsCollection), - invisible: !validateSettingQueries(editableSetting.displayQuery, settingsCollection), - }; - }); - }, [getSettingsCollection]); - - const queryEditableSettings = useMemo( - () => - createReactiveSubscriptionFactory((query = {}) => - getSettingsCollection() - .find( - { - ...('_id' in query && { _id: { $in: query._id } }), - ...('group' in query && { group: query.group }), - ...('changed' in query && { changed: query.changed }), - $and: [ - { - ...('section' in query && - (query.section - ? { section: query.section } - : { - $or: [{ section: { $exists: false } }, { section: '' }], - })), - }, - { - ...('tab' in query && - (query.tab - ? { tab: query.tab } - : { - $or: [{ tab: { $exists: false } }, { tab: '' }], - })), - }, - ], - }, - { - sort: { - section: 1, - sorter: 1, - i18nLabel: 1, - }, - }, - ) - .fetch(), - ), - [getSettingsCollection], - ); - - const queryGroupSections = useMemo( - () => - createReactiveSubscriptionFactory((_id: ISetting['_id'], tab?: ISetting['_id']) => - Array.from( - new Set( - getSettingsCollection() - .find( - { - group: _id, - ...(tab !== undefined - ? { tab } - : { - $or: [{ tab: { $exists: false } }, { tab: '' }], - }), - }, - { - fields: { - section: 1, - }, - sort: { - sorter: 1, - section: 1, - i18nLabel: 1, - }, - }, - ) - .fetch() - .map(({ section }) => section || ''), - ), +// TODO: this component can be replaced by RHF state management +const EditableSettingsProvider = ({ children }: EditableSettingsProviderProps) => { + const persistedSettings = useSettings(); + + const [useEditableSettingsStore] = useState(() => + create()((set) => ({ + state: persistedSettings + .filter((x) => !defaultOmit.includes(x._id)) + .map( + (persisted): EditableSetting => ({ + ...persisted, + changed: false, + disabled: persisted.blocked || !performSettingQuery(persisted.enableQuery, persistedSettings), + invisible: !performSettingQuery(persisted.displayQuery, persistedSettings), + }), ), - ), - [getSettingsCollection], + initialState: persistedSettings, + sync: (newInitialState) => { + set(({ state }) => ({ + state: newInitialState + .filter((x) => !defaultOmit.includes(x._id)) + .map( + (persisted): EditableSetting => ({ + ...state.find(({ _id }) => _id === persisted._id), + ...persisted, + changed: false, + disabled: persisted.blocked || !performSettingQuery(persisted.enableQuery, state), + invisible: !performSettingQuery(persisted.displayQuery, state), + }), + ), + })); + }, + mutate: (changes) => { + set(({ state, initialState }) => ({ + state: initialState + .filter((x) => !defaultOmit.includes(x._id)) + .map((persisted): EditableSetting => { + const current = state.find(({ _id }) => _id === persisted._id); + if (!current) throw new Error(`Setting ${persisted._id} not found`); + + const change = changes.find(({ _id }) => _id === current._id); + + if (!change) { + return current; + } + + return { + ...current, + ...change, + disabled: persisted.blocked || !performSettingQuery(persisted.enableQuery, state), + invisible: !performSettingQuery(persisted.displayQuery, state), + }; + }), + })); + }, + })), ); - const queryGroupTabs = useMemo( - () => - createReactiveSubscriptionFactory((_id: ISetting['_id']) => - Array.from( - new Set( - getSettingsCollection() - .find( - { - group: _id, - }, - { - fields: { - tab: 1, - }, - sort: { - sorter: 1, - tab: 1, - i18nLabel: 1, - }, - }, - ) - .fetch() - .map(({ tab }) => tab || ''), - ), - ), - ), - [getSettingsCollection], - ); - - const dispatch = useEffectEvent((changes: Partial[]): void => { - for (const { _id, ...data } of changes) { - if (!_id) { - continue; - } + const sync = useEditableSettingsStore((state) => state.sync); - getSettingsCollection().update(_id, { $set: data }); - } - Tracker.flush(); - }); + useEffect(() => { + sync(persistedSettings); + }, [persistedSettings, sync]); - const contextValue = useMemo( - () => ({ - queryEditableSetting, - queryEditableSettings, - queryGroupSections, - queryGroupTabs, - dispatch, - }), - [queryEditableSetting, queryEditableSettings, queryGroupSections, queryGroupTabs, dispatch], + return ( + ({ useEditableSettingsStore }), [useEditableSettingsStore])}> + {children} + ); - - return ; }; export default EditableSettingsProvider; diff --git a/apps/meteor/client/views/admin/settings/SettingsRoute.tsx b/apps/meteor/client/views/admin/settings/SettingsRoute.tsx index 70776dee4fe80..145fb72e3ef3e 100644 --- a/apps/meteor/client/views/admin/settings/SettingsRoute.tsx +++ b/apps/meteor/client/views/admin/settings/SettingsRoute.tsx @@ -6,8 +6,6 @@ import SettingsGroupSelector from './SettingsGroupSelector'; import SettingsPage from './SettingsPage'; import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; -const omittedSettings = ['Cloud_Workspace_AirGapped_Restrictions_Remaining_Days']; - export const SettingsRoute = (): ReactElement => { const hasPermission = useIsPrivilegedSettingsContext(); const groupId = useRouteParameter('group'); @@ -22,7 +20,7 @@ export const SettingsRoute = (): ReactElement => { } return ( - + router.navigate('/admin/settings')} /> ); diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 49c1a6f041e36..c35394fda8df5 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -445,7 +445,8 @@ "xml-encryption": "~3.1.0", "xml2js": "~0.6.2", "yaqrcode": "^0.2.1", - "zod": "^3.24.1" + "zod": "^3.24.1", + "zustand": "~5.0.3" }, "meteor": { "mainModule": { diff --git a/yarn.lock b/yarn.lock index d3468caa822f9..27495dd1e7bf8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9157,6 +9157,7 @@ __metadata: xml2js: "npm:~0.6.2" yaqrcode: "npm:^0.2.1" zod: "npm:^3.24.1" + zustand: "npm:~5.0.3" languageName: unknown linkType: soft @@ -38251,6 +38252,27 @@ __metadata: languageName: node linkType: hard +"zustand@npm:~5.0.3": + version: 5.0.3 + resolution: "zustand@npm:5.0.3" + peerDependencies: + "@types/react": ">=18.0.0" + immer: ">=9.0.6" + react: ">=18.0.0" + use-sync-external-store: ">=1.2.0" + peerDependenciesMeta: + "@types/react": + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + checksum: 10/35728fdaa68291ea3e469524316dda4fe1d8cc22d8be3df309ca99bda0dbc7e66a1c502f66c26f76abfb4bd49a6e1368160353eb3cb173c24042a5f252075462 + languageName: node + linkType: hard + "zwitch@npm:^1.0.0": version: 1.0.5 resolution: "zwitch@npm:1.0.5"