[IMPROVE] Performance editing Admin settings (#17916)
parent
cc1ed29852
commit
d73c7a7219
@ -1,217 +0,0 @@ |
||||
import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; |
||||
import { Mongo } from 'meteor/mongo'; |
||||
import { Tracker } from 'meteor/tracker'; |
||||
import React, { useEffect, useMemo, useReducer, useRef, useState } from 'react'; |
||||
|
||||
import { PrivilegedSettingsContext } from '../contexts/PrivilegedSettingsContext'; |
||||
import { useAtLeastOnePermission } from '../contexts/AuthorizationContext'; |
||||
import { PrivateSettingsCachedCollection } from './PrivateSettingsCachedCollection'; |
||||
|
||||
const compareStrings = (a = '', b = '') => { |
||||
if (a === b || (!a && !b)) { |
||||
return 0; |
||||
} |
||||
|
||||
return a > b ? 1 : -1; |
||||
}; |
||||
|
||||
const compareSettings = (a, b) => |
||||
compareStrings(a.section, b.section) |
||||
|| compareStrings(a.sorter, b.sorter) |
||||
|| compareStrings(a.i18nLabel, b.i18nLabel); |
||||
|
||||
const settingsReducer = (states, { type, payload }) => { |
||||
const { |
||||
settings, |
||||
persistedSettings, |
||||
} = states; |
||||
|
||||
switch (type) { |
||||
case 'add': { |
||||
return { |
||||
settings: [...settings, ...payload].sort(compareSettings), |
||||
persistedSettings: [...persistedSettings, ...payload].sort(compareSettings), |
||||
}; |
||||
} |
||||
|
||||
case 'change': { |
||||
const mapping = (setting) => (setting._id !== payload._id ? setting : payload); |
||||
|
||||
return { |
||||
settings: settings.map(mapping), |
||||
persistedSettings: settings.map(mapping), |
||||
}; |
||||
} |
||||
|
||||
case 'remove': { |
||||
const mapping = (setting) => setting._id !== payload; |
||||
|
||||
return { |
||||
settings: settings.filter(mapping), |
||||
persistedSettings: persistedSettings.filter(mapping), |
||||
}; |
||||
} |
||||
|
||||
case 'hydrate': { |
||||
const map = {}; |
||||
payload.forEach((setting) => { |
||||
map[setting._id] = setting; |
||||
}); |
||||
|
||||
const mapping = (setting) => (map[setting._id] ? { ...setting, ...map[setting._id] } : setting); |
||||
|
||||
return { |
||||
settings: settings.map(mapping), |
||||
persistedSettings, |
||||
}; |
||||
} |
||||
} |
||||
|
||||
return states; |
||||
}; |
||||
|
||||
function AuthorizedPrivilegedSettingsProvider({ cachedCollection, children }) { |
||||
const [isLoading, setLoading] = useState(true); |
||||
|
||||
const subscribersRef = useRef(); |
||||
if (!subscribersRef.current) { |
||||
subscribersRef.current = new Set(); |
||||
} |
||||
|
||||
const stateRef = useRef({ settings: [], persistedSettings: [] }); |
||||
|
||||
const [state, dispatch] = useReducer(settingsReducer, { settings: [], persistedSettings: [] }); |
||||
stateRef.current = state; |
||||
|
||||
subscribersRef.current.forEach((subscriber) => { |
||||
subscriber(state); |
||||
}); |
||||
|
||||
const collectionsRef = useRef({}); |
||||
|
||||
useEffect(() => { |
||||
const stopLoading = () => { |
||||
setLoading(false); |
||||
}; |
||||
|
||||
if (!Tracker.nonreactive(() => cachedCollection.ready.get())) { |
||||
cachedCollection.init().then(stopLoading, stopLoading); |
||||
} else { |
||||
stopLoading(); |
||||
} |
||||
|
||||
const { collection: persistedSettingsCollection } = cachedCollection; |
||||
const settingsCollection = new Mongo.Collection(null); |
||||
|
||||
collectionsRef.current = { |
||||
persistedSettingsCollection, |
||||
settingsCollection, |
||||
}; |
||||
}, [collectionsRef]); |
||||
|
||||
useEffect(() => { |
||||
if (isLoading) { |
||||
return; |
||||
} |
||||
|
||||
const { current: { persistedSettingsCollection, settingsCollection } } = collectionsRef; |
||||
|
||||
const query = persistedSettingsCollection.find(); |
||||
|
||||
const syncCollectionsHandle = query.observe({ |
||||
added: (data) => settingsCollection.insert(data), |
||||
changed: (data) => settingsCollection.update(data._id, data), |
||||
removed: ({ _id }) => settingsCollection.remove(_id), |
||||
}); |
||||
|
||||
const addedQueue = []; |
||||
let addedActionTimer; |
||||
|
||||
const syncStateHandle = query.observe({ |
||||
added: (data) => { |
||||
addedQueue.push(data); |
||||
clearTimeout(addedActionTimer); |
||||
addedActionTimer = setTimeout(() => { |
||||
dispatch({ type: 'add', payload: addedQueue }); |
||||
}, 300); |
||||
}, |
||||
changed: (data) => { |
||||
dispatch({ type: 'change', payload: data }); |
||||
}, |
||||
removed: ({ _id }) => { |
||||
dispatch({ type: 'remove', payload: _id }); |
||||
}, |
||||
}); |
||||
|
||||
return () => { |
||||
syncCollectionsHandle && syncCollectionsHandle.stop(); |
||||
syncStateHandle && syncStateHandle.stop(); |
||||
clearTimeout(addedActionTimer); |
||||
}; |
||||
}, [isLoading, collectionsRef]); |
||||
|
||||
const updateTimersRef = useRef({}); |
||||
|
||||
const updateAtCollection = useMutableCallback(({ _id, ...data }) => { |
||||
const { current: { settingsCollection } } = collectionsRef; |
||||
const { current: updateTimers } = updateTimersRef; |
||||
clearTimeout(updateTimers[_id]); |
||||
updateTimers[_id] = setTimeout(() => { |
||||
settingsCollection.update(_id, { $set: data }); |
||||
}, 70); |
||||
}); |
||||
|
||||
const hydrate = useMutableCallback((changes) => { |
||||
changes.forEach(updateAtCollection); |
||||
dispatch({ type: 'hydrate', payload: changes }); |
||||
}); |
||||
|
||||
const isDisabled = useMutableCallback(({ blocked, enableQuery }) => { |
||||
if (blocked) { |
||||
return true; |
||||
} |
||||
|
||||
if (!enableQuery) { |
||||
return false; |
||||
} |
||||
|
||||
const { current: { settingsCollection } } = collectionsRef; |
||||
|
||||
const queries = [].concat(typeof enableQuery === 'string' ? JSON.parse(enableQuery) : enableQuery); |
||||
return !queries.every((query) => !!settingsCollection.findOne(query)); |
||||
}); |
||||
|
||||
const contextValue = useMemo(() => ({ |
||||
authorized: true, |
||||
loading: isLoading, |
||||
subscribers: subscribersRef.current, |
||||
stateRef, |
||||
hydrate, |
||||
isDisabled, |
||||
}), [ |
||||
isLoading, |
||||
hydrate, |
||||
isDisabled, |
||||
]); |
||||
|
||||
return <PrivilegedSettingsContext.Provider children={children} value={contextValue} />; |
||||
} |
||||
|
||||
function PrivilegedSettingsProvider({ children }) { |
||||
const hasPermission = useAtLeastOnePermission([ |
||||
'view-privileged-setting', |
||||
'edit-privileged-setting', |
||||
'manage-selected-settings', |
||||
]); |
||||
|
||||
if (!hasPermission) { |
||||
return children; |
||||
} |
||||
|
||||
return <AuthorizedPrivilegedSettingsProvider |
||||
cachedCollection={PrivateSettingsCachedCollection.get()} |
||||
children={children} |
||||
/>; |
||||
} |
||||
|
||||
export default PrivilegedSettingsProvider; |
||||
@ -1,25 +0,0 @@ |
||||
import React from 'react'; |
||||
|
||||
import { usePrivilegedSettingsGroup } from '../../contexts/PrivilegedSettingsContext'; |
||||
import { AssetsGroupPage } from './groups/AssetsGroupPage'; |
||||
import { OAuthGroupPage } from './groups/OAuthGroupPage'; |
||||
import { GenericGroupPage } from './groups/GenericGroupPage'; |
||||
import { GroupPage } from './GroupPage'; |
||||
|
||||
export function GroupSelector({ groupId }) { |
||||
const group = usePrivilegedSettingsGroup(groupId); |
||||
|
||||
if (!group) { |
||||
return <GroupPage.Skeleton />; |
||||
} |
||||
|
||||
if (groupId === 'Assets') { |
||||
return <AssetsGroupPage {...group} />; |
||||
} |
||||
|
||||
if (groupId === 'OAuth') { |
||||
return <OAuthGroupPage {...group} />; |
||||
} |
||||
|
||||
return <GenericGroupPage {...group} />; |
||||
} |
||||
@ -0,0 +1,32 @@ |
||||
import React, { FunctionComponent } from 'react'; |
||||
|
||||
import { GroupId } from '../../../definition/ISetting'; |
||||
import { useSettingStructure } from '../../contexts/SettingsContext'; |
||||
import AssetsGroupPage from './groups/AssetsGroupPage'; |
||||
import OAuthGroupPage from './groups/OAuthGroupPage'; |
||||
import GenericGroupPage from './groups/GenericGroupPage'; |
||||
import GroupPage from './GroupPage'; |
||||
|
||||
type GroupSelectorProps = { |
||||
groupId: GroupId; |
||||
}; |
||||
|
||||
const GroupSelector: FunctionComponent<GroupSelectorProps> = ({ groupId }) => { |
||||
const group = useSettingStructure(groupId); |
||||
|
||||
if (!group) { |
||||
return <GroupPage.Skeleton />; |
||||
} |
||||
|
||||
if (groupId === 'Assets') { |
||||
return <AssetsGroupPage {...group} />; |
||||
} |
||||
|
||||
if (groupId === 'OAuth') { |
||||
return <OAuthGroupPage {...group} />; |
||||
} |
||||
|
||||
return <GenericGroupPage {...group} />; |
||||
}; |
||||
|
||||
export default GroupSelector; |
||||
@ -1,17 +1,21 @@ |
||||
import React from 'react'; |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { GroupPage } from '../GroupPage'; |
||||
import GroupPage from '../GroupPage'; |
||||
import { Section } from '../Section'; |
||||
import { useEditableSettingsGroupSections } from '../../../contexts/EditableSettingsContext'; |
||||
|
||||
export function GenericGroupPage({ _id, sections, ...group }) { |
||||
function GenericGroupPage({ _id, ...group }) { |
||||
const sections = useEditableSettingsGroupSections(_id); |
||||
const solo = sections.length === 1; |
||||
|
||||
return <GroupPage _id={_id} {...group}> |
||||
{sections.map((sectionName) => <Section |
||||
key={sectionName} |
||||
key={sectionName || ''} |
||||
groupId={_id} |
||||
sectionName={sectionName} |
||||
solo={solo} |
||||
/>)} |
||||
</GroupPage>; |
||||
} |
||||
|
||||
export default memo(GenericGroupPage); |
||||
|
||||
@ -0,0 +1,65 @@ |
||||
import { createContext, useContext, useMemo } from 'react'; |
||||
import { useSubscription, Subscription, Unsubscribe } from 'use-subscription'; |
||||
|
||||
import { |
||||
ISetting, |
||||
SectionName, |
||||
SettingId, |
||||
GroupId, |
||||
} from '../../definition/ISetting'; |
||||
import { SettingsContextQuery } from './SettingsContext'; |
||||
|
||||
export interface IEditableSetting extends ISetting { |
||||
disabled: boolean; |
||||
changed: boolean; |
||||
} |
||||
|
||||
export type EditableSettingsContextQuery = SettingsContextQuery & { |
||||
changed?: boolean; |
||||
}; |
||||
|
||||
export type EditableSettingsContextValue = { |
||||
readonly queryEditableSetting: (_id: SettingId) => Subscription<IEditableSetting | undefined>; |
||||
readonly queryEditableSettings: (query: EditableSettingsContextQuery) => Subscription<IEditableSetting[]>; |
||||
readonly queryGroupSections: (_id: GroupId) => Subscription<SectionName[]>; |
||||
readonly dispatch: (changes: Partial<IEditableSetting>[]) => void; |
||||
}; |
||||
|
||||
export const EditableSettingsContext = createContext<EditableSettingsContextValue>({ |
||||
queryEditableSetting: () => ({ |
||||
getCurrentValue: (): undefined => undefined, |
||||
subscribe: (): Unsubscribe => (): void => undefined, |
||||
}), |
||||
queryEditableSettings: () => ({ |
||||
getCurrentValue: (): IEditableSetting[] => [], |
||||
subscribe: (): Unsubscribe => (): void => undefined, |
||||
}), |
||||
queryGroupSections: () => ({ |
||||
getCurrentValue: (): SectionName[] => [], |
||||
subscribe: (): Unsubscribe => (): void => undefined, |
||||
}), |
||||
dispatch: () => undefined, |
||||
}); |
||||
|
||||
export const useEditableSetting = (_id: SettingId): IEditableSetting | undefined => { |
||||
const { queryEditableSetting } = useContext(EditableSettingsContext); |
||||
|
||||
const subscription = useMemo(() => queryEditableSetting(_id), [queryEditableSetting, _id]); |
||||
return useSubscription(subscription); |
||||
}; |
||||
|
||||
export const useEditableSettings = (query?: EditableSettingsContextQuery): IEditableSetting[] => { |
||||
const { queryEditableSettings } = useContext(EditableSettingsContext); |
||||
const subscription = useMemo(() => queryEditableSettings(query ?? {}), [queryEditableSettings, query]); |
||||
return useSubscription(subscription); |
||||
}; |
||||
|
||||
export const useEditableSettingsGroupSections = (_id: SettingId): SectionName[] => { |
||||
const { queryGroupSections } = useContext(EditableSettingsContext); |
||||
|
||||
const subscription = useMemo(() => queryGroupSections(_id), [queryGroupSections, _id]); |
||||
return useSubscription(subscription); |
||||
}; |
||||
|
||||
export const useEditableSettingsDispatch = (): ((changes: Partial<IEditableSetting>[]) => void) => |
||||
useContext(EditableSettingsContext).dispatch; |
||||
@ -1,302 +0,0 @@ |
||||
import { useDebouncedCallback, useMutableCallback } from '@rocket.chat/fuselage-hooks'; |
||||
import { Tracker } from 'meteor/tracker'; |
||||
import { createContext, useContext, RefObject, useState, useEffect, useLayoutEffect, useMemo, useCallback } from 'react'; |
||||
import { useSubscription } from 'use-subscription'; |
||||
|
||||
import { useReactiveValue } from '../hooks/useReactiveValue'; |
||||
import { useBatchSettingsDispatch } from './SettingsContext'; |
||||
import { useToastMessageDispatch } from './ToastMessagesContext'; |
||||
import { useTranslation, useLoadLanguage } from './TranslationContext'; |
||||
import { useUser } from './UserContext'; |
||||
|
||||
export type PrivilegedSetting = object & { |
||||
_id: string; |
||||
type: string; |
||||
blocked: boolean; |
||||
enableQuery: unknown; |
||||
group: string; |
||||
section: string; |
||||
changed: boolean; |
||||
value: unknown; |
||||
packageValue: unknown; |
||||
packageEditor: unknown; |
||||
editor: unknown; |
||||
sorter: string; |
||||
i18nLabel: string; |
||||
disabled?: boolean; |
||||
update?: () => void; |
||||
reset?: () => void; |
||||
}; |
||||
|
||||
export type PrivilegedSettingsState = { |
||||
settings: PrivilegedSetting[]; |
||||
persistedSettings: PrivilegedSetting[]; |
||||
}; |
||||
|
||||
type EqualityFunction<T> = (a: T, b: T) => boolean; |
||||
|
||||
// TODO: split editing into another context
|
||||
type PrivilegedSettingsContextValue = { |
||||
authorized: boolean; |
||||
loading: boolean; |
||||
subscribers: Set<(state: PrivilegedSettingsState) => void>; |
||||
stateRef: RefObject<PrivilegedSettingsState>; |
||||
hydrate: (changes: any[]) => void; |
||||
isDisabled: (setting: PrivilegedSetting) => boolean; |
||||
}; |
||||
|
||||
export const PrivilegedSettingsContext = createContext<PrivilegedSettingsContextValue>({ |
||||
authorized: false, |
||||
loading: false, |
||||
subscribers: new Set<(state: PrivilegedSettingsState) => void>(), |
||||
stateRef: { |
||||
current: { |
||||
settings: [], |
||||
persistedSettings: [], |
||||
}, |
||||
}, |
||||
hydrate: () => undefined, |
||||
isDisabled: () => false, |
||||
}); |
||||
|
||||
export const usePrivilegedSettingsAuthorized = (): boolean => |
||||
useContext(PrivilegedSettingsContext).authorized; |
||||
|
||||
export const useIsPrivilegedSettingsLoading = (): boolean => |
||||
useContext(PrivilegedSettingsContext).loading; |
||||
|
||||
export const usePrivilegedSettingsGroups = (filter?: string): any => { |
||||
const { stateRef, subscribers } = useContext(PrivilegedSettingsContext); |
||||
const t = useTranslation(); |
||||
|
||||
const getCurrentValue = useCallback(() => { |
||||
const filterRegex = filter ? new RegExp(filter, 'i') : null; |
||||
|
||||
const filterPredicate = (setting: PrivilegedSetting): boolean => |
||||
!filterRegex || filterRegex.test(t(setting.i18nLabel || setting._id)); |
||||
|
||||
const groupIds = Array.from(new Set( |
||||
(stateRef.current?.persistedSettings ?? []) |
||||
.filter(filterPredicate) |
||||
.map((setting) => setting.group || setting._id), |
||||
)); |
||||
|
||||
return (stateRef.current?.persistedSettings ?? []) |
||||
.filter(({ type, group, _id }) => type === 'group' && groupIds.includes(group || _id)) |
||||
.sort((a, b) => t(a.i18nLabel || a._id).localeCompare(t(b.i18nLabel || b._id))); |
||||
}, [filter]); |
||||
|
||||
const subscribe = useCallback((cb) => { |
||||
const handleUpdate = (): void => { |
||||
cb(getCurrentValue()); |
||||
}; |
||||
|
||||
subscribers.add(handleUpdate); |
||||
|
||||
return (): void => { |
||||
subscribers.delete(handleUpdate); |
||||
}; |
||||
}, [getCurrentValue]); |
||||
|
||||
return useSubscription(useMemo(() => ({ |
||||
getCurrentValue, |
||||
subscribe, |
||||
}), [getCurrentValue, subscribe])); |
||||
}; |
||||
|
||||
const useSelector = <T>( |
||||
selector: (state: PrivilegedSettingsState) => T, |
||||
equalityFunction: EqualityFunction<T> = Object.is, |
||||
): T | null => { |
||||
const { subscribers, stateRef } = useContext(PrivilegedSettingsContext); |
||||
const [value, setValue] = useState<T | null>(() => (stateRef.current ? selector(stateRef.current) : null)); |
||||
|
||||
const handleUpdate = useMutableCallback((state: PrivilegedSettingsState) => { |
||||
const newValue = selector(state); |
||||
|
||||
if (!value || !equalityFunction(newValue, value)) { |
||||
setValue(newValue); |
||||
} |
||||
}); |
||||
|
||||
useEffect(() => { |
||||
subscribers.add(handleUpdate); |
||||
|
||||
return (): void => { |
||||
subscribers.delete(handleUpdate); |
||||
}; |
||||
}, [handleUpdate]); |
||||
|
||||
useLayoutEffect(() => { |
||||
handleUpdate(stateRef.current); |
||||
}); |
||||
|
||||
return value; |
||||
}; |
||||
|
||||
export const usePrivilegedSettingsGroup = (groupId: string): any => { |
||||
const group = useSelector((state) => state.settings.find(({ _id, type }) => _id === groupId && type === 'group')); |
||||
|
||||
const filterSettings = (settings: any[]): any[] => settings.filter(({ group }) => group === groupId); |
||||
|
||||
const changed = useSelector((state) => filterSettings(state.settings).some(({ changed }) => changed)); |
||||
const sections = useSelector((state) => Array.from(new Set(filterSettings(state.settings).map(({ section }) => section || ''))), (a, b) => a.length === b.length && a.join() === b.join()); |
||||
|
||||
const batchSetSettings = useBatchSettingsDispatch(); |
||||
const { stateRef, hydrate } = useContext(PrivilegedSettingsContext); |
||||
|
||||
const dispatchToastMessage = useToastMessageDispatch() as any; |
||||
const t = useTranslation() as (key: string, ...args: any[]) => string; |
||||
const loadLanguage = useLoadLanguage() as any; |
||||
const user = useUser() as any; |
||||
|
||||
const save = useMutableCallback(async () => { |
||||
const state = stateRef.current; |
||||
const settings = filterSettings(state?.settings ?? []); |
||||
|
||||
const changes = settings.filter(({ changed }) => changed) |
||||
.map(({ _id, value, editor }) => ({ _id, value, editor })); |
||||
|
||||
if (changes.length === 0) { |
||||
return; |
||||
} |
||||
|
||||
try { |
||||
await batchSetSettings(changes); |
||||
|
||||
if (changes.some(({ _id }) => _id === 'Language')) { |
||||
const lng = user?.language |
||||
|| changes.filter(({ _id }) => _id === 'Language').shift()?.value |
||||
|| 'en'; |
||||
|
||||
try { |
||||
await loadLanguage(lng); |
||||
dispatchToastMessage({ type: 'success', message: t('Settings_updated', { lng }) }); |
||||
} catch (error) { |
||||
dispatchToastMessage({ type: 'error', message: error }); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
dispatchToastMessage({ type: 'success', message: t('Settings_updated') }); |
||||
} catch (error) { |
||||
dispatchToastMessage({ type: 'error', message: error }); |
||||
} |
||||
}); |
||||
|
||||
const cancel = useMutableCallback(() => { |
||||
const state = stateRef.current; |
||||
const settings = filterSettings(state?.settings ?? []); |
||||
const persistedSettings = filterSettings(state?.persistedSettings ?? []); |
||||
|
||||
const changes = settings.filter(({ changed }) => changed) |
||||
.map((field) => { |
||||
const { _id, value, editor } = persistedSettings.find(({ _id }) => _id === field._id); |
||||
return { _id, value, editor, changed: false }; |
||||
}); |
||||
|
||||
hydrate(changes); |
||||
}); |
||||
|
||||
return group && { ...group, sections, changed, save, cancel }; |
||||
}; |
||||
|
||||
export const usePrivilegedSettingsSection = (groupId: string, sectionName?: string): any => { |
||||
sectionName = sectionName || ''; |
||||
|
||||
const filterSettings = (settings: any[]): any[] => |
||||
settings.filter(({ group, section }) => group === groupId && ((!sectionName && !section) || (sectionName === section))); |
||||
|
||||
const canReset = useSelector((state) => filterSettings(state.settings).some(({ value, packageValue }) => JSON.stringify(value) !== JSON.stringify(packageValue))); |
||||
const settingsIds = useSelector((state) => filterSettings(state.settings).map(({ _id }) => _id), (a, b) => a.length === b.length && a.join() === b.join()); |
||||
|
||||
const { stateRef, hydrate, isDisabled } = useContext(PrivilegedSettingsContext); |
||||
|
||||
const reset = useMutableCallback(() => { |
||||
const state = stateRef.current; |
||||
const settings = filterSettings(state?.settings ?? []) |
||||
.filter((setting) => Tracker.nonreactive(() => !isDisabled(setting))); // Ignore disabled settings
|
||||
const persistedSettings = filterSettings(state?.persistedSettings ?? []); |
||||
|
||||
const changes = settings.map((setting) => { |
||||
const { _id, value, packageValue, packageEditor } = persistedSettings.find(({ _id }) => _id === setting._id); |
||||
return { |
||||
_id, |
||||
value: packageValue, |
||||
editor: packageEditor, |
||||
changed: JSON.stringify(packageValue) !== JSON.stringify(value), |
||||
}; |
||||
}); |
||||
|
||||
hydrate(changes); |
||||
}); |
||||
|
||||
return { |
||||
name: sectionName, |
||||
canReset, |
||||
settings: settingsIds, |
||||
reset, |
||||
}; |
||||
}; |
||||
|
||||
export const usePrivilegedSettingActions = (persistedSetting: PrivilegedSetting | null | undefined): { |
||||
update: () => void; |
||||
reset: () => void; |
||||
} => { |
||||
const { hydrate } = useContext(PrivilegedSettingsContext); |
||||
|
||||
const update = useDebouncedCallback(({ value, editor }) => { |
||||
const changes = [{ |
||||
_id: persistedSetting?._id, |
||||
...value !== undefined && { value }, |
||||
...editor !== undefined && { editor }, |
||||
changed: JSON.stringify(persistedSetting?.value) !== JSON.stringify(value) || JSON.stringify(editor) !== JSON.stringify(persistedSetting?.editor), |
||||
}]; |
||||
|
||||
hydrate(changes); |
||||
}, 100, [hydrate, persistedSetting]) as () => void; |
||||
|
||||
const reset = useDebouncedCallback(() => { |
||||
const changes = [{ |
||||
_id: persistedSetting?._id, |
||||
value: persistedSetting?.packageValue, |
||||
editor: persistedSetting?.packageEditor, |
||||
changed: JSON.stringify(persistedSetting?.packageValue) !== JSON.stringify(persistedSetting?.value) || JSON.stringify(persistedSetting?.packageEditor) !== JSON.stringify(persistedSetting?.editor), |
||||
}]; |
||||
|
||||
hydrate(changes); |
||||
}, 100, [hydrate, persistedSetting]) as () => void; |
||||
|
||||
return { update, reset }; |
||||
}; |
||||
|
||||
export const usePrivilegedSettingDisabledState = (setting: PrivilegedSetting | null | undefined): boolean => { |
||||
const { isDisabled } = useContext(PrivilegedSettingsContext); |
||||
return useReactiveValue(() => (setting ? isDisabled(setting) : false), [setting?.blocked, setting?.enableQuery]) as unknown as boolean; |
||||
}; |
||||
|
||||
export const usePrivilegedSettingsSectionChangedState = (groupId: string, sectionName: string): boolean => |
||||
!!useSelector((state) => |
||||
state.settings.some(({ group, section, changed }) => |
||||
group === groupId && ((!sectionName && !section) || (sectionName === section)) && changed)); |
||||
|
||||
export const usePrivilegedSetting = (_id: string): PrivilegedSetting | null | undefined => { |
||||
const selectSetting = (settings: PrivilegedSetting[]): PrivilegedSetting | undefined => settings.find((setting) => setting._id === _id); |
||||
|
||||
const setting = useSelector((state) => selectSetting(state.settings)); |
||||
const persistedSetting = useSelector((state) => selectSetting(state.persistedSettings)); |
||||
|
||||
const { update, reset } = usePrivilegedSettingActions(persistedSetting); |
||||
const disabled = usePrivilegedSettingDisabledState(persistedSetting); |
||||
|
||||
if (!setting) { |
||||
return null; |
||||
} |
||||
|
||||
return { |
||||
...setting, |
||||
disabled, |
||||
update, |
||||
reset, |
||||
}; |
||||
}; |
||||
@ -1,24 +0,0 @@ |
||||
import { createContext, useCallback, useContext } from 'react'; |
||||
|
||||
import { useObservableValue } from '../hooks/useObservableValue'; |
||||
|
||||
export const SettingsContext = createContext({ |
||||
get: () => {}, |
||||
set: async () => {}, |
||||
batchSet: async () => {}, |
||||
}); |
||||
|
||||
export const useSetting = (name) => { |
||||
const { get } = useContext(SettingsContext); |
||||
return useObservableValue((listener) => get(name, listener)); |
||||
}; |
||||
|
||||
export const useSettingDispatch = (name) => { |
||||
const { set } = useContext(SettingsContext); |
||||
return useCallback((value) => set(name, value), [set, name]); |
||||
}; |
||||
|
||||
export const useBatchSettingsDispatch = () => { |
||||
const { batchSet } = useContext(SettingsContext); |
||||
return useCallback((entries) => batchSet(entries), []); |
||||
}; |
||||
@ -0,0 +1,66 @@ |
||||
import { createContext, useCallback, useContext, useMemo } from 'react'; |
||||
import { useSubscription, Subscription, Unsubscribe } from 'use-subscription'; |
||||
|
||||
import { |
||||
SettingId, |
||||
ISetting, |
||||
GroupId, |
||||
SectionName, |
||||
} from '../../definition/ISetting'; |
||||
|
||||
export type SettingsContextQuery = { |
||||
readonly _id?: SettingId[]; |
||||
readonly group?: GroupId; |
||||
readonly section?: SectionName; |
||||
} |
||||
|
||||
export type SettingsContextValue = { |
||||
readonly hasPrivateAccess: boolean; |
||||
readonly isLoading: boolean; |
||||
readonly querySetting: (_id: SettingId) => Subscription<ISetting | undefined>; |
||||
readonly querySettings: (query: SettingsContextQuery) => Subscription<ISetting[]>; |
||||
readonly dispatch: (changes: Partial<ISetting>[]) => Promise<void>; |
||||
} |
||||
|
||||
export const SettingsContext = createContext<SettingsContextValue>({ |
||||
hasPrivateAccess: false, |
||||
isLoading: false, |
||||
querySetting: () => ({ |
||||
getCurrentValue: (): undefined => undefined, |
||||
subscribe: (): Unsubscribe => (): void => undefined, |
||||
}), |
||||
querySettings: () => ({ |
||||
getCurrentValue: (): ISetting[] => [], |
||||
subscribe: (): Unsubscribe => (): void => undefined, |
||||
}), |
||||
dispatch: async () => undefined, |
||||
}); |
||||
|
||||
export const useIsPrivilegedSettingsContext = (): boolean => |
||||
useContext(SettingsContext).hasPrivateAccess; |
||||
|
||||
export const useIsSettingsContextLoading = (): boolean => |
||||
useContext(SettingsContext).isLoading; |
||||
|
||||
export const useSettingStructure = (_id: SettingId): ISetting | undefined => { |
||||
const { querySetting } = useContext(SettingsContext); |
||||
const subscription = useMemo(() => querySetting(_id), [querySetting, _id]); |
||||
return useSubscription(subscription); |
||||
}; |
||||
|
||||
export const useSetting = (_id: SettingId): unknown | undefined => |
||||
useSettingStructure(_id)?.value; |
||||
|
||||
export const useSettings = (query?: SettingsContextQuery): ISetting[] => { |
||||
const { querySettings } = useContext(SettingsContext); |
||||
const subscription = useMemo(() => querySettings(query ?? {}), [querySettings, query]); |
||||
return useSubscription(subscription); |
||||
}; |
||||
|
||||
export const useSettingsDispatch = (): ((changes: Partial<ISetting>[]) => Promise<void>) => |
||||
useContext(SettingsContext).dispatch; |
||||
|
||||
export const useSettingSetValue = <T>(_id: SettingId): ((value: T) => Promise<void>) => { |
||||
const dispatch = useSettingsDispatch(); |
||||
return useCallback((value: T) => dispatch([{ _id, value }]), [dispatch, _id]); |
||||
}; |
||||
@ -1,4 +1,7 @@ |
||||
declare module '@rocket.chat/fuselage-hooks' { |
||||
import { RefObject } from 'react'; |
||||
|
||||
export const useDebouncedCallback: (fn: (...args: any[]) => any, ms: number, deps: any[]) => (...args: any[]) => any; |
||||
export const useLazyRef: <T>(initializer: () => T) => RefObject<T>; |
||||
export const useMutableCallback: (fn: (...args: any[]) => any) => (...args: any[]) => any; |
||||
} |
||||
|
||||
@ -0,0 +1,30 @@ |
||||
import { Tracker } from 'meteor/tracker'; |
||||
import { useCallback, useMemo, useRef } from 'react'; |
||||
import { useSubscription } from 'use-subscription'; |
||||
import { Mongo } from 'meteor/mongo'; |
||||
|
||||
const allQuery = {}; |
||||
|
||||
export const useQuery = <T>(collection: Mongo.Collection<T>, query: object = allQuery, options?: object): T[] => { |
||||
const queryHandle = useMemo(() => collection.find(query, options), [collection, query, options]); |
||||
const resultRef = useRef<T[]>([]); |
||||
resultRef.current = Tracker.nonreactive(() => queryHandle.fetch()) as unknown as T[]; |
||||
|
||||
const subscribe = useCallback((cb) => { |
||||
const computation = Tracker.autorun(() => { |
||||
resultRef.current = queryHandle.fetch(); |
||||
cb(resultRef.current); |
||||
}); |
||||
|
||||
return (): void => { |
||||
computation.stop(); |
||||
}; |
||||
}, [queryHandle]); |
||||
|
||||
const subscription = useMemo(() => ({ |
||||
getCurrentValue: (): T[] => resultRef.current ?? [], |
||||
subscribe, |
||||
}), [subscribe]); |
||||
|
||||
return useSubscription(subscription); |
||||
}; |
||||
@ -0,0 +1,28 @@ |
||||
import { Tracker } from 'meteor/tracker'; |
||||
import { useCallback } from 'react'; |
||||
import { Subscription, Unsubscribe } from 'use-subscription'; |
||||
|
||||
interface ISubscriptionFactory<T> { |
||||
(...args: any[]): Subscription<T>; |
||||
} |
||||
|
||||
export const useReactiveSubscriptionFactory = <T>(fn: (...args: any[]) => T): ISubscriptionFactory<T> => |
||||
useCallback<ISubscriptionFactory<T>>((...args: any[]) => { |
||||
const fnWithArgs = (): T => fn(...args); |
||||
|
||||
return { |
||||
getCurrentValue: (): T => Tracker.nonreactive(fnWithArgs) as unknown as T, |
||||
subscribe: (callback): Unsubscribe => { |
||||
const computation = Tracker.autorun((c) => { |
||||
fnWithArgs(); |
||||
if (!c.firstRun) { |
||||
callback(); |
||||
} |
||||
}); |
||||
|
||||
return (): void => { |
||||
computation.stop(); |
||||
}; |
||||
}, |
||||
}; |
||||
}, [fn]); |
||||
@ -1,5 +1,5 @@ |
||||
import { CachedCollection } from '../../app/ui-cached-collection/client'; |
||||
import { Notifications } from '../../app/notifications/client'; |
||||
import { CachedCollection } from '../../../app/ui-cached-collection/client'; |
||||
import { Notifications } from '../../../app/notifications/client'; |
||||
|
||||
export class PrivateSettingsCachedCollection extends CachedCollection { |
||||
constructor() { |
||||
@ -0,0 +1,22 @@ |
||||
import { CachedCollection } from '../../../app/ui-cached-collection/client'; |
||||
|
||||
export class PublicSettingsCachedCollection extends CachedCollection { |
||||
constructor() { |
||||
super({ |
||||
name: 'public-settings', |
||||
eventType: 'onAll', |
||||
userRelated: false, |
||||
listenChangesForLoggedUsersOnly: true, |
||||
}); |
||||
} |
||||
|
||||
static instance: PublicSettingsCachedCollection; |
||||
|
||||
static get(): PublicSettingsCachedCollection { |
||||
if (!PublicSettingsCachedCollection.instance) { |
||||
PublicSettingsCachedCollection.instance = new PublicSettingsCachedCollection(); |
||||
} |
||||
|
||||
return PublicSettingsCachedCollection.instance; |
||||
} |
||||
} |
||||
@ -0,0 +1,137 @@ |
||||
import { useLazyRef, useMutableCallback } from '@rocket.chat/fuselage-hooks'; |
||||
import { Mongo } from 'meteor/mongo'; |
||||
import { Tracker } from 'meteor/tracker'; |
||||
import React, { useEffect, useMemo, FunctionComponent, useCallback } from 'react'; |
||||
|
||||
import { SettingId, GroupId } from '../../definition/ISetting'; |
||||
import { EditableSettingsContext, IEditableSetting, EditableSettingsContextValue } from '../contexts/EditableSettingsContext'; |
||||
import { useSettings, SettingsContextQuery } from '../contexts/SettingsContext'; |
||||
import { useReactiveSubscriptionFactory } from '../hooks/useReactiveSubscriptionFactory'; |
||||
|
||||
const defaultQuery: SettingsContextQuery = {}; |
||||
|
||||
type EditableSettingsProviderProps = { |
||||
readonly query: SettingsContextQuery; |
||||
}; |
||||
|
||||
const EditableSettingsProvider: FunctionComponent<EditableSettingsProviderProps> = ({ |
||||
children, |
||||
query = defaultQuery, |
||||
}) => { |
||||
const settingsCollectionRef = useLazyRef(() => new Mongo.Collection<any>(null)); |
||||
const persistedSettings = useSettings(query); |
||||
|
||||
useEffect(() => { |
||||
if (!settingsCollectionRef.current) { |
||||
return; |
||||
} |
||||
|
||||
settingsCollectionRef.current.remove({ _id: { $nin: persistedSettings.map(({ _id }) => _id) } }); |
||||
for (const setting of persistedSettings) { |
||||
settingsCollectionRef.current.upsert(setting._id, { ...setting }); |
||||
} |
||||
}, [persistedSettings, settingsCollectionRef]); |
||||
|
||||
const queryEditableSetting = useReactiveSubscriptionFactory( |
||||
useCallback( |
||||
(_id: SettingId): IEditableSetting | undefined => { |
||||
if (!settingsCollectionRef.current) { |
||||
return; |
||||
} |
||||
|
||||
const editableSetting = settingsCollectionRef.current.findOne(_id); |
||||
|
||||
if (editableSetting.blocked) { |
||||
return { ...editableSetting, disabled: true }; |
||||
} |
||||
|
||||
if (!editableSetting.enableQuery) { |
||||
return { ...editableSetting, disabled: false }; |
||||
} |
||||
|
||||
const queries = [].concat(typeof editableSetting.enableQuery === 'string' |
||||
? JSON.parse(editableSetting.enableQuery) |
||||
: editableSetting.enableQuery); |
||||
return { |
||||
...editableSetting, |
||||
disabled: !queries.every((query) => (settingsCollectionRef.current?.find(query)?.count() ?? 0) > 0), |
||||
}; |
||||
}, |
||||
[settingsCollectionRef], |
||||
), |
||||
); |
||||
|
||||
const queryEditableSettings = useReactiveSubscriptionFactory( |
||||
useCallback( |
||||
(query = {}) => settingsCollectionRef.current?.find({ |
||||
...('_id' in query) && { _id: { $in: query._id } }, |
||||
...('group' in query) && { group: query.group }, |
||||
...('section' in query) && ( |
||||
query.section |
||||
? { section: query.section } |
||||
: { |
||||
$or: [ |
||||
{ section: { $exists: false } }, |
||||
{ section: null }, |
||||
], |
||||
} |
||||
), |
||||
...('changed' in query) && { changed: query.changed }, |
||||
}, { |
||||
sort: { |
||||
section: 1, |
||||
sorter: 1, |
||||
i18nLabel: 1, |
||||
}, |
||||
}).fetch() ?? [], |
||||
[settingsCollectionRef], |
||||
), |
||||
); |
||||
|
||||
const queryGroupSections = useReactiveSubscriptionFactory( |
||||
useCallback( |
||||
(_id: GroupId) => Array.from(new Set( |
||||
(settingsCollectionRef.current?.find({ |
||||
group: _id, |
||||
}, { |
||||
fields: { |
||||
section: 1, |
||||
}, |
||||
sort: { |
||||
section: 1, |
||||
sorter: 1, |
||||
i18nLabel: 1, |
||||
}, |
||||
}).fetch() ?? []).map(({ section }) => section), |
||||
)), |
||||
[settingsCollectionRef], |
||||
), |
||||
); |
||||
|
||||
const dispatch = useMutableCallback((changes: Partial<IEditableSetting>[]): void => { |
||||
for (const { _id, ...data } of changes) { |
||||
if (!_id) { |
||||
continue; |
||||
} |
||||
|
||||
settingsCollectionRef.current?.update(_id, { $set: data }); |
||||
} |
||||
Tracker.flush(); |
||||
}); |
||||
|
||||
const contextValue = useMemo<EditableSettingsContextValue>(() => ({ |
||||
queryEditableSetting, |
||||
queryEditableSettings, |
||||
queryGroupSections, |
||||
dispatch, |
||||
}), [ |
||||
queryEditableSetting, |
||||
queryEditableSettings, |
||||
queryGroupSections, |
||||
dispatch, |
||||
]); |
||||
|
||||
return <EditableSettingsContext.Provider children={children} value={contextValue} />; |
||||
}; |
||||
|
||||
export default EditableSettingsProvider; |
||||
@ -1,33 +0,0 @@ |
||||
import React from 'react'; |
||||
|
||||
import { settings } from '../../app/settings/client'; |
||||
import { SettingsContext } from '../contexts/SettingsContext'; |
||||
import { createObservableFromReactive } from './createObservableFromReactive'; |
||||
|
||||
const contextValue = { |
||||
get: createObservableFromReactive((name) => settings.get(name)), |
||||
set: (name, value) => new Promise((resolve, reject) => { |
||||
settings.set(name, value, (error, result) => { |
||||
if (error) { |
||||
reject(error); |
||||
return; |
||||
} |
||||
|
||||
resolve(result); |
||||
}); |
||||
}), |
||||
batchSet: (entries) => new Promise((resolve, reject) => { |
||||
settings.batchSet(entries, (error, result) => { |
||||
if (error) { |
||||
reject(error); |
||||
return; |
||||
} |
||||
|
||||
resolve(result); |
||||
}); |
||||
}), |
||||
}; |
||||
|
||||
export function SettingsProvider({ children }) { |
||||
return <SettingsContext.Provider children={children} value={contextValue} />; |
||||
} |
||||
@ -0,0 +1,113 @@ |
||||
import { Tracker } from 'meteor/tracker'; |
||||
import React, { useCallback, useEffect, useMemo, useState, FunctionComponent } from 'react'; |
||||
|
||||
import { useMethod } from '../contexts/ServerContext'; |
||||
import { SettingsContext, SettingsContextValue } from '../contexts/SettingsContext'; |
||||
import { useReactiveSubscriptionFactory } from '../hooks/useReactiveSubscriptionFactory'; |
||||
import { PrivateSettingsCachedCollection } from '../lib/settings/PrivateSettingsCachedCollection'; |
||||
import { PublicSettingsCachedCollection } from '../lib/settings/PublicSettingsCachedCollection'; |
||||
import { useAtLeastOnePermission } from '../contexts/AuthorizationContext'; |
||||
|
||||
type SettingsProviderProps = { |
||||
readonly privileged?: boolean; |
||||
}; |
||||
|
||||
const SettingsProvider: FunctionComponent<SettingsProviderProps> = ({ |
||||
children, |
||||
privileged = false, |
||||
}) => { |
||||
const hasPrivilegedPermission = useAtLeastOnePermission([ |
||||
'view-privileged-setting', |
||||
'edit-privileged-setting', |
||||
'manage-selected-settings', |
||||
]); |
||||
|
||||
const hasPrivateAccess = privileged && hasPrivilegedPermission; |
||||
|
||||
const cachedCollection = useMemo(() => ( |
||||
hasPrivateAccess |
||||
? PrivateSettingsCachedCollection.get() |
||||
: PublicSettingsCachedCollection.get() |
||||
), [hasPrivateAccess]); |
||||
|
||||
const [isLoading, setLoading] = useState(() => Tracker.nonreactive(() => !cachedCollection.ready.get())); |
||||
|
||||
useEffect(() => { |
||||
let mounted = true; |
||||
|
||||
const initialize = async (): Promise<void> => { |
||||
if (!Tracker.nonreactive(() => cachedCollection.ready.get())) { |
||||
await cachedCollection.init(); |
||||
} |
||||
|
||||
if (!mounted) { |
||||
return; |
||||
} |
||||
|
||||
setLoading(false); |
||||
}; |
||||
|
||||
initialize(); |
||||
|
||||
return (): void => { |
||||
mounted = false; |
||||
}; |
||||
}, [cachedCollection]); |
||||
|
||||
const querySetting = useReactiveSubscriptionFactory( |
||||
useCallback( |
||||
(_id) => ({ ...cachedCollection.collection.findOne(_id) }), |
||||
[cachedCollection], |
||||
), |
||||
); |
||||
|
||||
const querySettings = useReactiveSubscriptionFactory( |
||||
useCallback( |
||||
(query = {}) => cachedCollection.collection.find({ |
||||
...('_id' in query) && { _id: { $in: query._id } }, |
||||
...('group' in query) && { group: query.group }, |
||||
...('section' in query) && ( |
||||
query.section |
||||
? { section: query.section } |
||||
: { |
||||
$or: [ |
||||
{ section: { $exists: false } }, |
||||
{ section: null }, |
||||
], |
||||
} |
||||
), |
||||
}, { |
||||
sort: { |
||||
section: 1, |
||||
sorter: 1, |
||||
i18nLabel: 1, |
||||
}, |
||||
}).fetch(), |
||||
[cachedCollection], |
||||
), |
||||
); |
||||
|
||||
const saveSettings = useMethod('saveSettings'); |
||||
const dispatch = useCallback((changes) => saveSettings(changes), [saveSettings]); |
||||
|
||||
const contextValue = useMemo<SettingsContextValue>(() => ({ |
||||
hasPrivateAccess, |
||||
isLoading, |
||||
querySetting, |
||||
querySettings, |
||||
dispatch, |
||||
}), [ |
||||
hasPrivateAccess, |
||||
isLoading, |
||||
querySetting, |
||||
querySettings, |
||||
dispatch, |
||||
]); |
||||
|
||||
return <SettingsContext.Provider |
||||
children={children} |
||||
value={contextValue} |
||||
/>; |
||||
}; |
||||
|
||||
export default SettingsProvider; |
||||
@ -0,0 +1,42 @@ |
||||
export type SettingId = string | Mongo.ObjectID; |
||||
export type GroupId = SettingId; |
||||
export type SectionName = string; |
||||
|
||||
export enum SettingType { |
||||
BOOLEAN = 'boolean', |
||||
STRING = 'string', |
||||
RELATIVE_URL = 'relativeUrl', |
||||
PASSWORD = 'password', |
||||
INT = 'int', |
||||
SELECT = 'select', |
||||
MULTI_SELECT = 'multiSelect', |
||||
LANGUAGE = 'language', |
||||
COLOR = 'color', |
||||
FONT = 'font', |
||||
CODE = 'code', |
||||
ACTION = 'action', |
||||
ASSET = 'asset', |
||||
ROOM_PICK = 'roomPick', |
||||
GROUP = 'group', |
||||
} |
||||
|
||||
export enum SettingEditor { |
||||
COLOR = 'color', |
||||
EXPRESSION = 'expression' |
||||
} |
||||
|
||||
export interface ISetting { |
||||
_id: SettingId; |
||||
type: SettingType; |
||||
public: boolean; |
||||
group?: GroupId; |
||||
section?: SectionName; |
||||
i18nLabel: string; |
||||
value: unknown; |
||||
packageValue: unknown; |
||||
editor?: SettingEditor; |
||||
packageEditor?: SettingEditor; |
||||
blocked: boolean; |
||||
enableQuery?: string | Mongo.ObjectID | Mongo.Query<any> | Mongo.QueryWithModifiers<any>; |
||||
sorter?: number; |
||||
} |
||||
Loading…
Reference in new issue