Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/mui-material.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,19 @@ interface MeterPaletteOptions extends ExtendPaletteOptions<{

declare module "@mui/material/styles" {
interface Palette {
[key: string]: Palette["primary"] & {
onColor?: string;
offColor?: string;
borderColor?: string;
lineColor?: string;
needleColor?: string;
selectedColor?: string;
deselectedColor?: string;
fillColor?: string;
emptyColor?: string;
};
arc: Palette["primary"];
actionbutton: Palette["primary"];
boolbutton: Palette["primary"] & {
onColor: string;
offColor: string;
Expand Down Expand Up @@ -64,6 +76,7 @@ declare module "@mui/material/styles" {
}

interface PaletteOptions {
actionbutton?: PaletteOptions["primary"];
arc?: PaletteOptions["primary"];
boolbutton?: Partial<PaletteOptions["primary"]> & {
onColor: string;
Expand Down
1 change: 1 addition & 0 deletions src/redux/csWebLibConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export type CsWebLibConfig = {
THROTTLE_PERIOD: number | undefined;
defaultMjpgEndpoint: string | undefined;
csWebLibFeatureFlags: FeatureFlags;
classFile?: string;
};
11 changes: 7 additions & 4 deletions src/redux/slices/configurationSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ import { CsWebLibConfig } from "../csWebLibConfig";
* This reducer is intended to be initialized via `preloadedState`.
* The initialState here exists only as a type-safe fallback.
*/
const initialState: CsWebLibConfig = {
export const initialState: CsWebLibConfig = {
storeMode: "DEV",
PVWS_SOCKET: "",
PVWS_SSL: true,
THROTTLE_PERIOD: 100,
defaultMjpgEndpoint: "",
csWebLibFeatureFlags: {
enableDynamicScripts: false
}
},
classFile: undefined
};

/**
Expand All @@ -31,7 +32,8 @@ export const configurationSlice = createSlice({
selectFeatureFlags: state => state.csWebLibFeatureFlags,
selectEnableDynamicScripts: state =>
state.csWebLibFeatureFlags?.enableDynamicScripts ?? false,
selectDefaultMjpgEndpoint: state => state.defaultMjpgEndpoint
selectDefaultMjpgEndpoint: state => state.defaultMjpgEndpoint,
selectClassFile: state => state.classFile
}
});

Expand All @@ -41,5 +43,6 @@ export const {
selectConfiguration,
selectFeatureFlags,
selectEnableDynamicScripts,
selectDefaultMjpgEndpoint
selectDefaultMjpgEndpoint,
selectClassFile
} = configurationSlice.selectors;
8 changes: 6 additions & 2 deletions src/testResources.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
NotificationStack
} from "./redux/slices/notificationsSlice";
import { FileCache, FileCacheState } from "./redux/slices/fileCacheSlice";
import { CsWebLibConfig } from "./redux";
import { initialState } from "./redux/slices/configurationSlice";

// Helper functions for dtypes.
export function ddouble(
Expand Down Expand Up @@ -79,11 +81,13 @@ export const ACTIONS_EX_FIRST = {
export const createRootStoreState = (
csState?: CsState,
notifications?: NotificationStack,
fileCache?: FileCacheState
fileCache?: FileCacheState,
configuration?: CsWebLibConfig
) => ({
cs: csState ?? initialCsState,
notifications: notifications ?? initialNotificationsState,
fileCache: fileCache ?? ({} as FileCache)
fileCache: fileCache ?? ({} as FileCache),
configuration: configuration ?? initialState
});

export const contextWrapperGenerator = (
Expand Down
114 changes: 114 additions & 0 deletions src/ui/hooks/useClassFile.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import React from "react";
import { contextRender, createRootStoreState } from "../../testResources";
import { vi } from "vitest";
import { act, screen } from "@testing-library/react";
import { ensureWidgetsRegistered } from "../widgets";
import { useClassFile } from "./useClassFile";
import { CsWebLibConfig } from "../../redux";
import { phoebusTheme } from "../../phoebusTheme";
import { createTheme } from "@mui/material";
import { getFileState } from "./useFile.test";
ensureWidgetsRegistered();

const initialState: CsWebLibConfig = {
storeMode: "DEV",
PVWS_SOCKET: "",
PVWS_SSL: true,
THROTTLE_PERIOD: 100,
defaultMjpgEndpoint: "",
csWebLibFeatureFlags: {
enableDynamicScripts: false
}
};

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace NodeJS {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Global {}
}
}

interface GlobalFetch extends NodeJS.Global {
fetch: any;
}
const globalWithFetch = global as GlobalFetch;

const ClassFileTester = (): JSX.Element => {
const contents = useClassFile();
return <div>contents: {JSON.stringify(contents.palette)}</div>;
};

describe("useClassFile", (): void => {
it("returns default phoebus theme if no classfile", (): void => {
const { getByText } = contextRender(
<ClassFileTester />,
{},
{},
createRootStoreState(getFileState(), undefined, undefined, initialState),
{}
);

expect(
getByText(`contents: ${JSON.stringify(phoebusTheme.palette)}`)
).toBeInTheDocument();
});

it("returns theme with classes if classfile", async (): Promise<void> => {
const mockSuccessResponse = `<?xml version="1.0" encoding="UTF-8"?>
<display version="2.0.0">
<name>Widget Classes</name>
<y use_class="true">0</y>
<widget type="action_button" version="3.0.0">
<name>MY_CLASS</name>
<x>390</x>
<y>180</y>
<foreground_color use_class="true">
<color name="Text" red="0" green="0" blue="0">
</color>
</foreground_color>
<background_color use_class="true">
<color name="STOP" red="0" green="0" blue="255">
</color>
</background_color>
<tooltip>$(actions)</tooltip>
</widget>
</display>`;
const mockJsonPromise = Promise.resolve(mockSuccessResponse);
const mockFetchPromise = Promise.resolve({
text: (): Promise<unknown> => mockJsonPromise
});
const mockFetch = (): Promise<unknown> => mockFetchPromise;
vi.spyOn(globalWithFetch, "fetch").mockImplementation(mockFetch);
await act(async () => {
contextRender(
<ClassFileTester />,
{},
{},
createRootStoreState(getFileState(), undefined, undefined, {
classFile: "myclass.bcf",
...initialState
}),
{}
);
});

const responseContent = JSON.stringify(
createTheme({
customName: "class",
palette: {
...phoebusTheme.palette,
...{
MY_CLASSactionbutton: {
main: "rgba(0,0,255,1)",
contrastText: "rgba(0,0,0,1)"
}
}
}
}).palette
);
expect(
screen.getByText(`contents: ${responseContent}`)
).toBeInTheDocument();
});
});
89 changes: 89 additions & 0 deletions src/ui/hooks/useClassFile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useSelector } from "react-redux";
import { useEffect, useState } from "react";
import { createTheme, Theme } from "@mui/material";
import { fetchAndConvert } from "./useFile";
import { WidgetDescription } from "../widgets/createComponent";
import { selectClassFile } from "../../redux/slices/configurationSlice";
import { phoebusTheme } from "../../phoebusTheme";

// Map widget props to MUI theme props
const keyMap: Record<string, string> = {
backgroundColor: "main",
foregroundColor: "contrastText"
};

const CLASS_PROPS = new Set([
"offColor",
"onColor",
"foregroundColor",
"backgroundColor",
"lineColor",
"emptyColor",
"knobColor",
"color",
"fillColor",
"needleColor",
"selectedColor",
"deselectedColor",
"borderColor"
]);

export function useClassFile(userTheme?: Theme): Theme {
const classFile = useSelector(selectClassFile);
const [theme, setTheme] = useState<Theme>(userTheme ?? phoebusTheme);
Comment thread
abigailalexander marked this conversation as resolved.

useEffect(() => {
const fetchData = async (): Promise<void> => {
const widgetDescription = await fetchAndConvert(
classFile as string,
"ca",
{}
);
setTheme(createClassPalettes(widgetDescription));
};

if (classFile !== undefined) {
fetchData();
}
}, [classFile, userTheme]);

return theme;
}

export function createClassPalettes(classFile: WidgetDescription): Theme {
// If classfile is empty, do nothing
if (!classFile.children) return phoebusTheme;

const palette: { [key: string]: any } = {};
classFile.children?.forEach((child: WidgetDescription) => {
const widgetType: string = child.type;
// Construct palette name from widget type and classname
const paletteName = `${child.name}${widgetType}`;

// Only colors go in the theme palette
const matches = Object.entries(child)
.filter(([key]) => CLASS_PROPS.has(key))
.map(([key, value]) => ({ key, value }));

// Assign colors to palette
palette[paletteName] = {
// Put Phoebus theme defaults, overwrite with class props
...phoebusTheme.palette[widgetType],
...Object.fromEntries(
matches.map(({ key, value }) => [
keyMap[key] ?? key,
value?.colorString ?? undefined
])
)
};
});
// Create Theme
const classTheme = createTheme({
customName: "class",
palette: {
...phoebusTheme.palette,
...palette
}
});
return classTheme;
}
2 changes: 1 addition & 1 deletion src/ui/hooks/useFile.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const FileTester = (props: { file: File }): JSX.Element => {
return <div>contents: {JSON.stringify(contents)}</div>;
};

function getFileState(): CsState {
export function getFileState(): CsState {
return {
valueCache: {},
subscriptions: {},
Expand Down
6 changes: 5 additions & 1 deletion src/ui/hooks/useFile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { parseJson } from "../widgets/EmbeddedDisplay/jsonParser";
import { parseOpi } from "../widgets/EmbeddedDisplay/opiParser";
import { Store } from "redux";
import { newAbsolutePosition } from "../../types/position";
import { parseBcf } from "../widgets/EmbeddedDisplay/bcfParser";

const EMPTY_WIDGET: WidgetDescription = {
type: "shape",
Expand All @@ -29,7 +30,7 @@ export interface File {
defaultProtocol: string;
}

async function fetchAndConvert(
export async function fetchAndConvert(
filepath: string,
protocol: string,
macros?: MacroMap
Expand Down Expand Up @@ -58,6 +59,9 @@ async function fetchAndConvert(
filepath
);
break;
case "bcf":
description = await parseBcf(contents, protocol, parentDir, filepath);
break;
case "json":
description = await parseJson(
contents,
Expand Down
11 changes: 11 additions & 0 deletions src/ui/hooks/useStyle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const mockTheme = {
main: "#654321",
contrastText: "#000000",
light: "#fedcba"
},
MY_CLASSwidgetA: {
main: "#a52590",
contrastText: "#dd1c1c"
}
},
borders: {
Expand Down Expand Up @@ -118,6 +122,13 @@ describe("useStyle", () => {
});
});

it("selects the class theme over default widget theme if class exists", () => {
const { result } = renderHook(() => useStyle({}, "widgetA", "MY_CLASS"));

expect(result.current.colors.backgroundColor).toEqual("#a52590");
expect(result.current.colors.color).toEqual("#dd1c1c");
});

it("uses provided font when fontToCss returns a value", () => {
const font = newFont(12, FontStyle.Bold, "Liberation sans", "abc");
const { result } = renderHook(() => useStyle({ font }));
Expand Down
Loading
Loading