Skip to content

Commit

Permalink
feat/LIVE-3148 Added development tool for feature flags (desktop)
Browse files Browse the repository at this point in the history
  • Loading branch information
juan-cortes committed Aug 16, 2022
1 parent 0cff80c commit aae9906
Show file tree
Hide file tree
Showing 13 changed files with 471 additions and 24 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { ReactNode } from "react";
import React, { useState, ReactNode } from "react";
import isEqual from "lodash/isEqual";
import { FeatureFlagsProvider } from "@ledgerhq/live-common/featureFlags/index";
import { Feature, FeatureId } from "@ledgerhq/types-live";
import { getValue } from "firebase/remote-config";
Expand All @@ -11,22 +12,56 @@ type Props = {

export const FirebaseFeatureFlagsProvider = ({ children }: Props): JSX.Element => {
const remoteConfig = useFirebaseRemoteConfig();
const [localOverrides, setLocalOverrides] = useState({});

const getFeature = (key: FeatureId): Feature | null => {
if (!remoteConfig) {
return null;
}
const getFeature = useCallback(
(key: FeatureId, allowOverride = true): Feature | null => {
if (!remoteConfig) {
return null;
}

try {
const value = getValue(remoteConfig, formatFeatureId(key));
const feature: Feature = JSON.parse(value.asString());
try {
// Nb prioritize local overrides
if (allowOverride && localOverrides[key]) {
return localOverrides[key];
}

return feature;
} catch (error) {
console.error(`Failed to retrieve feature "${key}"`);
return null;
}
const value = getValue(remoteConfig, formatFeatureId(key));
const feature: Feature = JSON.parse(value.asString());

return feature;
} catch (error) {
console.error(`Failed to retrieve feature "${key}"`);
return null;
}
},
[localOverrides, remoteConfig],
);

const overrideFeature = useCallback(
(key: FeatureId, value: Feature): void => {
const actualRemoteValue = getFeature(key, false);
if (!isEqual(actualRemoteValue, value)) {
const overridenValue = { ...value, overridesRemote: true };
setLocalOverrides(currentOverrides => ({ ...currentOverrides, [key]: overridenValue }));
} else {
console.error("Not overriding");
}
},
[getFeature],
);

const resetFeature = (key: FeatureId): void => {
setLocalOverrides(currentOverrides => ({ ...currentOverrides, [key]: undefined }));
};

return <FeatureFlagsProvider getFeature={getFeature}>{children}</FeatureFlagsProvider>;
return (
<FeatureFlagsProvider
getFeature={getFeature}
overrideFeature={overrideFeature}
resetFeature={resetFeature}
>
{children}
</FeatureFlagsProvider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import React, { useState, useMemo, useCallback } from "react";
import Button from "~/renderer/components/Button";
import { useTranslation } from "react-i18next";
import { defaultFeatures, useFeatureFlags } from "@ledgerhq/live-common/featureFlags/index";
import { SettingsSectionRow as Row } from "../../SettingsSection";
import Box from "~/renderer/components/Box";
import Input from "~/renderer/components/Input";
import Alert from "~/renderer/components/Alert";
import styled from "styled-components";

const ButtonContainer = styled.div`
display: flex;
flex-direction: row;
`;

type EditSectionProps = {
error?: Error;
value: string;
disabled?: boolean;

onOverride: () => void;
onRestore: () => void;
onChange: (_: string) => void;
};

const EditSection = ({
error,
value,
onOverride,
onRestore,
onChange,
disabled,
}: EditSectionProps) => {
const { t } = useTranslation();
return (
<Box p={3}>
{error ? (
<Alert mb={3} type="warning">
{error.toString()}
</Alert>
) : null}
<Input value={value} onChange={onChange} />
<Box mt={3} horizontal justifyContent="flex-end">
<Button small onClick={onRestore}>
{t("settings.developer.featureFlagsRestore")}
</Button>
<Button disabled={disabled} small primary onClick={onOverride} style={{ marginLeft: 8 }}>
{t("settings.developer.featureFlagsOverride")}
</Button>
</Box>
</Box>
);
};

const FeatureFlagsButton = () => {
const { t } = useTranslation();
const featureFlagsProvider = useFeatureFlags();
const [error, setError] = useState();
const [name, setName] = useState();
const [inputValues, setInputValues] = useState({});

const featureFlags = useMemo(() => {
const features = {};
Object.keys(defaultFeatures).forEach(key => {
const value = featureFlagsProvider.getFeature(key);
if (value) {
features[key] = value;
}
});
return features;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [name, featureFlagsProvider]);

const handleInputChange = useCallback(
value => {
setError();
setInputValues(currentValues => ({
...currentValues,
[name]: value,
}));
},
[name],
);

const handleRestoreFeature = useCallback(() => {
setError();
setInputValues(currentValues => ({
...currentValues,
[name]: undefined,
}));
featureFlagsProvider.resetFeature(name);
setName();
}, [featureFlagsProvider, name]);

const handleOverrideFeature = useCallback(() => {
setError();
try {
// Nb if value is invalid or missing, JSON parse will fail
const newValue = JSON.parse(inputValues[name]);
featureFlagsProvider.overrideFeature(name, newValue);
setName();
} catch (e) {
setError(e);
}
}, [inputValues, name, featureFlagsProvider]);

return (
<>
<Row
title={t("settings.developer.featureFlagsTitle")}
desc={t("settings.developer.featureFlagsDesc")}
/>
<Box pt={2}>
{Object.entries(featureFlags).map(([flagName, value]) => (
<>
<Box horizontal px={4} py={1}>
<Box grow flex={1} mr={3}>
<Box ff="Inter|SemiBold" color="palette.text.shade100" fontSize={14} mb={2}>
{value?.overridesRemote ? `${flagName} **` : flagName}
</Box>
</Box>
<ButtonContainer>
{name !== flagName ? (
<Button
small
onClick={() => {
setName(flagName);
}}
>
{t("settings.developer.featureFlagsEdit")}
</Button>
) : null}
</ButtonContainer>
</Box>
{name === flagName ? (
<EditSection
value={inputValues[flagName] || JSON.stringify(featureFlags[flagName])}
disabled={!inputValues[flagName]}
error={error}
onChange={handleInputChange}
onOverride={handleOverrideFeature}
onRestore={handleRestoreFeature}
/>
) : null}
</>
))}
</Box>
</>
);
};

export default FeatureFlagsButton;
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import AllowDebugAppsToggle from "./AllowDebugAppsToggle";
import EnablePlatformDevToolsToggle from "./EnablePlatformDevToolsToggle";
import CatalogProviderSelect from "./CatalogProviderSelect";
import RunLocalAppButton from "./RunLocalAppButton";
import FeatureFlagsButton from "./FeatureFlagsButton";
import EnableLearnPageStagingUrlToggle from "./EnableLearnPageStagingUrlToggle";

const SectionDeveloper = () => {
Expand Down Expand Up @@ -43,6 +44,7 @@ const SectionDeveloper = () => {
<EnablePlatformDevToolsToggle />
</Row>
<RunLocalAppButton />
<FeatureFlagsButton />
<Row
title={t("settings.developer.enableLearnStagingUrl")}
desc={t("settings.developer.enableLearnStagingUrlDesc")}
Expand Down
5 changes: 5 additions & 0 deletions apps/ledger-live-desktop/static/i18n/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -2886,6 +2886,11 @@
"catalogServerDesc": "Switch between multiple platform apps sources",
"enablePlatformDevTools": "Enable platform dev tools",
"enablePlatformDevToolsDesc": "Enable opening platform apps dev tools window",
"featureFlagsTitle": "Defined feature flags used in Ledger Live",
"featureFlagsDesc": "Overriden values will be reset if the app is relaunched. Only valid JSON strings will be accepted.",
"featureFlagsEdit": "Edit",
"featureFlagsRestore": "Restore",
"featureFlagsOverride": "Override",
"addLocalApp": "Add a local app",
"addLocalAppDesc": "Browse local files and add a local app using a local manifest",
"addLocalAppButton": "Browse",
Expand Down
55 changes: 48 additions & 7 deletions apps/ledger-live-mobile/src/components/FirebaseFeatureFlags.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { ReactNode } from "react";
import React, { ReactNode, useCallback, useState } from "react";
import { useSelector } from "react-redux";
import isEqual from "lodash/isEqual";
import remoteConfig from "@react-native-firebase/remote-config";
import {
FeatureFlagsProvider,
Expand All @@ -15,8 +16,16 @@ type Props = {
children?: ReactNode;
};

const getFeature = (key: FeatureId) => {
const getFeature = (
key: FeatureId,
localOverrides?: { [name: FeatureId]: Feature },
) => {
try {
// Nb prioritize local overrides
if (localOverrides[key]) {
return localOverrides[key];
}

const value = remoteConfig().getValue(formatFeatureId(key));
const feature = JSON.parse(value.asString());
const currAppLanguage = useSelector(languageSelector);
Expand Down Expand Up @@ -54,8 +63,40 @@ export const getAllDivergedFlags = (): { [key in FeatureId]: boolean } => {
return res;
};

export const FirebaseFeatureFlagsProvider = ({ children }: Props) => (
<FeatureFlagsProvider getFeature={getFeature}>
{children}
</FeatureFlagsProvider>
);
export const FirebaseFeatureFlagsProvider = ({ children }: Props) => {
const [localOverrides, setLocalOverrides] = useState({});

const overrideFeature = useCallback((key: FeatureId, value: Feature): void => {
const actualRemoteValue = getFeature(key);
if (!isEqual(actualRemoteValue, value)) {
const overridenValue = { ...value, overridesRemote: true };
setLocalOverrides(currentOverrides => ({
...currentOverrides,
[key]: overridenValue,
}));
}
}, []);

const resetFeature = (key: FeatureId): void => {
setLocalOverrides(currentOverrides => ({
...currentOverrides,
[key]: undefined,
}));
};

// Nb wrapped because the method is also called from outside.
const wrappedGetFeature = useCallback(
(key: FeatureId): Feature => getFeature(key, localOverrides),
[localOverrides],
);

return (
<FeatureFlagsProvider
getFeature={wrappedGetFeature}
overrideFeature={overrideFeature}
resetFeature={resetFeature}
>
{children}
</FeatureFlagsProvider>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import DebugBLE from "../../screens/DebugBLE";
import DebugBLEBenchmark from "../../screens/DebugBLEBenchmark";
import DebugCrash from "../../screens/DebugCrash";
import DebugHttpTransport from "../../screens/DebugHttpTransport";
import DebugFeatureFlags from "../../screens/DebugFeatureFlags";
import DebugIcons from "../../screens/DebugIcons";
import DebugLottie from "../../screens/DebugLottie.js";
import DebugLogs from "../../screens/DebugLogs.js";
Expand Down Expand Up @@ -200,6 +201,13 @@ export default function SettingsNavigator() {
title: "Debug Devices",
}}
/>
<Stack.Screen
name={ScreenName.DebugFeatureFlags}
component={DebugFeatureFlags}
options={{
title: "Debug Feature Flags",
}}
/>
<Stack.Screen
name={ScreenName.DebugMocks}
component={DebugMocks}
Expand Down
1 change: 1 addition & 0 deletions apps/ledger-live-mobile/src/const/navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const ScreenName = {
DebugCrash: "DebugCrash",
DebugDevices: "DebugDevices",
DebugExport: "DebugExport",
DebugFeatureFlags: "DebugFeatureFlags",
DebugHttpTransport: "DebugHttpTransport",
DebugIcons: "DebugIcons",
DebugLogs: "DebugLogs",
Expand Down
7 changes: 7 additions & 0 deletions apps/ledger-live-mobile/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -2169,6 +2169,13 @@
"customManifest": {
"title": "Load Custom Platform Manifest"
}
},
"debug": {
"featureFlagsTitle": "Defined feature flags used in Ledger Live",
"featureFlagsDesc": "Overriden values will be reset if the app is relaunched. Only valid JSON strings will be accepted.",
"featureFlagsOverride": "Overide",
"featureFlagsEdit": "Edit",
"featureFlagsRestore": "Restore"
}
},
"notifications": {
Expand Down
Loading

0 comments on commit aae9906

Please sign in to comment.