From 14f58255ee6a98837df680f50293e3ef1a26d2dc Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sun, 28 Dec 2025 20:50:11 +0900
Subject: enhance(frontend): ウィジェットの設定画面を改良 (#17033)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* enhance(frontend): ウィジェットの設定画面を改良

* Update Changelog

* fix lint
---
 packages/frontend/src/components/MkFormDialog.vue | 87 ++++-------------------
 1 file changed, 13 insertions(+), 74 deletions(-)

(limited to 'packages/frontend/src/components/MkFormDialog.vue')

diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue
index 142ccb12a3..e598394ec4 100644
--- a/packages/frontend/src/components/MkFormDialog.vue
+++ b/packages/frontend/src/components/MkFormDialog.vue
@@ -20,66 +20,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 	</template>
 
 	<div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 32px;">
-		<div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m">
-			<template v-for="(v, k) in Object.fromEntries(Object.entries(form))">
-				<template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template>
-				<MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1">
-					<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
-					<template v-if="v.description" #caption>{{ v.description }}</template>
-				</MkInput>
-				<MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm">
-					<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
-					<template v-if="v.description" #caption>{{ v.description }}</template>
-				</MkInput>
-				<MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm">
-					<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
-					<template v-if="v.description" #caption>{{ v.description }}</template>
-				</MkTextarea>
-				<MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]">
-					<span v-text="v.label || k"></span>
-					<template v-if="v.description" #caption>{{ v.description }}</template>
-				</MkSwitch>
-				<MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)">
-					<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
-				</MkSelect>
-				<MkRadios v-else-if="v.type === 'radio'" v-model="values[k]">
-					<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
-					<option v-for="option in v.options" :key="getRadioKey(option)" :value="option.value">{{ option.label }}</option>
-				</MkRadios>
-				<MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter">
-					<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
-					<template v-if="v.description" #caption>{{ v.description }}</template>
-				</MkRange>
-				<MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)">
-					<span v-text="v.content || k"></span>
-				</MkButton>
-				<XFile
-					v-else-if="v.type === 'drive-file'"
-					:fileId="v.defaultFileId"
-					:validate="async f => !v.validate || await v.validate(f)"
-					@update="f => values[k] = f"
-				/>
-			</template>
-		</div>
-		<MkResult v-else type="empty"/>
+		<MkForm v-model="values" :form="form"/>
 	</div>
 </MkModalWindow>
 </template>
 
 <script lang="ts" setup>
 import { reactive, useTemplateRef } from 'vue';
-import MkInput from './MkInput.vue';
-import MkTextarea from './MkTextarea.vue';
-import MkSwitch from './MkSwitch.vue';
-import MkSelect from './MkSelect.vue';
-import MkRange from './MkRange.vue';
-import MkButton from './MkButton.vue';
-import MkRadios from './MkRadios.vue';
-import XFile from './MkFormDialog.file.vue';
-import type { MkSelectItem } from '@/components/MkSelect.vue';
-import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js';
+import type { Form } from '@/utility/form.js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
-import { i18n } from '@/i18n.js';
+import MkForm from '@/components/MkForm.vue';
 
 const props = defineProps<{
 	title: string;
@@ -96,15 +46,18 @@ const emit = defineEmits<{
 }>();
 
 const dialog = useTemplateRef('dialog');
-const values = reactive({});
 
-for (const item in props.form) {
-	if ('default' in props.form[item]) {
-		values[item] = props.form[item].default ?? null;
-	} else {
-		values[item] = null;
+const values = reactive((() => {
+	const obj: Record<string, any> = {};
+	for (const item in props.form) {
+		if ('default' in props.form[item]) {
+			obj[item] = props.form[item].default ?? null;
+		} else {
+			obj[item] = null;
+		}
 	}
-}
+	return obj;
+})());
 
 function ok() {
 	emit('done', {
@@ -119,18 +72,4 @@ function cancel() {
 	});
 	dialog.value?.close();
 }
-
-function getMkSelectDef(def: EnumFormItem): MkSelectItem[] {
-	return def.enum.map((v) => {
-		if (typeof v === 'string') {
-			return { value: v, label: v };
-		} else {
-			return { value: v.value, label: v.label };
-		}
-	});
-}
-
-function getRadioKey(e: RadioFormItem['options'][number]) {
-	return typeof e.value === 'string' ? e.value : JSON.stringify(e.value);
-}
 </script>
-- 
cgit v1.2.3-freya


From 404fca6c2d5f63a7c82dd0d0415de6b8563394be Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Tue, 30 Dec 2025 17:55:52 +0900
Subject: fix(frontend): fix build error (#17050)

---
 packages/frontend/src/components/MkFormDialog.vue           | 6 +++---
 packages/frontend/src/components/MkWidgetSettingsDialog.vue | 4 ++--
 2 files changed, 5 insertions(+), 5 deletions(-)

(limited to 'packages/frontend/src/components/MkFormDialog.vue')

diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue
index e598394ec4..c314de7c89 100644
--- a/packages/frontend/src/components/MkFormDialog.vue
+++ b/packages/frontend/src/components/MkFormDialog.vue
@@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { reactive, useTemplateRef } from 'vue';
+import { ref, useTemplateRef } from 'vue';
 import type { Form } from '@/utility/form.js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkForm from '@/components/MkForm.vue';
@@ -47,7 +47,7 @@ const emit = defineEmits<{
 
 const dialog = useTemplateRef('dialog');
 
-const values = reactive((() => {
+const values = ref((() => {
 	const obj: Record<string, any> = {};
 	for (const item in props.form) {
 		if ('default' in props.form[item]) {
@@ -61,7 +61,7 @@ const values = reactive((() => {
 
 function ok() {
 	emit('done', {
-		result: values,
+		result: values.value,
 	});
 	dialog.value?.close();
 }
diff --git a/packages/frontend/src/components/MkWidgetSettingsDialog.vue b/packages/frontend/src/components/MkWidgetSettingsDialog.vue
index 0dc26e054d..188103d4b7 100644
--- a/packages/frontend/src/components/MkWidgetSettingsDialog.vue
+++ b/packages/frontend/src/components/MkWidgetSettingsDialog.vue
@@ -68,10 +68,10 @@ const emit = defineEmits<{
 
 const dialog = useTemplateRef('dialog');
 
-const settings = reactive<Record<string, any>>(deepClone(props.currentSettings));
+const settings = ref<Record<string, any>>(deepClone(props.currentSettings));
 
 function save() {
-	emit('saved', deepClone(settings));
+	emit('saved', deepClone(settings.value));
 	dialog.value?.close();
 }
 
-- 
cgit v1.2.3-freya


From f3aa5081ed994af857a97798528e1788d7762d36 Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Tue, 13 Jan 2026 15:02:50 +0900
Subject: fix(frontend):
 MkFormで入力に不備がある場合は完了ボタンを押して続行できないように (#17096)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* fix(frontend): MkFormで入力に不備がある場合は完了ボタンを押して続行できないように

* fix lint
---
 packages/frontend/src/components/MkForm.vue        | 41 ++++++++++++++++++++--
 packages/frontend/src/components/MkFormDialog.vue  | 12 +++++--
 packages/frontend/src/components/MkInput.vue       |  5 +++
 packages/frontend/src/components/MkTextarea.vue    |  5 +++
 .../src/components/MkWidgetSettingsDialog.vue      | 12 +++++--
 5 files changed, 68 insertions(+), 7 deletions(-)

(limited to 'packages/frontend/src/components/MkFormDialog.vue')

diff --git a/packages/frontend/src/components/MkForm.vue b/packages/frontend/src/components/MkForm.vue
index 711aa611c3..3d4724e6b7 100644
--- a/packages/frontend/src/components/MkForm.vue
+++ b/packages/frontend/src/components/MkForm.vue
@@ -7,15 +7,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 <div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m">
 	<template v-for="v, k in form">
 		<template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template>
-		<MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1" :manualSave="v.manualSave">
+		<MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1" :manualSave="v.manualSave" @savingStateChange="(changed, invalid) => onSavingStateChange(k, changed, invalid)">
 			<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
 			<template v-if="v.description" #caption>{{ v.description }}</template>
 		</MkInput>
-		<MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm" :manualSave="v.manualSave">
+		<MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm" :manualSave="v.manualSave" @savingStateChange="(changed, invalid) => onSavingStateChange(k, changed, invalid)">
 			<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
 			<template v-if="v.description" #caption>{{ v.description }}</template>
 		</MkInput>
-		<MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm" :manualSave="v.manualSave">
+		<MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm" :manualSave="v.manualSave" @savingStateChange="(changed, invalid) => onSavingStateChange(k, changed, invalid)">
 			<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
 			<template v-if="v.description" #caption>{{ v.description }}</template>
 		</MkTextarea>
@@ -49,6 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
+import { computed, ref, watch } from 'vue';
 import XFile from '@/components/MkForm.file.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
@@ -65,9 +66,43 @@ const props = defineProps<{
 	form: Form;
 }>();
 
+const emit = defineEmits<{
+	(ev: 'canSaveStateChange', canSave: boolean): void;
+}>();
+
 // TODO: ジェネリックにしたい
 const values = defineModel<Record<string, any>>({ required: true });
 
+// 保存可能状態の管理
+const inputSavingStates = ref<Record<string, { changed: boolean; invalid: boolean }>>({});
+
+function onSavingStateChange(key: string, changed: boolean, invalid: boolean) {
+	inputSavingStates.value[key] = { changed, invalid };
+}
+
+const canSave = computed(() => {
+	for (const key in inputSavingStates.value) {
+		const state = inputSavingStates.value[key];
+		if (
+			('manualSave' in props.form[key] && props.form[key].manualSave && state.changed) ||
+			state.invalid
+	 	) {
+			return false;
+		}
+		if ('required' in props.form[key] && props.form[key].required) {
+			const val = values.value[key];
+			if (val === null || val === undefined || val === '') {
+				return false;
+			}
+		}
+	}
+	return true;
+});
+
+watch(canSave, (newCanSave) => {
+	emit('canSaveStateChange', newCanSave);
+}, { immediate: true });
+
 function getMkSelectDef(def: EnumFormItem): MkSelectItem[] {
 	return def.enum.map((v) => {
 		if (typeof v === 'string') {
diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue
index c314de7c89..091721b40b 100644
--- a/packages/frontend/src/components/MkFormDialog.vue
+++ b/packages/frontend/src/components/MkFormDialog.vue
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	:width="450"
 	:canClose="false"
 	:withOkButton="true"
-	:okButtonDisabled="false"
+	:okButtonDisabled="!canSave"
 	@click="cancel()"
 	@ok="ok()"
 	@close="cancel()"
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	</template>
 
 	<div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 32px;">
-		<MkForm v-model="values" :form="form"/>
+		<MkForm v-model="values" :form="form" @canSaveStateChange="onCanSaveStateChanged"/>
 	</div>
 </MkModalWindow>
 </template>
@@ -59,7 +59,15 @@ const values = ref((() => {
 	return obj;
 })());
 
+const canSave = ref(true);
+
+function onCanSaveStateChanged(newCanSave: boolean) {
+	canSave.value = newCanSave;
+}
+
 function ok() {
+	if (!canSave.value) return;
+
 	emit('done', {
 		result: values.value,
 	});
diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue
index 4f6ca083a3..8a49fd231d 100644
--- a/packages/frontend/src/components/MkInput.vue
+++ b/packages/frontend/src/components/MkInput.vue
@@ -92,6 +92,7 @@ const emit = defineEmits<{
 	(ev: 'keydown', _ev: KeyboardEvent): void;
 	(ev: 'enter', _ev: KeyboardEvent): void;
 	(ev: 'update:modelValue', value: ModelValueType<T>): void;
+	(ev: 'savingStateChange', saved: boolean, invalid: boolean): void;
 }>();
 
 const { modelValue } = toRefs(props);
@@ -152,6 +153,10 @@ watch(v, () => {
 	invalid.value = inputEl.value?.validity.badInput ?? true;
 });
 
+watch([changed, invalid], ([newChanged, newInvalid]) => {
+	emit('savingStateChange', newChanged, newInvalid);
+}, { immediate: true });
+
 // このコンポーネントが作成された時、非表示状態である場合がある
 // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
 useInterval(() => {
diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue
index d53d4ec018..fe4f7b7aaf 100644
--- a/packages/frontend/src/components/MkTextarea.vue
+++ b/packages/frontend/src/components/MkTextarea.vue
@@ -67,6 +67,7 @@ const emit = defineEmits<{
 	(ev: 'keydown', _ev: KeyboardEvent): void;
 	(ev: 'enter'): void;
 	(ev: 'update:modelValue', value: string): void;
+	(ev: 'savingStateChange', saved: boolean, invalid: boolean): void;
 }>();
 
 const { modelValue, autofocus } = toRefs(props);
@@ -131,6 +132,10 @@ watch(v, () => {
 	invalid.value = inputEl.value?.validity.badInput ?? true;
 });
 
+watch([changed, invalid], ([newChanged, newInvalid]) => {
+	emit('savingStateChange', newChanged, newInvalid);
+}, { immediate: true });
+
 onMounted(() => {
 	nextTick(() => {
 		if (autofocus.value) {
diff --git a/packages/frontend/src/components/MkWidgetSettingsDialog.vue b/packages/frontend/src/components/MkWidgetSettingsDialog.vue
index 63f294770c..41fec2a9e0 100644
--- a/packages/frontend/src/components/MkWidgetSettingsDialog.vue
+++ b/packages/frontend/src/components/MkWidgetSettingsDialog.vue
@@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	:height="600"
 	:scroll="false"
 	:withOkButton="true"
+	:okButtonDisabled="!canSave"
 	@close="cancel()"
 	@ok="save()"
 	@closed="emit('closed')"
@@ -38,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 		<template #controls>
 			<div class="_spacer">
-				<MkForm v-model="settings" :form="form"/>
+				<MkForm v-model="settings" :form="form" @canSaveStateChange="onCanSaveStateChanged"/>
 			</div>
 		</template>
 	</MkPreviewWithControls>
@@ -46,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script setup lang="ts">
-import { reactive, useTemplateRef, ref, computed, watch, onBeforeUnmount, onMounted } from 'vue';
+import { useTemplateRef, ref, computed, onBeforeUnmount, onMounted } from 'vue';
 import MkPreviewWithControls from './MkPreviewWithControls.vue';
 import type { Form } from '@/utility/form.js';
 import { deepClone } from '@/utility/clone.js';
@@ -70,7 +71,14 @@ const dialog = useTemplateRef('dialog');
 
 const settings = ref<Record<string, any>>(deepClone(props.currentSettings));
 
+const canSave = ref(true);
+
+function onCanSaveStateChanged(newCanSave: boolean) {
+	canSave.value = newCanSave;
+}
+
 function save() {
+	if (!canSave.value) return;
 	emit('saved', deepClone(settings.value));
 	dialog.value?.close();
 }
-- 
cgit v1.2.3-freya

