Newer
Older
smart-home-server / webclient / src / components / script / ScriptRunModal.vue
<template>
  <GnModal
    :open="open"
    :title="`Run: ${script?.name ?? script?.alias ?? ''}`"
    @update:open="$emit('update:open', $event)"
  >
    <div v-if="script?.params_schema" class="script-run-form">
      <div
        v-for="(config, name) in script.params_schema"
        :key="name"
        class="form-field"
      >
        <GnInput
          v-if="config.type === 'text'"
          v-model="formValues[name]"
          :label="config.label || name"
          :placeholder="config.placeholder || ''"
          :state="fieldState(name, config)"
        />
        <GnInput
          v-else-if="config.type === 'number'"
          v-model.number="formValues[name]"
          type="number"
          :label="config.label || name"
          :placeholder="config.placeholder || ''"
          :state="fieldState(name, config)"
        />
        <GnRange
          v-else-if="config.type === 'range'"
          v-model.number="formValues[name]"
          :label="config.label || name"
          :min="config.min ?? 0"
          :max="config.max ?? 100"
          :step="config.step ?? 1"
          :state="fieldState(name, config)"
        />
        <GnSelect
          v-else-if="config.type === 'select'"
          v-model="formValues[name]"
          :label="config.label || name"
          :options="selectOptions(config.options)"
          :state="fieldState(name, config)"
        />
        <GnSwitch
          v-else-if="config.type === 'toggle'"
          v-model="formValues[name]"
          :label="config.label || name"
        />
        <GnTextarea
          v-else-if="config.type === 'textarea'"
          v-model="formValues[name]"
          :label="config.label || name"
          :placeholder="config.placeholder || ''"
          :state="fieldState(name, config)"
        />
        <p v-if="fieldError(name, config)" class="field-error">{{ fieldError(name, config) }}</p>
      </div>
    </div>

    <template #footer>
      <GnButton variant="secondary" @click="$emit('update:open', false)">
        Cancel
      </GnButton>
      <GnButton
        variant="primary"
        icon="ph-play"
        :disabled="!canSubmit"
        @click="submit"
      >
        Run
      </GnButton>
    </template>
  </GnModal>
</template>

<script setup>
import { ref, computed, watch } from "vue";
import {
  GnModal,
  GnInput,
  GnRange,
  GnSelect,
  GnSwitch,
  GnTextarea,
  GnButton,
} from "gnexus-ui-kit/vue";

const props = defineProps({
  open: {
    type: Boolean,
    default: false,
  },
  script: {
    type: Object,
    default: null,
  },
});

const emit = defineEmits(["update:open", "run"]);

const formValues = ref({});
const touched = ref(new Set());

function emptyValue(type, config) {
  switch (type) {
    case "text":
    case "textarea":
      return "";
    case "number":
      return 0;
    case "range":
      return config.min ?? 0;
    case "select": {
      const keys = Object.keys(config.options || {});
      return keys.length ? keys[0] : "";
    }
    case "toggle":
      return false;
    default:
      return "";
  }
}

function resetForm() {
  touched.value = new Set();
  if (!props.script?.params_schema) {
    formValues.value = {};
    return;
  }
  const defaults = {};
  for (const [name, config] of Object.entries(props.script.params_schema)) {
    defaults[name] =
      config.default !== undefined ? config.default : emptyValue(config.type, config);
  }
  formValues.value = defaults;
}

watch(
  () => props.open,
  (isOpen) => {
    if (isOpen) {
      resetForm();
    }
  },
  { immediate: true }
);

function selectOptions(options) {
  if (!options) return [];
  return Object.entries(options).map(([value, label]) => ({ value, label }));
}

function isFieldEmpty(name, config) {
  const val = formValues.value[name];
  if (val === undefined || val === null) return true;
  if (config.type === "text" || config.type === "textarea") {
    return String(val).trim() === "";
  }
  return false;
}

function fieldState(name, config) {
  if (!touched.value.has(name)) return null;
  if (config.required && isFieldEmpty(name, config)) return "error";
  return null;
}

function fieldError(name, config) {
  if (!touched.value.has(name)) return null;
  if (config.required && isFieldEmpty(name, config)) {
    return `${config.label || name} is required`;
  }
  return null;
}

const canSubmit = computed(() => {
  if (!props.script?.params_schema) return true;
  for (const [name, config] of Object.entries(props.script.params_schema)) {
    if (config.required && isFieldEmpty(name, config)) {
      return false;
    }
  }
  return true;
});

function submit() {
  // Touch all fields to show validation
  for (const name of Object.keys(props.script?.params_schema || {})) {
    touched.value.add(name);
  }
  if (!canSubmit.value) return;

  emit("run", {
    alias: props.script.alias,
    params: { ...formValues.value },
  });
  emit("update:open", false);
}
</script>

<style scoped>
.script-run-form {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.form-field {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.field-error {
  color: var(--color-danger, #ef4444);
  font-size: 12px;
  margin: 0;
}
</style>