Plugin settings (#4143)

* Add backend support for plugin settings
* Add plugin settings config
* Add UI support for plugin settings
This commit is contained in:
WithoutPants
2023-10-18 14:09:13 +11:00
committed by GitHub
parent 06d8353f4f
commit 2b8718100b
24 changed files with 445 additions and 57 deletions

View File

@@ -192,6 +192,7 @@ export const ChangeButtonSetting = <T extends {}>(props: IDialogSetting<T>) => {
id,
className,
headingID,
heading,
tooltipID,
subHeadingID,
subHeading,
@@ -211,7 +212,11 @@ export const ChangeButtonSetting = <T extends {}>(props: IDialogSetting<T>) => {
<div className={`setting ${className ?? ""} ${disabledClassName}`} id={id}>
<div>
<h3 title={tooltip}>
{headingID ? intl.formatMessage({ id: headingID }) : undefined}
{headingID
? intl.formatMessage({ id: headingID })
: heading
? heading
: undefined}
</h3>
<div className="value">
@@ -240,7 +245,7 @@ export const ChangeButtonSetting = <T extends {}>(props: IDialogSetting<T>) => {
};
export interface ISettingModal<T> {
heading?: string;
heading?: React.ReactNode;
headingID?: string;
subHeadingID?: string;
subHeading?: React.ReactNode;
@@ -319,6 +324,7 @@ export const ModalSetting = <T extends {}>(props: IModalSetting<T>) => {
className,
value,
headingID,
heading,
subHeadingID,
subHeading,
onChange,
@@ -338,6 +344,7 @@ export const ModalSetting = <T extends {}>(props: IModalSetting<T>) => {
<SettingModal<T>
headingID={headingID}
subHeadingID={subHeadingID}
heading={heading}
subHeading={subHeading}
value={value}
renderField={renderField}
@@ -356,6 +363,7 @@ export const ModalSetting = <T extends {}>(props: IModalSetting<T>) => {
buttonText={buttonText}
buttonTextID={buttonTextID}
headingID={headingID}
heading={heading}
tooltipID={tooltipID}
subHeadingID={subHeadingID}
subHeading={subHeading}

View File

@@ -13,7 +13,7 @@ import {
SelectSetting,
StringSetting,
} from "../Inputs";
import { SettingStateContext } from "../context";
import { useSettings } from "../context";
import DurationUtils from "src/utils/duration";
import * as GQL from "src/core/generated-graphql";
import {
@@ -65,7 +65,7 @@ export const SettingsInterfacePanel: React.FC = () => {
saveUI,
loading,
error,
} = React.useContext(SettingStateContext);
} = useSettings();
const {
interactive,

View File

@@ -4,14 +4,14 @@ import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { StashSetting } from "./StashConfiguration";
import { SettingSection } from "./SettingSection";
import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs";
import { SettingStateContext } from "./context";
import { useSettings } from "./context";
import { useIntl } from "react-intl";
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
export const SettingsLibraryPanel: React.FC = () => {
const intl = useIntl();
const { general, loading, error, saveGeneral, defaults, saveDefaults } =
React.useContext(SettingStateContext);
useSettings();
function commaDelimitedToList(value: string | undefined) {
if (value) {

View File

@@ -13,19 +13,74 @@ import { CollapseButton } from "../Shared/CollapseButton";
import { Icon } from "../Shared/Icon";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { SettingSection } from "./SettingSection";
import { Setting, SettingGroup } from "./Inputs";
import {
BooleanSetting,
NumberSetting,
Setting,
SettingGroup,
StringSetting,
} from "./Inputs";
import { faLink, faSyncAlt } from "@fortawesome/free-solid-svg-icons";
import { useSettings } from "./context";
interface IPluginSettingProps {
pluginID: string;
setting: GQL.PluginSetting;
value: unknown;
onChange: (value: unknown) => void;
}
const PluginSetting: React.FC<IPluginSettingProps> = ({
pluginID,
setting,
value,
onChange,
}) => {
const commonProps = {
heading: setting.display_name ? setting.display_name : setting.name,
id: `plugin-${pluginID}-${setting.name}`,
subHeading: setting.description ?? undefined,
};
switch (setting.type) {
case GQL.PluginSettingTypeEnum.Boolean:
return (
<BooleanSetting
{...commonProps}
checked={(value as boolean) ?? false}
onChange={() => onChange(!value)}
/>
);
case GQL.PluginSettingTypeEnum.String:
return (
<StringSetting
{...commonProps}
value={(value as string) ?? ""}
onChange={(v) => onChange(v)}
/>
);
case GQL.PluginSettingTypeEnum.Number:
return (
<NumberSetting
{...commonProps}
value={(value as number) ?? 0}
onChange={(v) => onChange(v)}
/>
);
}
};
export const SettingsPluginsPanel: React.FC = () => {
const Toast = useToast();
const intl = useIntl();
const { loading: configLoading, plugins, savePluginSettings } = useSettings();
const { data, loading, refetch } = usePlugins();
const [changedPluginID, setChangedPluginID] = React.useState<
string | undefined
>();
const { data, loading, refetch } = usePlugins();
async function onReloadPlugins() {
await mutateReloadPlugins().catch((e) => Toast.error(e));
}
@@ -101,6 +156,7 @@ export const SettingsPluginsPanel: React.FC = () => {
}
>
{renderPluginHooks(plugin.hooks ?? undefined)}
{renderPluginSettings(plugin.id, plugin.settings ?? [])}
</SettingGroup>
));
@@ -145,10 +201,40 @@ export const SettingsPluginsPanel: React.FC = () => {
);
}
return renderPlugins();
}, [data?.plugins, intl, Toast, changedPluginID, refetch]);
function renderPluginSettings(
pluginID: string,
settings: GQL.PluginSetting[]
) {
const pluginSettings = plugins[pluginID] ?? {};
if (loading) return <LoadingIndicator />;
return settings.map((setting) => (
<PluginSetting
key={setting.name}
pluginID={pluginID}
setting={setting}
value={pluginSettings[setting.name]}
onChange={(v) =>
savePluginSettings(pluginID, {
...pluginSettings,
[setting.name]: v,
})
}
/>
));
}
return renderPlugins();
}, [
data?.plugins,
intl,
Toast,
changedPluginID,
refetch,
plugins,
savePluginSettings,
]);
if (loading || configLoading) return <LoadingIndicator />;
return (
<>

View File

@@ -16,7 +16,7 @@ import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { ScrapeType } from "src/core/generated-graphql";
import { SettingSection } from "./SettingSection";
import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs";
import { SettingStateContext } from "./context";
import { useSettings } from "./context";
import { StashBoxSetting } from "./StashBoxConfiguration";
import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
@@ -87,7 +87,7 @@ export const SettingsScrapingPanel: React.FC = () => {
useListMovieScrapers();
const { general, scraping, loading, error, saveGeneral, saveScraping } =
React.useContext(SettingStateContext);
useSettings();
async function onReloadScrapers() {
await mutateReloadScrapers().catch((e) => Toast.error(e));

View File

@@ -4,7 +4,7 @@ import { SettingSection } from "./SettingSection";
import * as GQL from "src/core/generated-graphql";
import { Button, Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { SettingStateContext } from "./context";
import { useSettings } from "./context";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { useToast } from "src/hooks/Toast";
import { useGenerateAPIKey } from "src/core/StashService";
@@ -72,7 +72,7 @@ export const SettingsSecurityPanel: React.FC = () => {
const Toast = useToast();
const { general, apiKey, loading, error, saveGeneral, refetch } =
React.useContext(SettingStateContext);
useSettings();
const [generateAPIKey] = useGenerateAPIKey();

View File

@@ -20,7 +20,7 @@ import {
StringSetting,
SelectSetting,
} from "./Inputs";
import { SettingStateContext } from "./context";
import { useSettings } from "./context";
import {
videoSortOrderIntlMap,
defaultVideoSort,
@@ -35,12 +35,7 @@ export const SettingsServicesPanel: React.FC = () => {
const intl = useIntl();
const Toast = useToast();
const {
dlna,
loading: configLoading,
error,
saveDLNA,
} = React.useContext(SettingStateContext);
const { dlna, loading: configLoading, error, saveDLNA } = useSettings();
// undefined to hide dialog, true for enable, false for disable
const [enableDisable, setEnableDisable] = useState<boolean>();

View File

@@ -10,7 +10,7 @@ import {
StringListSetting,
StringSetting,
} from "./Inputs";
import { SettingStateContext } from "./context";
import { useSettings } from "./context";
import {
VideoPreviewInput,
VideoPreviewSettingsInput,
@@ -20,8 +20,7 @@ import { useIntl } from "react-intl";
export const SettingsConfigurationPanel: React.FC = () => {
const intl = useIntl();
const { general, loading, error, saveGeneral } =
React.useContext(SettingStateContext);
const { general, loading, error, saveGeneral } = useSettings();
const transcodeQualities = [
GQL.StreamingResolutionEnum.Low,

View File

@@ -13,6 +13,7 @@ import {
useConfigureDLNA,
useConfigureGeneral,
useConfigureInterface,
useConfigurePlugin,
useConfigureScraping,
useConfigureUI,
} from "src/core/StashService";
@@ -21,6 +22,7 @@ import { useToast } from "src/hooks/Toast";
import { withoutTypename } from "src/utils/data";
import { Icon } from "../Shared/Icon";
type PluginSettings = Record<string, Record<string, unknown>>;
export interface ISettingsContextState {
loading: boolean;
error: ApolloError | undefined;
@@ -30,6 +32,7 @@ export interface ISettingsContextState {
scraping: GQL.ConfigScrapingInput;
dlna: GQL.ConfigDlnaInput;
ui: IUIConfig;
plugins: PluginSettings;
// apikey isn't directly settable, so expose it here
apiKey: string;
@@ -40,28 +43,23 @@ export interface ISettingsContextState {
saveScraping: (input: Partial<GQL.ConfigScrapingInput>) => void;
saveDLNA: (input: Partial<GQL.ConfigDlnaInput>) => void;
saveUI: (input: Partial<IUIConfig>) => void;
savePluginSettings: (pluginID: string, input: {}) => void;
refetch: () => void;
}
export const SettingStateContext = React.createContext<ISettingsContextState>({
loading: false,
error: undefined,
general: {},
interface: {},
defaults: {},
scraping: {},
dlna: {},
ui: {},
apiKey: "",
saveGeneral: () => {},
saveInterface: () => {},
saveDefaults: () => {},
saveScraping: () => {},
saveDLNA: () => {},
saveUI: () => {},
refetch: () => {},
});
export const SettingStateContext =
React.createContext<ISettingsContextState | null>(null);
export const useSettings = () => {
const context = React.useContext(SettingStateContext);
if (context === null) {
throw new Error("useSettings must be used within a SettingsContext");
}
return context;
};
export const SettingsContext: React.FC = ({ children }) => {
const Toast = useToast();
@@ -97,6 +95,10 @@ export const SettingsContext: React.FC = ({ children }) => {
const [pendingUI, setPendingUI] = useState<{}>();
const [updateUIConfig] = useConfigureUI();
const [plugins, setPlugins] = useState<PluginSettings>({});
const [pendingPlugins, setPendingPlugins] = useState<PluginSettings>();
const [updatePluginConfig] = useConfigurePlugin();
const [updateSuccess, setUpdateSuccess] = useState<boolean>();
const [apiKey, setApiKey] = useState("");
@@ -132,6 +134,7 @@ export const SettingsContext: React.FC = ({ children }) => {
setScraping({ ...withoutTypename(data.configuration.scraping) });
setDLNA({ ...withoutTypename(data.configuration.dlna) });
setUI(data.configuration.ui);
setPlugins(data.configuration.plugins);
}, [data, error]);
const resetSuccess = useDebounce(() => setUpdateSuccess(undefined), 4000);
@@ -433,6 +436,63 @@ export const SettingsContext: React.FC = ({ children }) => {
});
}
// saves the configuration if no further changes are made after a half second
const savePluginConfig = useDebounce(async (input: PluginSettings) => {
try {
setUpdateSuccess(undefined);
for (const pluginID in input) {
await updatePluginConfig({
variables: {
plugin_id: pluginID,
input: input[pluginID],
},
});
}
setPendingPlugins(undefined);
onSuccess();
} catch (e) {
setSaveError(e);
}
}, 500);
useEffect(() => {
if (!pendingPlugins) {
return;
}
savePluginConfig(pendingPlugins);
}, [pendingPlugins, savePluginConfig]);
function savePluginSettings(
pluginID: string,
input: Record<string, unknown>
) {
if (!plugins) {
return;
}
setPlugins({
...plugins,
[pluginID]: input,
});
setPendingPlugins((current) => {
if (!current) {
// use full UI object to ensure nothing is wiped
return {
...plugins,
[pluginID]: input,
};
}
return {
...current,
[pluginID]: input,
};
});
}
function maybeRenderLoadingIndicator() {
if (updateSuccess === false) {
return (
@@ -448,7 +508,8 @@ export const SettingsContext: React.FC = ({ children }) => {
pendingDefaults ||
pendingScraping ||
pendingDLNA ||
pendingUI
pendingUI ||
pendingPlugins
) {
return (
<div className="loading-indicator">
@@ -480,6 +541,7 @@ export const SettingsContext: React.FC = ({ children }) => {
scraping,
dlna,
ui,
plugins,
saveGeneral,
saveInterface,
saveDefaults,
@@ -487,6 +549,7 @@ export const SettingsContext: React.FC = ({ children }) => {
saveDLNA,
saveUI,
refetch,
savePluginSettings,
}}
>
{maybeRenderLoadingIndicator()}