[IMPROVE] Performance editing Admin settings (#17916)

pull/17949/head
Tasso Evangelista 6 years ago committed by GitHub
parent cc1ed29852
commit d73c7a7219
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 13
      app/settings/client/lib/settings.ts
  2. 6
      client/admin/AdministrationRouter.js
  3. 217
      client/admin/PrivilegedSettingsProvider.js
  4. 87
      client/admin/settings/GroupPage.js
  5. 2
      client/admin/settings/GroupPage.stories.js
  6. 25
      client/admin/settings/GroupSelector.js
  7. 2
      client/admin/settings/GroupSelector.stories.js
  8. 32
      client/admin/settings/GroupSelector.tsx
  9. 54
      client/admin/settings/Section.js
  10. 56
      client/admin/settings/Setting.js
  11. 11
      client/admin/settings/SettingsRoute.js
  12. 10
      client/admin/settings/groups/AssetsGroupPage.js
  13. 12
      client/admin/settings/groups/GenericGroupPage.js
  14. 10
      client/admin/settings/groups/OAuthGroupPage.js
  15. 58
      client/admin/sidebar/AdminSidebar.js
  16. 2
      client/admin/users/UserInfo.js
  17. 65
      client/contexts/EditableSettingsContext.ts
  18. 302
      client/contexts/PrivilegedSettingsContext.ts
  19. 24
      client/contexts/SettingsContext.js
  20. 66
      client/contexts/SettingsContext.ts
  21. 3
      client/fuselage-hooks.d.ts
  22. 30
      client/hooks/useQuery.ts
  23. 28
      client/hooks/useReactiveSubscriptionFactory.ts
  24. 4
      client/lib/settings/PrivateSettingsCachedCollection.ts
  25. 22
      client/lib/settings/PublicSettingsCachedCollection.ts
  26. 19
      client/meteor.d.ts
  27. 137
      client/providers/EditableSettingsProvider.tsx
  28. 2
      client/providers/MeteorProvider.js
  29. 33
      client/providers/SettingsProvider.js
  30. 113
      client/providers/SettingsProvider.tsx
  31. 4
      client/views/setupWizard/steps/FinalStep.js
  32. 6
      client/views/setupWizard/steps/RegisterServerStep.js
  33. 8
      client/views/setupWizard/steps/SettingsBasedStep.js
  34. 42
      definition/ISetting.ts

@ -1,20 +1,13 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveDict } from 'meteor/reactive-dict';
import { CachedCollection } from '../../../ui-cached-collection';
import { PublicSettingsCachedCollection } from '../../../../client/lib/settings/PublicSettingsCachedCollection';
import { SettingsBase, SettingValue } from '../../lib/settings';
const cachedCollection = new CachedCollection({
name: 'public-settings',
eventType: 'onAll',
userRelated: false,
listenChangesForLoggedUsersOnly: true,
});
class Settings extends SettingsBase {
cachedCollection = cachedCollection
cachedCollection = PublicSettingsCachedCollection.get()
collection = cachedCollection.collection;
collection = PublicSettingsCachedCollection.get().collection;
dict = new ReactiveDict<any>('settings');

@ -1,17 +1,17 @@
import React, { lazy, useMemo, Suspense } from 'react';
import SettingsProvider from '../providers/SettingsProvider';
import AdministrationLayout from './AdministrationLayout';
import PrivilegedSettingsProvider from './PrivilegedSettingsProvider';
import PageSkeleton from './PageSkeleton';
function AdministrationRouter({ lazyRouteComponent, ...props }) {
const LazyRouteComponent = useMemo(() => lazy(lazyRouteComponent), [lazyRouteComponent]);
return <AdministrationLayout>
<PrivilegedSettingsProvider>
<SettingsProvider privileged>
<Suspense fallback={<PageSkeleton />}>
<LazyRouteComponent {...props} />
</Suspense>
</PrivilegedSettingsProvider>
</SettingsProvider>
</AdministrationLayout>;
}

@ -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,13 +1,80 @@
import { Accordion, Box, Button, ButtonGroup, Skeleton } from '@rocket.chat/fuselage';
import React, { useMemo } from 'react';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import React, { useMemo, memo } from 'react';
import Page from '../../components/basic/Page';
import { useTranslation } from '../../contexts/TranslationContext';
import { useEditableSettingsDispatch, useEditableSettings } from '../../contexts/EditableSettingsContext';
import { useSettingsDispatch, useSettings } from '../../contexts/SettingsContext';
import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext';
import { useTranslation, useLoadLanguage } from '../../contexts/TranslationContext';
import { useUser } from '../../contexts/UserContext';
import { Section } from './Section';
const style = { margin: '0 auto', width: '100%', maxWidth: '590px' };
export function GroupPage({ children, headerButtons, save, cancel, _id, i18nLabel, i18nDescription, changed }) {
function GroupPage({ children, headerButtons, _id, i18nLabel, i18nDescription }) {
const changedEditableSettings = useEditableSettings(useMemo(() => ({
group: _id,
changed: true,
}), [_id]));
const originalSettings = useSettings(useMemo(() => ({
_id: changedEditableSettings.map(({ _id }) => _id),
}), [changedEditableSettings]));
const dispatch = useSettingsDispatch();
const dispatchToastMessage = useToastMessageDispatch();
const t = useTranslation();
const loadLanguage = useLoadLanguage();
const user = useUser();
const save = useMutableCallback(async () => {
const changes = changedEditableSettings
.map(({ _id, value, editor }) => ({ _id, value, editor }));
if (changes.length === 0) {
return;
}
try {
await dispatch(changes);
if (changes.some(({ _id }) => _id === 'Language')) {
const lng = user?.language
|| changes.filter(({ _id }) => _id === 'Language').shift()?.value
|| 'en';
await loadLanguage(lng);
dispatchToastMessage({ type: 'success', message: t('Settings_updated', { lng }) });
return;
}
dispatchToastMessage({ type: 'success', message: t('Settings_updated') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
});
const dispatchToEditing = useEditableSettingsDispatch();
const cancel = useMutableCallback(() => {
dispatchToEditing(
changedEditableSettings
.map(({ _id }) => originalSettings.find((setting) => setting._id === _id))
.map((setting) => {
if (!setting) {
return;
}
return {
_id: setting._id,
value: setting.value,
editor: setting.editor,
changed: false,
};
})
.filter(Boolean),
);
});
const handleSubmit = (event) => {
event.preventDefault();
@ -34,11 +101,11 @@ export function GroupPage({ children, headerButtons, save, cancel, _id, i18nLabe
return <Page is='form' action='#' method='post' onSubmit={handleSubmit}>
<Page.Header title={t(i18nLabel)}>
<ButtonGroup>
{changed && <Button danger primary type='reset' onClick={handleCancelClick}>{t('Cancel')}</Button>}
{changedEditableSettings.length > 0 && <Button danger primary type='reset' onClick={handleCancelClick}>{t('Cancel')}</Button>}
<Button
children={t('Save_changes')}
className='save'
disabled={!changed}
disabled={changedEditableSettings.length === 0}
primary
type='submit'
onClick={handleSaveClick}
@ -48,7 +115,7 @@ export function GroupPage({ children, headerButtons, save, cancel, _id, i18nLabe
</Page.Header>
<Page.ScrollableContentWithShadow>
<Box style={style}>
<Box marginBlock='none' marginInline='auto' width='full' maxWidth='x580'>
{t.has(i18nDescription) && <Box is='p' color='hint' fontScale='p1'>{t(i18nDescription)}</Box>}
<Accordion className='page-settings'>
@ -59,7 +126,7 @@ export function GroupPage({ children, headerButtons, save, cancel, _id, i18nLabe
</Page>;
}
export function GroupPageSkeleton() {
function GroupPageSkeleton() {
const t = useTranslation();
return <Page>
@ -89,4 +156,6 @@ export function GroupPageSkeleton() {
</Page>;
}
GroupPage.Skeleton = GroupPageSkeleton;
export default Object.assign(memo(GroupPage), {
Skeleton: GroupPageSkeleton,
});

@ -1,6 +1,6 @@
import React from 'react';
import { GroupPage } from './GroupPage';
import GroupPage from './GroupPage';
export default {
title: 'admin/settings/GroupPage',

@ -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} />;
}

@ -1,6 +1,6 @@
import React from 'react';
import { GroupSelector } from './GroupSelector';
import GroupSelector from './GroupSelector';
export default {
title: 'admin/settings/GroupSelector',

@ -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,37 +1,67 @@
import { Accordion, Box, Button, FieldGroup, Skeleton } from '@rocket.chat/fuselage';
import React from 'react';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import React, { useMemo } from 'react';
import {
usePrivilegedSettingsSection,
usePrivilegedSettingsSectionChangedState,
} from '../../contexts/PrivilegedSettingsContext';
useEditableSettings,
useEditableSettingsDispatch,
} from '../../contexts/EditableSettingsContext';
import { useTranslation } from '../../contexts/TranslationContext';
import { Setting } from './Setting';
export function Section({ children, groupId, hasReset = true, help, sectionName, solo }) {
const section = usePrivilegedSettingsSection(groupId, sectionName);
const changed = usePrivilegedSettingsSectionChangedState(groupId, sectionName);
const editableSettings = useEditableSettings(useMemo(() => ({
group: groupId,
section: sectionName,
}), [groupId, sectionName]));
const changed = useMemo(
() => editableSettings.some(({ changed }) => changed),
[editableSettings],
);
const canReset = useMemo(
() => editableSettings.some(({ value, packageValue }) => JSON.stringify(value) !== JSON.stringify(packageValue)),
[editableSettings],
);
const dispatch = useEditableSettingsDispatch();
const reset = useMutableCallback(() => {
dispatch(
editableSettings
.filter(({ disabled }) => !disabled)
.map(({ _id, value, packageValue, editor, packageEditor }) => ({
_id,
value: packageValue,
editor: packageEditor,
changed:
JSON.stringify(value) !== JSON.stringify(packageValue)
|| JSON.stringify(editor) !== JSON.stringify(packageEditor),
})),
);
});
const t = useTranslation();
const handleResetSectionClick = () => {
section.reset();
reset();
};
return <Accordion.Item
data-qa-section={sectionName}
noncollapsible={solo || !section.name}
title={section.name && t(section.name)}
noncollapsible={solo || !sectionName}
title={sectionName && t(sectionName)}
>
{help && <Box is='p' color='hint' fontScale='p1'>{help}</Box>}
<FieldGroup>
{section.settings.map((settingId) => <Setting key={settingId} settingId={settingId} sectionChanged={changed} />)}
{editableSettings.map((setting) => <Setting key={setting} settingId={setting._id} sectionChanged={changed} />)}
{hasReset && section.canReset && <Button
{hasReset && canReset && <Button
children={t('Reset_section_settings')}
danger
data-section={section.name}
data-section={sectionName}
ghost
onClick={handleResetSectionClick}
/>}

@ -1,8 +1,9 @@
import { Callout, Field, Flex, InputBox, Margins, Skeleton } from '@rocket.chat/fuselage';
import React, { memo, useEffect, useMemo, useState, useCallback } from 'react';
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import MarkdownText from '../../components/basic/MarkdownText';
import { usePrivilegedSetting } from '../../contexts/PrivilegedSettingsContext';
import { useEditableSetting, useEditableSettingsDispatch } from '../../contexts/EditableSettingsContext';
import { useTranslation } from '../../contexts/TranslationContext';
import { GenericSettingInput } from './inputs/GenericSettingInput';
import { BooleanSettingInput } from './inputs/BooleanSettingInput';
@ -19,6 +20,7 @@ import { CodeSettingInput } from './inputs/CodeSettingInput';
import { ActionSettingInput } from './inputs/ActionSettingInput';
import { AssetSettingInput } from './inputs/AssetSettingInput';
import { RoomPickSettingInput } from './inputs/RoomPickSettingInput';
import { useSettingStructure } from '../../contexts/SettingsContext';
export const MemoizedSetting = memo(function MemoizedSetting({
type,
@ -63,28 +65,38 @@ export const MemoizedSetting = memo(function MemoizedSetting({
});
export function Setting({ settingId, sectionChanged }) {
const {
value: contextValue,
editor: contextEditor,
packageEditor,
update,
reset,
...setting
} = usePrivilegedSetting(settingId);
const setting = useEditableSetting(settingId);
const persistedSetting = useSettingStructure(settingId);
const dispatch = useEditableSettingsDispatch();
const update = useDebouncedCallback(({ value, editor }) => {
if (!persistedSetting) {
return;
}
dispatch([{
_id: persistedSetting._id,
...value !== undefined && { value },
...editor !== undefined && { editor },
changed:
JSON.stringify(persistedSetting.value) !== JSON.stringify(value)
|| JSON.stringify(persistedSetting.editor) !== JSON.stringify(editor),
}]);
}, 230, [persistedSetting, dispatch]);
const t = useTranslation();
const [value, setValue] = useState(contextValue);
const [editor, setEditor] = useState(contextEditor);
const [value, setValue] = useState(setting.value);
const [editor, setEditor] = useState(setting.editor);
useEffect(() => {
setValue(contextValue);
}, [contextValue]);
setValue(setting.value);
}, [setting.value]);
useEffect(() => {
setEditor(contextEditor);
}, [contextEditor]);
setEditor(setting.editor);
}, [setting.editor]);
const onChangeValue = useCallback((value) => {
setValue(value);
@ -97,10 +109,13 @@ export function Setting({ settingId, sectionChanged }) {
}, [update]);
const onResetButtonClick = useCallback(() => {
setValue(contextValue);
setEditor(contextEditor);
reset();
}, [contextValue, contextEditor, reset]);
setValue(setting.value);
setEditor(setting.editor);
update({
value: persistedSetting.packageValue,
editor: persistedSetting.packageEditor,
});
}, [setting.value, setting.editor, update, persistedSetting]);
const {
_id,
@ -108,6 +123,7 @@ export function Setting({ settingId, sectionChanged }) {
disableReset,
readonly,
type,
packageEditor,
packageValue,
i18nLabel,
i18nDescription,

@ -1,12 +1,13 @@
import React from 'react';
import { usePrivilegedSettingsAuthorized } from '../../contexts/PrivilegedSettingsContext';
import { useRouteParameter } from '../../contexts/RouterContext';
import { GroupSelector } from './GroupSelector';
import { useIsPrivilegedSettingsContext } from '../../contexts/SettingsContext';
import NotAuthorizedPage from '../NotAuthorizedPage';
import EditableSettingsProvider from '../../providers/EditableSettingsProvider';
import GroupSelector from './GroupSelector';
export function SettingsRoute() {
const hasPermission = usePrivilegedSettingsAuthorized();
const hasPermission = useIsPrivilegedSettingsContext();
const groupId = useRouteParameter('group');
@ -14,7 +15,9 @@ export function SettingsRoute() {
return <NotAuthorizedPage />;
}
return <GroupSelector groupId={groupId} />;
return <EditableSettingsProvider>
<GroupSelector groupId={groupId} />
</EditableSettingsProvider>;
}
export default SettingsRoute;

@ -1,13 +1,15 @@
import { Button } from '@rocket.chat/fuselage';
import React from 'react';
import React, { memo } from 'react';
import { useEditableSettingsGroupSections } from '../../../contexts/EditableSettingsContext';
import { useMethod } from '../../../contexts/ServerContext';
import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext';
import { useTranslation } from '../../../contexts/TranslationContext';
import { GroupPage } from '../GroupPage';
import GroupPage from '../GroupPage';
import { Section } from '../Section';
export function AssetsGroupPage({ _id, sections, ...group }) {
function AssetsGroupPage({ _id, ...group }) {
const sections = useEditableSettingsGroupSections(_id);
const solo = sections.length === 1;
const t = useTranslation();
@ -35,3 +37,5 @@ export function AssetsGroupPage({ _id, sections, ...group }) {
/>)}
</GroupPage>;
}
export default memo(AssetsGroupPage);

@ -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);

@ -1,15 +1,17 @@
import { Button } from '@rocket.chat/fuselage';
import React from 'react';
import React, { memo } from 'react';
import s from 'underscore.string';
import { useEditableSettingsGroupSections } from '../../../contexts/EditableSettingsContext';
import { useModal } from '../../../contexts/ModalContext';
import { useAbsoluteUrl, useMethod } from '../../../contexts/ServerContext';
import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext';
import { useTranslation } from '../../../contexts/TranslationContext';
import { GroupPage } from '../GroupPage';
import GroupPage from '../GroupPage';
import { Section } from '../Section';
export function OAuthGroupPage({ _id, sections, ...group }) {
function OAuthGroupPage({ _id, ...group }) {
const sections = useEditableSettingsGroupSections(_id);
const solo = sections.length === 1;
const t = useTranslation();
@ -105,3 +107,5 @@ export function OAuthGroupPage({ _id, sections, ...group }) {
})}
</GroupPage>;
}
export default memo(OAuthGroupPage);

@ -4,13 +4,14 @@ import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import React, { useCallback, useState, useMemo, useEffect } from 'react';
import { menu, SideNav, Layout } from '../../../app/ui-utils/client';
import { SettingType } from '../../../definition/ISetting';
import { useReactiveValue } from '../../hooks/useReactiveValue';
import { useSettings } from '../../contexts/SettingsContext';
import { useTranslation } from '../../contexts/TranslationContext';
import { useRoutePath, useCurrentRoute } from '../../contexts/RouterContext';
import { useAtLeastOnePermission } from '../../contexts/AuthorizationContext';
import SettingsProvider from '../../providers/SettingsProvider';
import { sidebarItems } from '../sidebarItems';
import PrivilegedSettingsProvider from '../PrivilegedSettingsProvider';
import { usePrivilegedSettingsGroups } from '../../contexts/PrivilegedSettingsContext';
const SidebarItem = React.memo(({ permissionGranted, pathGroup, href, icon, label, currentPath }) => {
const params = useMemo(() => ({ group: pathGroup }), [pathGroup]);
@ -79,12 +80,57 @@ const AdminSidebarPages = React.memo(({ currentPath }) => {
</Box>;
});
const useSettingsGroups = (filter) => {
const settings = useSettings();
const t = useTranslation();
const filterPredicate = useMemo(() => {
if (!filter) {
return () => true;
}
const getMatchableStrings = (setting) => [
setting.i18nLabel && t(setting.i18nLabel),
t(setting._id),
setting._id,
].filter(Boolean);
try {
const filterRegex = new RegExp(filter, 'i');
return (setting) =>
getMatchableStrings(setting).some((text) => filterRegex.test(text));
} catch (e) {
return (setting) =>
getMatchableStrings(setting).some((text) => text.slice(0, filter.length) === filter);
}
}, [filter, t]);
return useMemo(() => {
const groupIds = Array.from(new Set(
settings
.filter(filterPredicate)
.map((setting) => {
if (setting.type === SettingType.GROUP) {
return setting._id;
}
return setting.group;
}),
));
return settings
.filter(({ type, group, _id }) => type === SettingType.GROUP && groupIds.includes(group || _id))
.sort((a, b) => t(a.i18nLabel || a._id).localeCompare(t(b.i18nLabel || b._id)));
}, [settings, filterPredicate, t]);
};
const AdminSidebarSettings = ({ currentPath }) => {
const t = useTranslation();
const [filter, setFilter] = useState('');
const handleChange = useCallback((e) => setFilter(e.currentTarget.value), []);
const groups = usePrivilegedSettingsGroups(useDebouncedValue(filter, 400));
const groups = useSettingsGroups(useDebouncedValue(filter, 400));
const isLoadingGroups = false; // TODO: get from PrivilegedSettingsContext
return <Box is='section' display='flex' flexDirection='column' flexShrink={0} pb='x24'>
@ -134,10 +180,10 @@ export default React.memo(function AdminSidebar() {
if (!currentPath.startsWith('/admin/')) {
SideNav.closeFlex();
}
}, [currentRoute]);
}, [currentRoute, currentPath]);
// TODO: uplift this provider
return <PrivilegedSettingsProvider>
return <SettingsProvider privileged>
<Box display='flex' flexDirection='column' h='100vh'>
<Box is='header' pb='x16' pi='x24' display='flex' flexDirection='row' alignItems='center' justifyContent='space-between'>
<Box color='neutral-800' fontSize='p1' fontWeight='p1' fontWeight='p1' flexShrink={1} withTruncatedText>{t('Administration')}</Box>
@ -150,5 +196,5 @@ export default React.memo(function AdminSidebar() {
</Box>
</Scrollable>
</Box>
</PrivilegedSettingsProvider>;
</SettingsProvider>;
});

@ -70,7 +70,7 @@ export function UserInfo({ data, onChange, ...props }) {
</Box>
<UserInfoActions isActive={data.active} isAdmin={data.roles.includes('admin')} _id={data._id} username={data.username} onChange={onChange}/>
<Box display='flex' flexDirection='column' w='full' backgroundColor='neutral-200' p='x16' withTruncatedTex flexShrink={0}t>
<Box display='flex' flexDirection='column' w='full' backgroundColor='neutral-200' p='x16' withTruncatedText flexShrink={0}>
<Margins blockEnd='x4'>
{data.bio && data.bio.trim().length > 0 && <MarkdownText withTruncatedText fontScale='s1' content={data.bio} />}
{!!data.roles.length && <>

@ -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;
}
}

19
client/meteor.d.ts vendored

@ -26,3 +26,22 @@ declare module 'meteor/meteor' {
const connection: IMeteorConnection;
}
}
declare module 'meteor/tracker' {
namespace Tracker {
function nonreactive<T>(func: () => T): T;
}
}
declare module 'meteor/mongo' {
namespace Mongo {
// eslint-disable-next-line @typescript-eslint/interface-name-prefix
interface CollectionStatic {
new <T>(name: string | null, options?: {
connection?: object | null;
idGeneration?: string;
transform?: Function | null;
}): Collection<T>;
}
}
}

@ -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;

@ -4,7 +4,7 @@ import { AuthorizationProvider } from './AuthorizationProvider';
import { ConnectionStatusProvider } from './ConnectionStatusProvider';
import { RouterProvider } from './RouterProvider';
import { SessionProvider } from './SessionProvider';
import { SettingsProvider } from './SettingsProvider';
import SettingsProvider from './SettingsProvider';
import { ServerProvider } from './ServerProvider';
import { SidebarProvider } from './SidebarProvider';
import { TranslationProvider } from './TranslationProvider';

@ -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;

@ -1,13 +1,13 @@
import { Box, Button, Tile } from '@rocket.chat/fuselage';
import React from 'react';
import { useSetting, useSettingDispatch } from '../../../contexts/SettingsContext';
import { useSetting, useSettingSetValue } from '../../../contexts/SettingsContext';
import { useTranslation } from '../../../contexts/TranslationContext';
function FinalStep() {
const t = useTranslation();
const siteUrl = useSetting('Site_Url');
const setShowSetupWizard = useSettingDispatch('Show_Setup_Wizard');
const setShowSetupWizard = useSettingSetValue('Show_Setup_Wizard');
const handleClick = () => {
setShowSetupWizard('completed');

@ -10,7 +10,7 @@ import { useAutoFocus, useMergedRefs, useUniqueId } from '@rocket.chat/fuselage-
import React, { useRef, useState } from 'react';
import { useMethod } from '../../../contexts/ServerContext';
import { useBatchSettingsDispatch } from '../../../contexts/SettingsContext';
import { useSettingsDispatch } from '../../../contexts/SettingsContext';
import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext';
import { useTranslation } from '../../../contexts/TranslationContext';
import { Pager } from '../Pager';
@ -91,7 +91,7 @@ function RegisterServerStep({ step, title, active }) {
const [commiting, setComitting] = useState(false);
const batchSetSettings = useBatchSettingsDispatch();
const dispatchSettings = useSettingsDispatch();
const registerCloudWorkspace = useMethod('cloud:registerWorkspace');
@ -111,7 +111,7 @@ function RegisterServerStep({ step, title, active }) {
throw new Object({ error: 'Register_Server_Terms_Alert' });
}
await batchSetSettings([
await dispatchSettings([
{
_id: 'Statistics_reporting',
value: registerServer,

@ -11,7 +11,7 @@ import {
import { useAutoFocus } from '@rocket.chat/fuselage-hooks';
import React, { useEffect, useReducer, useState } from 'react';
import { useBatchSettingsDispatch } from '../../../contexts/SettingsContext';
import { useSettingsDispatch } from '../../../contexts/SettingsContext';
import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext';
import { useTranslation, useLanguages } from '../../../contexts/TranslationContext';
import { Pager } from '../Pager';
@ -57,11 +57,11 @@ function SettingsBasedStep({ step, title, active }) {
.sort(({ wizard: { order: a } }, { wizard: { order: b } }) => a - b)
.map(({ value, ...field }) => ({ ...field, value: value != null ? value : '' })),
);
}, [settings, currentStep]);
}, [settings, currentStep, resetFields, step]);
const t = useTranslation();
const batchSetSettings = useBatchSettingsDispatch();
const dispatchSettings = useSettingsDispatch();
const autoFocusRef = useAutoFocus(active);
@ -77,7 +77,7 @@ function SettingsBasedStep({ step, title, active }) {
setCommiting(true);
try {
await batchSetSettings(fields.map(({ _id, value }) => ({ _id, value })));
await dispatchSettings(fields.map(({ _id, value }) => ({ _id, value })));
goToNextStep();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });

@ -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…
Cancel
Save