Add the Component Library to the app (#8319)

* Add the Component Library to the app

* fix i18n

---------

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Daniel Espino García 2025-02-04 13:32:55 +01:00 committed by GitHub
parent 46f1cf3969
commit c8f42ee67e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 454 additions and 2 deletions

View file

@ -19,6 +19,7 @@ export const CONVERT_GM_TO_CHANNEL = 'ConvertGMToChannel';
export const CREATE_DIRECT_MESSAGE = 'CreateDirectMessage';
export const CREATE_OR_EDIT_CHANNEL = 'CreateOrEditChannel';
export const CREATE_TEAM = 'CreateTeam';
export const COMPONENT_LIBRARY = 'ComponentLibrary';
export const CUSTOM_STATUS = 'CustomStatus';
export const CUSTOM_STATUS_CLEAR_AFTER = 'CustomStatusClearAfter';
export const DRAFT = 'Draft';
@ -98,6 +99,7 @@ export default {
CHANNEL_NOTIFICATION_PREFERENCES,
CODE,
CONVERT_GM_TO_CHANNEL,
COMPONENT_LIBRARY,
CREATE_DIRECT_MESSAGE,
CREATE_OR_EDIT_CHANNEL,
CREATE_TEAM,

View file

@ -76,6 +76,10 @@ const ThemeProvider = ({currentTeamId, children, themes}: Props) => {
return (<Provider value={theme}>{children}</Provider>);
};
export const CustomThemeProvider = ({theme, children}: {theme: Theme; children: React.ReactNode}) => {
return (<Provider value={theme}>{children}</Provider>);
};
export function withTheme<T extends WithThemeProps>(Component: ComponentType<T>): ComponentType<T> {
return function ThemeComponent(props) {
return (

View file

@ -0,0 +1,65 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {Alert, View} from 'react-native';
import Button from '@components/button';
import {useTheme} from '@context/theme';
import {useBooleanProp, useDropdownProp, useStringProp} from './hooks';
import {buildComponent} from './utils';
const propPossibilities = {};
const buttonSizeValues = ['xs', 's', 'm', 'lg'];
const buttonEmphasisValues = ['primary', 'secondary', 'tertiary', 'link'];
const buttonTypeValues = ['default', 'destructive', 'inverted', 'disabled'];
const buttonStateValues = ['default', 'hover', 'active', 'focus'];
const onPress = () => Alert.alert('Button pressed!');
const ButtonComponentLibrary = () => {
const theme = useTheme();
const [text, textSelector] = useStringProp('text', 'Some text', false);
const [disabled, disabledSelector] = useBooleanProp('disabled', false);
const [buttonSize, buttonSizePosibilities, buttonSizeSelector] = useDropdownProp('size', 'm', buttonSizeValues, true);
const [buttonEmphasis, buttonEmphasisPosibilities, buttonEmphasisSelector] = useDropdownProp('emphasis', 'primary', buttonEmphasisValues, true);
const [buttonType, buttonTypePosibilities, buttonTypeSelector] = useDropdownProp('buttonType', 'default', buttonTypeValues, true);
const [buttonState, buttonStatePosibilities, buttonStateSelector] = useDropdownProp('buttonState', 'default', buttonStateValues, true);
const components = useMemo(
() => buildComponent(Button, propPossibilities, [
buttonSizePosibilities,
buttonEmphasisPosibilities,
buttonTypePosibilities,
buttonStatePosibilities,
], [
text,
disabled,
buttonSize,
buttonEmphasis,
buttonType,
buttonState,
{
theme,
onPress,
},
]),
[buttonEmphasis, buttonEmphasisPosibilities, buttonSize, buttonSizePosibilities, buttonState, buttonStatePosibilities, buttonType, buttonTypePosibilities, disabled, text, theme],
);
return (
<>
{textSelector}
{disabledSelector}
{buttonSizeSelector}
{buttonEmphasisSelector}
{buttonTypeSelector}
{buttonStateSelector}
<View>{components}</View>
</>
);
};
export default ButtonComponentLibrary;

View file

@ -0,0 +1,107 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useState} from 'react';
import AutocompleteSelector from '@components/autocomplete_selector';
import BoolSetting from '@components/settings/bool_setting';
import TextSetting from '@components/settings/text_setting';
type HookResult<T> = [
{[x: string]: T},
JSX.Element,
]
export const useStringProp = (
propName: string,
defaultValue: string,
isTextarea: boolean,
): HookResult<string> => {
const [value, setValue] = useState(defaultValue);
const selector = useMemo(() => (
<TextSetting
label={propName}
multiline={isTextarea}
disabled={false}
keyboardType='default'
onChange={setValue}
optional={false}
secureTextEntry={false}
testID={`${propName}.input`}
value={value}
/>
), [value, propName, isTextarea]);
const preparedProp = useMemo(() => ({[propName]: value}), [propName, value]);
return [preparedProp, selector];
};
export const useBooleanProp = (
propName: string,
defaultValue: boolean,
): HookResult<boolean> => {
const [value, setValue] = useState(defaultValue);
const selector = useMemo(() => (
<BoolSetting
onChange={setValue}
testID={`${propName}.input`}
value={value}
label={propName}
/>
), [propName, value]);
const preparedProp = useMemo(() => ({[propName]: value}), [propName, value]);
return [preparedProp, selector];
};
const ALL_OPTION = 'ALL';
type DropdownHookResult = [
{[x: string]: string} | undefined,
{[x: string]: string[]} | undefined,
JSX.Element,
];
export const useDropdownProp = (
propName: string,
defaultValue: string,
options: string[],
allowAll: boolean,
): DropdownHookResult => {
const [value, setValue] = useState(defaultValue);
const onChange = useCallback((v: SelectedDialogOption) => {
if (!v) {
setValue(defaultValue);
return;
}
if (Array.isArray(v)) {
setValue(v[0].value);
return;
}
setValue(v.value);
}, [defaultValue]);
const renderedOptions = useMemo(() => {
const toReturn: DialogOption[] = options.map((v) => ({
value: v,
text: v,
}));
if (allowAll) {
toReturn.unshift({
value: ALL_OPTION,
text: ALL_OPTION,
});
}
return toReturn;
}, [options, allowAll]);
const selector = useMemo(() => (
<AutocompleteSelector
testID={`${propName}.input`}
label={propName}
onSelected={onChange}
options={renderedOptions}
selected={value}
/>
), [onChange, propName, renderedOptions, value]);
const preparedProp = useMemo(() => (value === ALL_OPTION ? undefined : ({[propName]: value})), [propName, value]);
const preparedPossibilities = useMemo(() => (value === ALL_OPTION ? ({[propName]: options}) : undefined), [propName, value, options]);
return [preparedProp, preparedPossibilities, selector];
};

View file

@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useState} from 'react';
import {ScrollView, View, type StyleProp, type ViewStyle} from 'react-native';
import AutocompleteSelector from '@components/autocomplete_selector';
import {Preferences} from '@constants';
import {CustomThemeProvider} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {popTopScreen} from '@screens/navigation';
import ButtonComponentLibrary from './button.cl';
import type {AvailableScreens} from '@typings/screens/navigation';
const componentMap = {
Button: ButtonComponentLibrary,
};
type ComponentName = keyof typeof componentMap
const defaultComponent = Object.keys(componentMap)[0] as ComponentName;
const componentOptions = Object.keys(componentMap).map((v) => ({
value: v,
text: v,
}));
type ThemeName = keyof typeof Preferences.THEMES;
const defaultTheme = Object.keys(Preferences.THEMES)[0] as ThemeName;
const themeOptions = Object.keys(Preferences.THEMES).map((v) => ({
value: v,
text: v,
}));
type BackgroundType = 'center' | 'sidebar';
const backgroundOptions = [{
value: 'center',
text: 'Center channel',
}, {
value: 'sidebar',
text: 'Sidebar background',
}];
type Props = {
componentId: AvailableScreens;
};
const ComponentLibrary = ({componentId}: Props) => {
const [selectedComponent, setSelectedComponent] = useState<ComponentName>(defaultComponent);
const onSelectComponent = useCallback((value: SelectedDialogOption) => {
if (!value) {
setSelectedComponent(defaultComponent);
return;
}
if (Array.isArray(value)) {
setSelectedComponent(value[0].value as ComponentName);
return;
}
setSelectedComponent(value.value as ComponentName);
}, []);
const [selectedTheme, setSelectedTheme] = useState(defaultTheme);
const onSelectTheme = useCallback((value: SelectedDialogOption) => {
if (!value) {
setSelectedTheme(defaultTheme);
return;
}
if (Array.isArray(value)) {
setSelectedTheme(value[0].value as ThemeName);
return;
}
setSelectedTheme(value.value as ThemeName);
}, []);
const [selectedBackground, setSelectedBackground] = useState<BackgroundType>('center');
const onSelectBackground = useCallback((value: SelectedDialogOption) => {
if (!value) {
setSelectedBackground('center');
return;
}
if (Array.isArray(value)) {
setSelectedBackground(value[0].value as BackgroundType);
return;
}
setSelectedBackground(value.value as BackgroundType);
}, []);
const backgroundStyle: StyleProp<ViewStyle> = useMemo(() => {
const theme = Preferences.THEMES[selectedTheme];
switch (selectedBackground) {
case 'center':
return {
backgroundColor: theme.centerChannelBg,
};
case 'sidebar':
default:
return {
backgroundColor: theme.sidebarBg,
};
}
}, [selectedBackground, selectedTheme]);
const close = useCallback(() => {
popTopScreen(componentId);
}, [componentId]);
useAndroidHardwareBackHandler(componentId, close);
const SelectedComponent = componentMap[selectedComponent];
return (
<ScrollView style={{margin: 10}}>
<AutocompleteSelector
testID='selectedComponent'
label='Component'
onSelected={onSelectComponent}
selected={selectedComponent}
options={componentOptions}
/>
<AutocompleteSelector
testID='selectedTheme'
label='Theme'
onSelected={onSelectTheme}
selected={selectedTheme}
options={themeOptions}
/>
<AutocompleteSelector
testID='selectedBackground'
label='Background'
onSelected={onSelectBackground}
selected={selectedBackground}
options={backgroundOptions}
/>
<View style={backgroundStyle}>
<CustomThemeProvider theme={Preferences.THEMES[selectedTheme]}>
<SelectedComponent/>
</CustomThemeProvider>
</View>
</ScrollView>
);
};
export default ComponentLibrary;

View file

@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Text} from 'react-native';
function buildPropsLists(inputPossibilities: {[x: string]: any[]}): Array<{[x: string]: any}> {
const keys = Object.keys(inputPossibilities);
if (!keys.length) {
return [{}];
}
const selectedKey = keys[0];
const restPossibilities = {...inputPossibilities};
delete restPossibilities[selectedKey];
const subProps = buildPropsLists(restPossibilities);
const result: Array<{[x: string]: any}> = [];
inputPossibilities[selectedKey].forEach((v) => {
subProps.forEach((rest) => {
result.push({...rest, [selectedKey]: v});
});
});
return result;
}
function buildPropString(inputProps: {[x: string]: any}) {
const propKeys = Object.keys(inputProps);
if (!propKeys.length) {
return undefined;
}
const result = [(<>{'PROPS: '}</>)];
propKeys.forEach((v) => {
result.push((<>{v}{`: ${inputProps[v]}, `}</>));
});
return <Text>{result}</Text>;
}
export function buildComponent(
Component: React.ComponentType<any>,
propPossibilities: {[x: string]: any[]},
dropdownPossibilities: Array<{[x: string]: string[]} | undefined>,
setProps: Array<{[x: string]: any} | undefined>,
) {
const res: React.ReactNode[] = [];
let currentPropPossibilities = {...propPossibilities};
dropdownPossibilities.forEach((v) => {
if (v) {
currentPropPossibilities = {
...currentPropPossibilities,
...v,
};
}
});
const propsVariations = buildPropsLists(currentPropPossibilities);
let builtSetProps = {};
setProps.forEach((v) => {
if (v) {
builtSetProps = {
...builtSetProps,
...v,
};
}
});
propsVariations.forEach((v) => {
const propString = buildPropString(v);
res.push(
<>
{Boolean(propString) && <Text key={`text.${propString}`}>{propString}</Text>}
<Component
key={propString}
{...builtSetProps}
{...v}
/>
</>,
);
});
return res;
}

View file

@ -99,6 +99,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.CREATE_OR_EDIT_CHANNEL:
screen = withServerDatabase(require('@screens/create_or_edit_channel').default);
break;
case Screens.COMPONENT_LIBRARY:
screen = withServerDatabase(require('@screens/component_library').default);
break;
case Screens.CUSTOM_STATUS:
screen = withServerDatabase(require('@screens/custom_status').default);
break;

View file

@ -8,10 +8,11 @@ import {Alert, TouchableOpacity} from 'react-native';
import SettingContainer from '@components/settings/container';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {popTopScreen} from '@screens/navigation';
import {goToScreen, popTopScreen} from '@screens/navigation';
import {deleteFileCache, getAllFilesInCachesDirectory, getFormattedFileSize} from '@utils/file';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme} from '@utils/theme';
@ -32,8 +33,12 @@ const EMPTY_FILES: FileInfo[] = [];
type AdvancedSettingsProps = {
componentId: AvailableScreens;
isDevMode: boolean;
};
const AdvancedSettings = ({componentId}: AdvancedSettingsProps) => {
const AdvancedSettings = ({
componentId,
isDevMode,
}: AdvancedSettingsProps) => {
const theme = useTheme();
const intl = useIntl();
const serverUrl = useServerUrl();
@ -77,6 +82,13 @@ const AdvancedSettings = ({componentId}: AdvancedSettingsProps) => {
}
});
const onPressComponentLibrary = () => {
const screen = Screens.COMPONENT_LIBRARY;
const title = intl.formatMessage({id: 'settings.advanced_settings.component_library', defaultMessage: 'Component library'});
goToScreen(screen, title);
};
useEffect(() => {
getAllCachedFiles();
}, []);
@ -107,6 +119,19 @@ const AdvancedSettings = ({componentId}: AdvancedSettingsProps) => {
/>
<SettingSeparator/>
</TouchableOpacity>
{isDevMode && (
<TouchableOpacity
onPress={onPressComponentLibrary}
>
<SettingOption
containerStyle={styles.itemStyle}
label={intl.formatMessage({id: 'settings.advanced.component_library', defaultMessage: 'Component library'})}
testID='advanced_settings.component_library.option'
type='none'
/>
<SettingSeparator/>
</TouchableOpacity>
)}
</SettingContainer>
);
};

View file

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {observeConfigBooleanValue} from '@queries/servers/system';
import AdvancedSettings from './advanced';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
return {
isDevMode: observeConfigBooleanValue(database, 'EnableDeveloper', false),
};
});
export default withDatabase(enhanced(AdvancedSettings));

View file

@ -1075,7 +1075,9 @@
"settings.about.server.version.title": "Server Version:",
"settings.about.server.version.value": "{version} (Build {buildNumber})",
"settings.advanced_settings": "Advanced Settings",
"settings.advanced_settings.component_library": "Component library",
"settings.advanced.cancel": "Cancel",
"settings.advanced.component_library": "Component library",
"settings.advanced.delete": "Delete",
"settings.advanced.delete_data": "Delete local files",
"settings.advanced.delete_message.confirmation": "\nThis will delete all files downloaded through the app for this server. Please confirm to proceed.\n",