diff --git a/.gitignore b/.gitignore index 2bc28bbb7..fd65db239 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,6 @@ dist *storybook.log storybook-static + +# JetBrains IDEs +.idea diff --git a/src/components/device-page/AddScene.tsx b/src/components/device-page/AddScene.tsx deleted file mode 100644 index 4de5072b8..000000000 --- a/src/components/device-page/AddScene.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { memo, useCallback, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import type { Zigbee2MQTTAPI } from "zigbee2mqtt"; -import { useShallow } from "zustand/react/shallow"; -import { useAppStore } from "../../store.js"; -import type { Device, DeviceState, Group } from "../../types.js"; -import { isDevice } from "../../utils.js"; -import { sendMessage } from "../../websocket/WebSocketManager.js"; -import Button from "../Button.js"; -import DashboardFeatureWrapper from "../dashboard-page/DashboardFeatureWrapper.js"; -import Feature from "../features/Feature.js"; -import { getFeatureKey } from "../features/index.js"; -import InputField from "../form-fields/InputField.js"; -import { getScenes } from "./index.js"; - -type AddSceneProps = { - sourceIdx: number; - target: Device | Group; - deviceState: DeviceState; -}; - -const AddScene = memo(({ sourceIdx, target, deviceState }: AddSceneProps) => { - const { t } = useTranslation("scene"); - const [sceneId, setSceneId] = useState(0); - const [sceneName, setSceneName] = useState(""); - const scenes = useMemo(() => getScenes(target), [target]); - const scenesFeatures = useAppStore( - useShallow((state) => (isDevice(target) ? (state.deviceScenesFeatures[sourceIdx][target.ieee_address] ?? []) : [])), - ); - - const onCompositeChange = useCallback( - async (value: Record | unknown) => { - await sendMessage<"{friendlyNameOrId}/set">( - sourceIdx, - // @ts-expect-error templated API endpoint - `${target.friendly_name}/set`, // TODO: swap to ID/ieee_address - value, - ); - }, - [sourceIdx, target], - ); - - const onStoreClick = useCallback(async () => { - const payload: Zigbee2MQTTAPI["{friendlyNameOrId}/set"][string] = { ID: sceneId, name: sceneName || `Scene ${sceneId}` }; - - await sendMessage<"{friendlyNameOrId}/set">( - sourceIdx, - // @ts-expect-error templated API endpoint - `${target.friendly_name}/set`, // TODO: swap to ID/ieee_address - { scene_store: payload }, - ); - }, [sourceIdx, target, sceneId, sceneName]); - - const isValidSceneId = useMemo(() => { - return sceneId >= 0 && sceneId <= 255 && !scenes.find((s) => s.id === sceneId); - }, [sceneId, scenes]); - - return ( - <> -

{t(($) => $.add_update_header)}

-
- $.scene_id)} - type="number" - value={sceneId} - onChange={(e) => !!e.target.value && setSceneId(e.target.valueAsNumber)} - min={0} - max={255} - required - /> - $.scene_name)} - type="text" - value={sceneName} - placeholder={`Scene ${sceneId}`} - onChange={(e) => setSceneName(e.target.value)} - required - /> - {scenesFeatures.length > 0 && ( -
-
- {scenesFeatures.map((feature) => ( - - ))} -
-
- )} -
- - - ); -}); - -export default AddScene; diff --git a/src/components/device-page/AddUpdateScene.tsx b/src/components/device-page/AddUpdateScene.tsx new file mode 100644 index 000000000..413d88eb4 --- /dev/null +++ b/src/components/device-page/AddUpdateScene.tsx @@ -0,0 +1,170 @@ +import { type ChangeEvent, useCallback, useEffect, useEffectEvent, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { Zigbee2MQTTAPI } from "zigbee2mqtt"; +import { useShallow } from "zustand/react/shallow"; +import { useAppStore } from "../../store"; +import type { Device, DeviceState, Group, Scene } from "../../types"; +import { isDevice } from "../../utils"; +import { sendMessage } from "../../websocket/WebSocketManager"; +import Button from "../Button"; +import ConfirmButton from "../ConfirmButton"; +import DashboardFeatureWrapper from "../dashboard-page/DashboardFeatureWrapper"; +import { getFeatureKey } from "../features"; +import Feature from "../features/Feature"; +import InputField from "../form-fields/InputField"; +import { getScenes } from "./index"; + +type AddUpdateSceneAction = "add" | "update"; + +type SceneInput = { + action: AddUpdateSceneAction; +} & Scene; + +const DEFAULT_SCENE_ID = 0; +const DEFAULT_SCENE_INPUT: SceneInput = { id: DEFAULT_SCENE_ID, name: "", action: "add" }; + +const createSceneInput = (scenes: Scene[], sceneId: number): SceneInput => { + const existingScene = scenes.find((scene) => scene.id === sceneId); + + if (existingScene) { + return { + id: sceneId, + name: existingScene.name, + action: "update", + }; + } + return { + ...DEFAULT_SCENE_INPUT, + id: sceneId, + }; +}; + +type AddSceneProps = { + sourceIdx: number; + target: Device | Group; + deviceState: DeviceState; +}; + +const AddUpdateScene = ({ sourceIdx, target, deviceState }: AddSceneProps) => { + const { t } = useTranslation("scene"); + + const scenes = useMemo(() => getScenes(target), [target]); + const [sceneInput, setSceneInput] = useState(() => createSceneInput(scenes, DEFAULT_SCENE_ID)); + + const onScenesChange = useEffectEvent((scenes: Scene[]) => { + setSceneInput(createSceneInput(scenes, sceneInput.id)); + }); + + useEffect(() => { + onScenesChange(scenes); + }, [scenes]); + + useEffect(() => { + if (scenes.length === 0) { + setSceneInput(DEFAULT_SCENE_INPUT); + } + }, [scenes.length]); + + const scenesFeatures = useAppStore( + useShallow((state) => (isDevice(target) ? (state.deviceScenesFeatures[sourceIdx]?.[target.ieee_address] ?? []) : [])), + ); + + const onCompositeChange = useCallback( + async (value: Record | unknown) => { + await sendMessage<"{friendlyNameOrId}/set">( + sourceIdx, + // @ts-expect-error templated API endpoint + `${target.friendly_name}/set`, // TODO: swap to ID/ieee_address + value, + ); + }, + [sourceIdx, target], + ); + + const onStoreClick = useCallback(async () => { + setSceneInput({ ...sceneInput, action: "update" }); + const payload: Zigbee2MQTTAPI["{friendlyNameOrId}/set"][string] = { + ID: sceneInput.id, + name: sceneInput.name || `Scene ${sceneInput.id}`, + }; + + await sendMessage<"{friendlyNameOrId}/set">( + sourceIdx, + // @ts-expect-error templated API endpoint + `${target.friendly_name}/set`, // TODO: swap to ID/ieee_address + { scene_store: payload }, + ); + }, [sourceIdx, target, sceneInput]); + + const handleOnSceneIdInputChange = (e: ChangeEvent) => { + const sceneId = e.target.valueAsNumber || DEFAULT_SCENE_ID; + setSceneInput(createSceneInput(scenes, sceneId)); + }; + + const isValidSceneId = useMemo(() => { + return sceneInput.id >= 0 && sceneInput.id <= 255; + }, [sceneInput.id]); + + return ( + <> +

{t(($) => $.add_update_header)}

+
+ $.scene_id)} + type="number" + value={sceneInput.id} + onChange={handleOnSceneIdInputChange} + min={0} + max={255} + required + /> + $.scene_name)} + type="text" + value={sceneInput.name} + placeholder={`Scene ${sceneInput.id}`} + onChange={(e) => setSceneInput({ ...sceneInput, name: e.target.value })} + required + /> + {scenesFeatures.length > 0 && ( +
+
+ {scenesFeatures.map((feature) => ( + + ))} +
+
+ )} +
+ {sceneInput.action === "add" ? ( + + ) : ( + $.update_scene)} + modalDescription={t(($) => $.dialog_confirmation_prompt, { ns: "common" })} + modalCancelLabel={t(($) => $.cancel, { ns: "common" })} + > + {t(($) => $.update)} + + )} + + ); +}; + +export default AddUpdateScene; diff --git a/src/components/device-page/tabs/Scene.tsx b/src/components/device-page/tabs/Scene.tsx index b4e9d63ef..d9abb6048 100644 --- a/src/components/device-page/tabs/Scene.tsx +++ b/src/components/device-page/tabs/Scene.tsx @@ -1,7 +1,7 @@ import { useShallow } from "zustand/react/shallow"; import { useAppStore } from "../../../store.js"; import type { Device } from "../../../types.js"; -import AddScene from "../AddScene.js"; +import AddUpdateScene from "../AddUpdateScene"; import RecallRemove from "../RecallRemove.js"; type SceneProps = { @@ -16,7 +16,7 @@ export default function Scene({ sourceIdx, device }: SceneProps) {
- +
diff --git a/src/components/group-page/tabs/Devices.tsx b/src/components/group-page/tabs/Devices.tsx index 9e54597c0..429086614 100644 --- a/src/components/group-page/tabs/Devices.tsx +++ b/src/components/group-page/tabs/Devices.tsx @@ -1,7 +1,7 @@ import { useShallow } from "zustand/react/shallow"; import { useAppStore } from "../../../store.js"; import type { Group } from "../../../types.js"; -import AddScene from "../../device-page/AddScene.js"; +import AddUpdateScene from "../../device-page/AddUpdateScene"; import RecallRemove from "../../device-page/RecallRemove.js"; import AddDeviceToGroup from "../AddDeviceToGroup.js"; import GroupMembers from "../GroupMembers.js"; @@ -29,7 +29,7 @@ export default function Devices({ sourceIdx, group }: DevicesProps) {
- +
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5ad713bf5..98c4017bc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -408,9 +408,12 @@ "scene_id": "Scene ID", "recall": "Recall", "store": "Store", + "update": "Update", "remove_all": "Remove all", "select_scene": "Select Scene", - "scene_name": "Scene Name" + "scene_name": "Scene Name", + "update_scene": "Update Scene", + "add_scene": "Add Scene" }, "stats": { "byType": "By device type",