feat: add import commands modal

This commit is contained in:
Fritz Heiden 2025-04-10 19:46:54 +02:00
parent 3ba3f5efd5
commit 7a20ae1536
7 changed files with 545 additions and 17 deletions

View File

@ -14,6 +14,7 @@
href="./src/lib/bootstrap-icons-1.11.3/font/bootstrap-icons.css"
rel="stylesheet"
/>
<script src="./lib/papaparse-5.5.2.min.js"></script>
<script src="./lib/fusejs-7.1.0.min.js"></script>
<script src="./lib/popper.min.js"></script>
<script src="./lib/bootstrap-5.3.3-dist/js/bootstrap.bundle.min.js"></script>

View File

@ -1,7 +1,7 @@
function Command({
id,
protocol,
command: commandNumber,
commandNumber,
device,
commandType,
title,
@ -54,7 +54,8 @@ function Command({
}
function getTitle() {
return _title;
if (_title) return _title;
return `${Command.CommandTypes[commandType]} (${Command.Protocols[protocol]})`;
}
function setTitle(title) {
@ -102,7 +103,7 @@ Command.Protocols = {
fast: "FAST",
whynter: "Whynter",
magiquest: "MagiQuest",
}
};
Command.CommandTypes = {
power: "Power",
@ -126,13 +127,13 @@ Command.CommandTypes = {
home: "Home",
settings: "Settings",
options: "Options",
up_arrow: "Up Arrow",
down_arrow: "Down Arrow",
left_arrow: "Left Arrow",
right_arrow: "Right Arrow",
select: "Select",
up: "Up",
down: "Down",
left: "Left",
right: "Right",
enter: "Enter",
info: "Info",
back: "Back",
return: "Return",
exit: "Exit",
red: "Red",
green: "Green",
@ -144,6 +145,6 @@ Command.CommandTypes = {
stop: "Stop",
forward: "Forward",
other: "Other",
}
};
export default Command;

7
www/src/lib/papaparse-5.5.2.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,489 @@
import {
createEffect,
createMemo,
createSignal,
Match,
on,
Show,
Switch,
} from "solid-js";
import RemotesService from "../services/remotes-service.js";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
import Command from "../data/command.js";
const eventEmitter = new EventEmitter();
const COMMANDS_IMPORTED_EVENT = "success";
const ENTER_CSV_STEP = 1;
const MAP_FIELDS_STEP = 2;
const MAP_VALUES_STEP = 3;
const CONFIRM_STEP = 4;
const TOTAL_STEPS = 4;
const PROTOCOL_FIELD = "protocol";
const COMMAND_NUMBER_FIELD = "commandNumber";
const DEVICE_FIELD = "device";
const COMMAND_TYPE_FIELD = "commandType";
const TITLE_FIELD = "title";
let FieldTitles = {};
FieldTitles[PROTOCOL_FIELD] = "Protocol";
FieldTitles[COMMAND_NUMBER_FIELD] = "Command";
FieldTitles[DEVICE_FIELD] = "Device";
FieldTitles[COMMAND_TYPE_FIELD] = "Type";
FieldTitles[TITLE_FIELD] = "Title";
const CommandFieldsRequiringValueMapping = ["protocol", "commandType"];
const commandTypeFuse = new Fuse(Object.values(Command.CommandTypes));
const protocolsFuse = new Fuse(Object.values(Command.Protocols));
function ImportCommandsModal(props) {
const [csvString, setCsvString] = createSignal("");
const [currentStep, setCurrentStep] = createSignal(ENTER_CSV_STEP);
const [error, setError] = createSignal("");
const [csvArray, setCsvArray] = createSignal([]);
const [commands, setCommands] = createSignal([]);
const [fieldMapping, setFieldMapping] = createSignal(
{},
{ equals: () => false }
);
const [isComputingValueMapping, setComputingValueMapping] =
createSignal(false);
const [valueMapping, setValueMapping] = createSignal(
(() => {
let mapping = {};
mapping[PROTOCOL_FIELD] = {};
mapping[COMMAND_TYPE_FIELD] = {};
return mapping;
})(),
{ equals: () => false }
);
const canMakeNextStep = createMemo(() => {
switch (currentStep()) {
case ENTER_CSV_STEP:
return csvString() !== "";
default:
return true;
}
});
const fields = createMemo(() =>
Object.keys(csvArray().find(() => true) || {})
);
createEffect(() => {
switch (currentStep()) {
case MAP_VALUES_STEP:
onMapValuesStep();
break;
}
});
function handleNextStep() {
switch (currentStep()) {
case ENTER_CSV_STEP:
handleEnterCsvStep();
break;
case MAP_FIELDS_STEP:
handleMapFieldsStep();
break;
case MAP_VALUES_STEP:
handleMapValuesStep();
break;
case CONFIRM_STEP:
handleImportCommands();
break;
default:
nextStep();
}
}
async function handleImportCommands() {
let newCommands = [];
try {
newCommands = await RemotesService.createCommands(commands());
} catch (e) {
setError(e.message);
return;
}
resetFields();
ImportCommandsModal.Handler.hide();
eventEmitter.dispatchEvent(COMMANDS_IMPORTED_EVENT, newCommands);
}
function EnterCsvStep() {
return (
<div class="mb-3">
<label for="csvTextarea" class="form-label">
Enter CSV:
</label>
<textarea
class="form-control"
id="csvTextarea"
rows="10"
value={csvString()}
onInput={(e) => setCsvString(e.target.value)}
></textarea>
</div>
);
}
function handleEnterCsvStep() {
if (!csvString()) {
setError("Please enter a CSV string.");
return;
}
let result = Papa.parse(csvString(), { header: true });
if (result.errors.length > 0) {
setError(result.errors[0].message);
return;
}
let csvArray = result.data;
setCsvArray(csvArray);
nextStep();
}
function MapFieldsStep() {
function setMapping(field, commandField) {
let mapping = fieldMapping();
mapping[field] = commandField;
setFieldMapping(mapping);
}
return (
<div class="mb-3">
<div>Map Fields:</div>
<div class="d-flex flex-column">
{fields().map((field) => (
<div class="d-flex flex-row align-items-center justify-content-center text-end mb-2">
<div class="col-sm-4">
<div>{field}</div>
<div class="fw-light lh-1">(e.g. {csvArray()[0][field]})</div>
</div>
<div style="font-size: 1.5rem; line-height: 1;">
<i class="bi bi-arrow-right-short"></i>
</div>
<div class="col-sm-4">
<select
class="form-select"
onChange={(e) => setMapping(field, e.target.value)}
>
<option value="" selected>
Please select
</option>
{Object.keys(FieldTitles).map((commandField) => (
<option
value={commandField}
selected={fieldMapping()[field] === commandField}
>
{FieldTitles[commandField]}
</option>
))}
</select>
</div>
</div>
))}
</div>
</div>
);
}
function handleMapFieldsStep() {
let usedCommandFields = [];
let mapping = fieldMapping();
for (let field in mapping) {
if (usedCommandFields.includes(mapping[field])) {
setError("Duplicate mapping found.");
return false;
}
usedCommandFields.push(mapping[field]);
}
nextStep();
}
function MapValuesStep() {
function setMapping(field, value, commandValue) {
let mapping = valueMapping();
if (!mapping[field]) mapping[field] = {};
mapping[field][value] = commandValue;
setValueMapping(mapping);
}
return (
<div class="mb-3">
<div>Map Values:</div>
{Object.keys(fieldMapping())
.filter((field) =>
CommandFieldsRequiringValueMapping.includes(fieldMapping()[field])
)
.map((csvField) => {
let field = fieldMapping()[csvField];
return (
<div>
<div>{FieldTitles[field]}:</div>
<div class="d-flex flex-column">
{csvArray()
.map((row) => row[csvField])
.filter(
(value, index, array) => array.indexOf(value) === index
)
.map((value) => (
<div class="d-flex flex-row align-items-center justify-content-center text-end mb-2">
<div class="col-sm-4">
<div>{value}</div>
</div>
<div style="font-size: 1.5rem; line-height: 1;">
<i class="bi bi-arrow-right-short"></i>
</div>
<div class="col-sm-4">
<Switch>
<Match when={field === "protocol"}>
<select
class="form-select"
onChange={(e) =>
setMapping(field, value, e.target.value)
}
>
<option value="" selected>
Please select
</option>
{Object.keys(Command.Protocols).map(
(protocol) => (
<option
value={protocol}
selected={
valueMapping()[PROTOCOL_FIELD][
value
] === protocol
}
>
{Command.Protocols[protocol]}
</option>
)
)}
</select>
</Match>
<Match when={field === "commandType"}>
<select
class="form-select"
onChange={(e) =>
setMapping(field, value, e.target.value)
}
>
<option value="" selected>
Please select
</option>
{Object.keys(Command.CommandTypes).map(
(commandType) => (
<option
value={commandType}
selected={
valueMapping()[COMMAND_TYPE_FIELD][
value
] === commandType
}
>
{Command.CommandTypes[commandType]}
</option>
)
)}
</select>
</Match>
</Switch>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
);
}
function onMapValuesStep() {
setComputingValueMapping(true);
(async () => {
let valueMapping = {};
valueMapping[PROTOCOL_FIELD] = {};
valueMapping[COMMAND_TYPE_FIELD] = {};
let protocolField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === PROTOCOL_FIELD
);
let commandTypeField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === COMMAND_TYPE_FIELD
);
csvArray().forEach((row) => {
let protocolValue = row[protocolField];
let commandTypeValue = row[commandTypeField];
if (!valueMapping[PROTOCOL_FIELD][protocolValue]) {
let result = protocolsFuse.search(protocolValue).shift();
if (result) {
let protocol = Object.keys(Command.Protocols).find(
(protocol) => Command.Protocols[protocol] === result.item
);
valueMapping[PROTOCOL_FIELD][protocolValue] = protocol;
}
}
if (!valueMapping[COMMAND_TYPE_FIELD][commandTypeValue]) {
let result = commandTypeFuse.search(commandTypeValue).shift();
if (result) {
let commandType = Object.keys(Command.CommandTypes).find(
(commandType) => Command.CommandTypes[commandType] === result.item
);
valueMapping[COMMAND_TYPE_FIELD][commandTypeValue] = commandType;
}
}
});
setValueMapping(valueMapping);
setComputingValueMapping(false);
})();
}
function handleMapValuesStep() {
let protocolField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === PROTOCOL_FIELD
);
let commandNumberField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === COMMAND_NUMBER_FIELD
);
let deviceField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === DEVICE_FIELD
);
let commandTypeField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === COMMAND_TYPE_FIELD
);
let titleField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === TITLE_FIELD
);
let commands = csvArray()
.map((row) => {
let protocol = valueMapping()[PROTOCOL_FIELD][row[protocolField]];
if (!protocol) return null;
let commandNumber = row[commandNumberField];
let device = row[deviceField];
let commandType =
valueMapping()[COMMAND_TYPE_FIELD][row[commandTypeField]];
if (!commandType) return null;
let title = row[titleField];
let command = new Command({
protocol,
commandNumber,
device,
commandType,
title,
});
return command;
})
.filter((command) => command !== null);
setCommands(commands);
nextStep();
}
function ConfirmStep() {
return (
<div class="mb-3">
<div>Confirm:</div>
<div class="d-flex flex-column">
{commands().map((command) => (
<div class="d-flex flex-row align-items-center justify-content-center mb-2">
<div class="col-sm-2">{command.getProtocol()}</div>
<div class="col-sm-2">{command.getCommandNumber()}</div>
<div class="col-sm-2">{command.getDevice()}</div>
<div class="col-sm-2">{command.getCommandType()}</div>
<div class="col-sm-2">{command.getTitle()}</div>
</div>
))}
</div>
</div>
);
}
function nextStep() {
if (currentStep() >= TOTAL_STEPS) return;
setError("");
setCurrentStep(currentStep() + 1);
}
function previousStep() {
if (currentStep() <= 1) return;
setError("");
setCurrentStep(currentStep() - 1);
}
function resetFields() {
setCsvString("");
setError("");
}
return (
<Modal
ref={props.ref}
id="importCommandsModal"
modalTitle="Import Commands from CSV"
centered={true}
>
<div class="modal-body">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<Switch>
<Match when={currentStep() === ENTER_CSV_STEP}>
<EnterCsvStep />
</Match>
<Match when={currentStep() === MAP_FIELDS_STEP}>
<MapFieldsStep />
</Match>
<Match when={currentStep() === MAP_VALUES_STEP}>
<MapValuesStep />
</Match>
<Match when={currentStep() === CONFIRM_STEP}>
<ConfirmStep />
</Match>
</Switch>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<Show when={currentStep() > 1}>
<button
type="button"
onClick={previousStep}
class="btn btn-secondary"
disabled={false}
>
Back
</button>
</Show>
<Show when={currentStep() < TOTAL_STEPS}>
<button
type="button"
onClick={handleNextStep}
class="btn btn-secondary"
disabled={!canMakeNextStep()}
>
Next
</button>
</Show>
<Show when={currentStep() === TOTAL_STEPS}>
<button
type="button"
onClick={handleImportCommands}
class="btn btn-primary"
disabled={false}
>
Import
</button>
</Show>
</div>
</Modal>
);
}
ImportCommandsModal.Handler = new ModalHandler();
ImportCommandsModal.onCommandsImported = (callback) =>
eventEmitter.on(COMMANDS_IMPORTED_EVENT, callback);
export default ImportCommandsModal;

View File

@ -10,6 +10,7 @@ import CreateCommandModal from "./create-command-modal.jsx";
import DeleteCommandModal from "./delete-command-modal.jsx";
import CreateRemoteModal from "./create-remote-modal.jsx";
import DeleteRemoteModal from "./delete-remote-modal.jsx";
import ImportCommandsModal from "./import-commands-modal.jsx";
const ModalRegistry = (function () {
const modals = [
@ -62,6 +63,11 @@ const ModalRegistry = (function () {
id: "deleteRemoteModal",
component: DeleteRemoteModal,
ref: null,
},
{
id: "importCommandsModal",
component: ImportCommandsModal,
ref: null,
}
];

View File

@ -2,7 +2,7 @@ import Command from "../data/command";
import Serializer from "../data/serializer";
function RemotesService() {
let commands = [
let _commands = [
new Command({
id: 1,
protocol: "samsung",
@ -50,21 +50,31 @@ function RemotesService() {
}
async function getCommands() {
return [].concat(commands);
return [].concat(_commands);
}
async function createCommand(commandObject) {
let command = Serializer.deserializeCommand(commandObject);
let id = Math.random().toString(36).substr(2, 9);
command.setId(id);
commands.push(command);
_commands.push(command);
return command;
}
async function createCommands(commands) {
if (!commands || commands.length === 0) return [];
commands.forEach((command) => {
let id = Math.random().toString(36).substr(2, 9);
command.setId(id);
_commands.push(command);
});
return commands;
}
async function deleteCommand(commandId) {
let index = commands.findIndex((command) => command.getId() === commandId);
let index = _commands.findIndex((command) => command.getId() === commandId);
if (index >= 0) {
commands.splice(index, 1);
_commands.splice(index, 1);
}
}
@ -74,6 +84,7 @@ function RemotesService() {
deleteRemote,
getCommands,
createCommand,
createCommands,
deleteCommand,
};
}

View File

@ -3,6 +3,7 @@ import List from "../../components/list";
import RemotesService from "../../services/remotes-service";
import CreateCommandModal from "../../modals/create-command-modal";
import DeleteCommandModal from "../../modals/delete-command-modal";
import ImportCommandsModal from "../../modals/import-commands-modal";
function CommandsList(props) {
const [commands, { refetch: refetchCommands }] = createResource(
@ -17,6 +18,10 @@ function CommandsList(props) {
refetchCommands();
});
ImportCommandsModal.onCommandsImported(() => {
refetchCommands();
});
DeleteCommandModal.onCommandDeleted(() => {
refetchCommands();
});
@ -25,11 +30,15 @@ function CommandsList(props) {
refetchCommands();
CreateCommandModal.Handler.show();
}
function handleDeleteCommand(command) {
DeleteCommandModal.setCommand(command);
DeleteCommandModal.Handler.show();
}
function handleImportCommands() {
ImportCommandsModal.Handler.show();
}
return (
<>
@ -38,9 +47,13 @@ function CommandsList(props) {
{props.navigation ? props.navigation : null}
</div>
<div class="d-flex flex-row justify-content-end flex-fill">
<button class="btn btn-dark me-2 mb-3" onClick={handleImportCommands}>
<i class="bi bi-box-arrow-in-down me-2"></i>
Import
</button>
<button class="btn btn-dark me-2 mb-3" onClick={handleNewCommand}>
<i class="bi bi-plus-square me-2"></i>
New Command
New
</button>
</div>
</div>