feat: add editing of remotes

This commit is contained in:
Fritz Heiden 2025-04-15 18:47:43 +02:00
parent 6a9c69a535
commit d7b8ad9976
9 changed files with 253 additions and 11 deletions

View File

@ -97,6 +97,19 @@ func (db *RemoteDatabase) CreateRemoteCommands(remoteId string, commandIds []str
return nil return nil
} }
func (db *RemoteDatabase) UpdateRemoteCommands(remoteId string, commandIds []string) error {
queryString := "DELETE FROM RemoteCommands WHERE remote_id = ?"
_, err := db.Connection.Exec(queryString, remoteId)
if err != nil {
return err
}
err = db.CreateRemoteCommands(remoteId, commandIds)
if err != nil {
return err
}
return nil
}
func (db *RemoteDatabase) GetRemote(remoteId string) (Remote, error) { func (db *RemoteDatabase) GetRemote(remoteId string) (Remote, error) {
var remote Remote var remote Remote
queryString := "SELECT id, title FROM Remotes WHERE id = ?" queryString := "SELECT id, title FROM Remotes WHERE id = ?"
@ -132,6 +145,23 @@ func (db *RemoteDatabase) GetRemotes() ([]Remote, error) {
return remotes, nil return remotes, nil
} }
func (db *RemoteDatabase) UpdateRemote(remote Remote) error {
queryString := "UPDATE Remotes SET title = ? WHERE id = ?"
_, error := db.Connection.Exec(queryString, remote.Title, remote.Id)
if error != nil {
return fmt.Errorf("error updating remote %s: %s", remote.Id, error)
}
commandIds := []string{}
for _, command := range remote.Commands {
commandIds = append(commandIds, command.Id)
}
err := db.UpdateRemoteCommands(remote.Id, commandIds)
if err != nil {
return err
}
return nil
}
func (db *RemoteDatabase) DeleteRemote(remoteId string) error { func (db *RemoteDatabase) DeleteRemote(remoteId string) error {
queryString := "DELETE FROM Remotes WHERE id = ?" queryString := "DELETE FROM Remotes WHERE id = ?"
_, error := db.Connection.Exec(queryString, remoteId) _, error := db.Connection.Exec(queryString, remoteId)

View File

@ -33,6 +33,10 @@ func (rm *RemoteManager) GetRemotes() ([]d.Remote, error) {
return remotes, nil return remotes, nil
} }
func (rm *RemoteManager) UpdateRemote(remote d.Remote) error {
return rm.remoteDatabase.UpdateRemote(remote)
}
func (rm *RemoteManager) DeleteRemote(remoteID string) error { func (rm *RemoteManager) DeleteRemote(remoteID string) error {
return rm.remoteDatabase.DeleteRemote(remoteID) return rm.remoteDatabase.DeleteRemote(remoteID)
} }

View File

@ -19,7 +19,8 @@ func (r *RemoteApiHandler) Initialize(authenticator *Authenticator) {
remotesApi.GET("", r.handleGetRemotes) remotesApi.GET("", r.handleGetRemotes)
remotesApi.GET("/:id", r.handleGetRemote) remotesApi.GET("/:id", r.handleGetRemote)
remotesApi.POST("", r.handleCreateRemote) remotesApi.POST("", r.handleCreateRemote)
remotesApi.DELETE("/:id", r.handleDelteRemote) remotesApi.PUT("/:id", r.handleUpdateRemote)
remotesApi.DELETE("/:id", r.handleDeleteRemote)
r.router.Use(authenticator.Authenticate("/api/commands", []string{})) r.router.Use(authenticator.Authenticate("/api/commands", []string{}))
commandsApi := r.router.Group("/api/commands") commandsApi := r.router.Group("/api/commands")
@ -65,7 +66,21 @@ func (r *RemoteApiHandler) handleGetRemotes(context echo.Context) error {
return context.JSON(200, remotes) return context.JSON(200, remotes)
} }
func (r *RemoteApiHandler) handleDelteRemote(context echo.Context) error { func (r *RemoteApiHandler) handleUpdateRemote(context echo.Context) error {
remote := d.Remote{}
if err := context.Bind(&remote); err != nil {
SendError(400, context, err.Error())
return nil
}
err := r.remoteManager.UpdateRemote(remote)
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, "")
}
func (r *RemoteApiHandler) handleDeleteRemote(context echo.Context) error {
id := context.Param("id") id := context.Param("id")
err := r.remoteManager.DeleteRemote(id) err := r.remoteManager.DeleteRemote(id)
if err != nil { if err != nil {

View File

@ -1,21 +1,23 @@
import { createEffect, createMemo, createSignal, mergeProps } from "solid-js"; import { createMemo, createSignal, mergeProps } from "solid-js";
function ListManager(props) { function ListManager(props) {
props = mergeProps( props = mergeProps(
{ {
items: [], items: [],
availableItems: [], availableItems: [],
itemToString: () => "",
style: "", style: "",
onItemSelect: () => {},
onItemDeselect: () => {},
itemsTitle: "Selected items", itemsTitle: "Selected items",
availableItemsTitle: "Available items", availableItemsTitle: "Available items",
itemToString: () => "",
onItemSelect: () => {},
onItemDeselect: () => {},
itemsEqual: (a, b) => a === b,
}, },
props props
); );
const itemToString = (item) => props.itemToString(item); const itemToString = (item) => props.itemToString(item);
const byLabel = (a, b) => itemToString(a).localeCompare(itemToString(b));
const [selectedAvailableItemIndex, setSelectedAvailableItemIndex] = const [selectedAvailableItemIndex, setSelectedAvailableItemIndex] =
createSignal(-1); createSignal(-1);
const [selectedItemIndex, setSelectedItemIndex] = createSignal(-1); const [selectedItemIndex, setSelectedItemIndex] = createSignal(-1);
@ -42,11 +44,16 @@ function ListManager(props) {
.search(availableItemsSearchString()) .search(availableItemsSearchString())
.map((item) => item.item) .map((item) => item.item)
: props.availableItems : props.availableItems
).filter((item) => !props.items.includes(item)) ).filter(
(availableItem) =>
!props.items.find((item) => props.itemsEqual(item, availableItem))
)
); );
const selectableItems = createMemo(() => const selectableItems = createMemo(() =>
itemsSearchString() itemsSearchString()
? itemsFuse().search(itemsSearchString()).map((item) => item.item) ? itemsFuse()
.search(itemsSearchString())
.map((item) => item.item)
: props.items : props.items
); );
const canSelect = createMemo( const canSelect = createMemo(
@ -120,7 +127,7 @@ function ListManager(props) {
/> />
</div> </div>
<div class="rounded rounded-top-0 border bg-body flex-fill overflow-y-scroll"> <div class="rounded rounded-top-0 border bg-body flex-fill overflow-y-scroll">
{props.items.map((item, index) => ( {props.items.sort(byLabel).map((item, index) => (
<ListItem <ListItem
onClick={() => props.onItemSelected(index)} onClick={() => props.onItemSelected(index)}
selected={index === props.selectedItemIndex} selected={index === props.selectedItemIndex}

View File

@ -1,4 +1,4 @@
function Remote({ id, title, commands = [] } = {}) { function Remote({ id = "", title = "", commands = [] } = {}) {
let _id = id; let _id = id;
let _title = title; let _title = title;
let _commands = commands; let _commands = commands;

View File

@ -0,0 +1,145 @@
import {
createEffect,
createMemo,
createResource,
createSignal,
} from "solid-js";
import ListManager from "../components/list-manager.jsx";
import ValidatedTextInput from "../components/validated-text-input.jsx";
import Remote from "../data/remote.js";
import RemoteService from "../services/remotes-service.js";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
const eventEmitter = new EventEmitter();
const REMOTE_EDITED_EVENT = "success";
const MIN_TITLE_LENGTH = 3;
const modalHandler = new ModalHandler();
const [remoteId, setRemoteId] = createSignal(null);
function EditRemoteModal(props) {
const [title, setTitle] = createSignal("");
const [commands, setCommands] = createSignal([]);
const [availableCommands, { refetch: refetchAvailableCommands }] =
createResource(RemoteService.getCommands);
const [remote, {}] = createResource(
remoteId,
() => RemoteService.getRemote(remoteId()),
{ initialValue: new Remote() }
);
const [error, setError] = createSignal("");
const isTitleValid = createMemo(() => title().length >= MIN_TITLE_LENGTH);
const isFormValid = createMemo(() => isTitleValid());
createEffect(() => {
if (!remote()) return;
setTitle(remote().getTitle());
setCommands(remote().getCommands());
});
modalHandler.onShow(() => {
refetchAvailableCommands();
});
async function handleEditRemote() {
try {
await RemoteService.updateRemote(
new Remote({
id: remote().getId(),
title: title(),
commands: commands(),
})
);
} catch (e) {
setError(e.message);
console.error(e);
return;
}
resetFields();
EditRemoteModal.Handler.hide();
eventEmitter.dispatchEvent(REMOTE_EDITED_EVENT);
}
function resetFields() {
setTitle("");
setCommands([]);
setRemoteId("");
setError("");
}
function handleCommandSelect(item) {
setCommands([...commands(), item]);
}
function handleCommandDeselect(item) {
setCommands(
commands().filter((command) => command.getId() !== item.getId())
);
}
return (
<Modal
ref={props.ref}
id="editRemoteModal"
modalTitle="New Remote"
centered={true}
>
<div class="modal-body bg-body-tertiary">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<label for="edit_remote_title" class="col-form-label col-sm-1">
Title
</label>
<ValidatedTextInput
class="col-sm-11"
id="edit_remote_title"
valid={isTitleValid()}
value={title()}
onInput={(e) => setTitle(e.target.value)}
errorText={`Title must be at least ${MIN_TITLE_LENGTH} characters long`}
/>
</div>
<ListManager
style="height: 20em;"
items={commands()}
availableItems={availableCommands()}
itemToString={(command) =>
`${command.getTitle()} (${command.getProtocol()}:${command.getCommandType()})`
}
onItemSelect={handleCommandSelect}
onItemDeselect={handleCommandDeselect}
itemsTitle="Selected Commands"
availableItemsTitle="Available Commands"
itemsEqual={(a, b) => a.getId() === b.getId()}
/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
onClick={handleEditRemote}
class="btn btn-primary"
disabled={!isFormValid()}
>
Edit
</button>
</div>
</Modal>
);
}
EditRemoteModal.Handler = modalHandler;
EditRemoteModal.onRemoteEdited = (callback) =>
eventEmitter.on(REMOTE_EDITED_EVENT, callback);
EditRemoteModal.setRemoteId = setRemoteId;
export default EditRemoteModal;

View File

@ -12,6 +12,7 @@ import CreateRemoteModal from "./create-remote-modal.jsx";
import DeleteRemoteModal from "./delete-remote-modal.jsx"; import DeleteRemoteModal from "./delete-remote-modal.jsx";
import ImportCommandsModal from "./import-commands-modal.jsx"; import ImportCommandsModal from "./import-commands-modal.jsx";
import EditCommandModal from "./edit-command-modal.jsx"; import EditCommandModal from "./edit-command-modal.jsx";
import EditRemoteModal from "./edit-remote-modal.jsx";
const ModalRegistry = (function () { const ModalRegistry = (function () {
const modals = [ const modals = [
@ -75,6 +76,11 @@ const ModalRegistry = (function () {
component: EditCommandModal, component: EditCommandModal,
ref: null, ref: null,
}, },
{
id: "editRemoteModal",
component: EditRemoteModal,
ref: null,
}
]; ];
function getModals(props) { function getModals(props) {

View File

@ -6,6 +6,7 @@ const MESSAGE_TYPE_COMMAND = "command";
function RemoteService() { function RemoteService() {
async function getRemote(remoteId) { async function getRemote(remoteId) {
if (!remoteId) return null;
let response = await Net.sendRequest({ let response = await Net.sendRequest({
method: "GET", method: "GET",
url: "/api/remotes/" + remoteId, url: "/api/remotes/" + remoteId,
@ -49,6 +50,20 @@ function RemoteService() {
} }
} }
async function updateRemote(remote) {
let remoteObject = Serializer.serializeRemote(remote);
let response = await Net.sendJsonRequest({
method: "PUT",
url: "/api/remotes/" + remote.getId(),
data: remoteObject,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
async function deleteRemote(remoteId) { async function deleteRemote(remoteId) {
let response = await Net.sendRequest({ let response = await Net.sendRequest({
method: "DELETE", method: "DELETE",
@ -132,6 +147,7 @@ function RemoteService() {
getRemote, getRemote,
getRemotes, getRemotes,
createRemote, createRemote,
updateRemote,
deleteRemote, deleteRemote,
getCommands, getCommands,
createCommand, createCommand,

View File

@ -6,6 +6,7 @@ import DeleteRemoteModal from "../../modals/delete-remote-modal";
import FileUtils from "../../tools/file-utils"; import FileUtils from "../../tools/file-utils";
import Serializer from "../../data/serializer"; import Serializer from "../../data/serializer";
import RemoteService from "../../services/remotes-service"; import RemoteService from "../../services/remotes-service";
import EditRemoteModal from "../../modals/edit-remote-modal";
function RemotesList(props) { function RemotesList(props) {
const [remotes, { refetch: refetchRemotes }] = createResource( const [remotes, { refetch: refetchRemotes }] = createResource(
@ -26,10 +27,19 @@ function RemotesList(props) {
refetchRemotes(); refetchRemotes();
}); });
EditRemoteModal.onRemoteEdited(() => {
refetchRemotes();
});
function handleNewRemote() { function handleNewRemote() {
CreateRemoteModal.Handler.show(); CreateRemoteModal.Handler.show();
} }
function handleEditRemote(remote) {
EditRemoteModal.setRemoteId(remote.getId());
EditRemoteModal.Handler.show();
}
function handleDeleteRemote(remote) { function handleDeleteRemote(remote) {
DeleteRemoteModal.setRemote(remote); DeleteRemoteModal.setRemote(remote);
DeleteRemoteModal.Handler.show(); DeleteRemoteModal.Handler.show();
@ -90,7 +100,7 @@ function RemotesList(props) {
{ {
id: "options", id: "options",
name: "", name: "",
width: 4, width: 6,
}, },
]} ]}
onListItemClick={() => {}} onListItemClick={() => {}}
@ -105,6 +115,15 @@ function RemotesList(props) {
options: { options: {
html: ( html: (
<> <>
<button
class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => {
event.stopPropagation();
handleEditRemote(remote);
}}
>
<i class="bi bi-pencil-fill"></i>
</button>
<button <button
class="btn btn-sm btn-outline-secondary me-2" class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => { onClick={(event) => {