From 99251b47483dcb7931d0e9d7135504e7f5023bdb Mon Sep 17 00:00:00 2001 From: Yoshihiro OKUMURA Date: Thu, 2 Oct 2025 13:26:13 +0900 Subject: [PATCH] refactor: enhance configuration handling and error checking for template fields in the Word output plugin --- .cspell.json | 1 + src/common/ErrorFallback.tsx | 7 +-- src/common/config.ts | 35 ++++++++++++++ src/config/Settings.tsx | 4 +- src/desktop/MenuPanel.tsx | 80 +++++++++++-------------------- src/desktop/checkConfig.ts | 92 ++++++++++++++++++++++++++++++++++++ src/desktop/locales/en.json | 17 ++++--- src/desktop/locales/ja.json | 11 +++-- src/types/kintone.d.ts | 16 +++++++ 9 files changed, 193 insertions(+), 70 deletions(-) create mode 100644 src/common/config.ts create mode 100644 src/desktop/checkConfig.ts diff --git a/.cspell.json b/.cspell.json index b22c2e9..fae3818 100644 --- a/.cspell.json +++ b/.cspell.json @@ -5,6 +5,7 @@ "words": [ "blankspace", "docxtemplater", + "errback", "Heiti", "Kaku", "kintone", diff --git a/src/common/ErrorFallback.tsx b/src/common/ErrorFallback.tsx index 2ce8385..7d365d8 100644 --- a/src/common/ErrorFallback.tsx +++ b/src/common/ErrorFallback.tsx @@ -8,11 +8,6 @@ interface Props { const ErrorFallback: React.FC = (props) => { const { error } = props; - return ( - -

Something went wrong:

-
{error.message}
-
- ); + return {error.message}; }; export default ErrorFallback; diff --git a/src/common/config.ts b/src/common/config.ts new file mode 100644 index 0000000..a43a1a8 --- /dev/null +++ b/src/common/config.ts @@ -0,0 +1,35 @@ +export const PluginConfigType = { + SELF: 0, + APP: 1, + URL: 2, +} as const; + +export interface PluginConfigSelf { + type: typeof PluginConfigType.SELF; + template: string; +} +export interface PluginConfigApp { + type: typeof PluginConfigType.APP; + appId: string; + template: string; + recordId: string; +} +export interface PluginConfigUrl { + type: typeof PluginConfigType.URL; + template: string; +} + +export type PluginConfig = PluginConfigSelf | PluginConfigApp | PluginConfigUrl; + +const DEFAULT_CONFIG: PluginConfig = { + type: PluginConfigType.SELF, + template: '', +} as const; + +export const saveConfig = (config: PluginConfig, callback?: () => void): void => { + kintone.plugin.app.setConfig(config, callback); +}; + +export const loadConfig = (pluginId: string): PluginConfig => { + return { ...DEFAULT_CONFIG, ...(kintone.plugin.app.getConfig(pluginId) as PluginConfig) }; +}; diff --git a/src/config/Settings.tsx b/src/config/Settings.tsx index 947cea5..71fdab3 100644 --- a/src/config/Settings.tsx +++ b/src/config/Settings.tsx @@ -3,6 +3,7 @@ import moize from 'moize'; import React from 'react'; import { useTranslation } from 'react-i18next'; import invariant from 'tiny-invariant'; +import { PluginConfigType, saveConfig } from '../common/config'; import { naturalCompare } from '../common/stringUtils'; import type { KintoneFormFieldProperties } from '../common/types'; import KintonePluginAlert from '../common/ui/KintonePluginAlert'; @@ -13,7 +14,6 @@ import KintonePluginRequire from '../common/ui/KintonePluginRequire'; import KintonePluginRow from '../common/ui/KintonePluginRow'; import KintonePluginSelect, { type KintonePluginSelectOptionData } from '../common/ui/KintonePluginSelect'; import KintonePluginTitle from '../common/ui/KintonePluginTitle'; - import styles from './Settings.module.css'; const cachedFormFieldsProperties = moize.promise(async (appId: number): Promise => { @@ -50,7 +50,7 @@ const Settings: React.FC = (props) => { const handleOnSubmit = (e: React.FormEvent) => { e.preventDefault(); - kintone.plugin.app.setConfig({ template }, () => { + saveConfig({ type: PluginConfigType.SELF, template }, () => { alert(t('on-saved')); window.location.href = `../../flow?app=${appId}`; }); diff --git a/src/desktop/MenuPanel.tsx b/src/desktop/MenuPanel.tsx index 2e983f3..a1487f0 100644 --- a/src/desktop/MenuPanel.tsx +++ b/src/desktop/MenuPanel.tsx @@ -3,10 +3,10 @@ import saveAs from 'file-saver'; import moize from 'moize'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { DOCX_CONTENT_TYPE } from '../common/constants'; +import { loadConfig, type PluginConfig, PluginConfigType } from '../common/config'; import type { KintoneFormFieldProperties } from '../common/types'; -import KintonePluginAlert from '../common/ui/KintonePluginAlert'; import KintonePluginButton from '../common/ui/KintonePluginButton'; +import { checkConfig } from './checkConfig'; import generateWordFileData from './generateWordFileData'; export interface MenuPanelProps { @@ -20,65 +20,39 @@ const cachedFormFieldsProperties = moize.promise(async (appId: number): Promise< return properties; }); +const cachedConfigCheck = moize.promise( + async (config: PluginConfig, record: kintone.types.SavedFields): Promise => { + return checkConfig(config, record); + }, +); + +const downloadFile = async (config: PluginConfig, template: string): Promise => { + if (config.type === PluginConfigType.SELF || config.type === PluginConfigType.APP) { + const client = new KintoneRestAPIClient(); + const content = await client.file.downloadFile({ fileKey: template }); + return content; + } else { + // PluginConfigType.URL + const [body, status, _headers] = await kintone.proxy(template, 'GET', {}, {}); + if (status !== 200) { + throw new Error(`Failed to download the file from URL: ${template}`); + } + return new Uint16Array(Array.from(String(body), (c) => c.charCodeAt(0))).buffer; + } +}; + const MenuPanel: React.FC = (props) => { const { pluginId, event } = props; const { t } = useTranslation(); const appId = event.appId; - const properties = React.use(cachedFormFieldsProperties(appId)); const language = kintone.getLoginUser().language; - const config = kintone.plugin.app.getConfig(pluginId); - const template: string = config.template ?? ''; - - if (template === '') { - return ( - - {t('name')}: {t('errors.template-field-is-not-set')} - - ); - } - const record = event.record[template]; - if (record == null) { - return ( - - {t('name')}: {t('errors.template-field-is-not-available')} - - ); - } - if (record.type !== 'FILE') { - return ( - - {t('name')}: {t('errors.template-field-must-be-an-attachment-field')} - - ); - } - if (record.value.length === 0) { - return ( - - {t('name')}: {t('errors.template-field-does-not-contain-any-files')} - - ); - } - if (record.value.length > 1) { - return ( - - {t('name')}: {t('errors.template-field-contains-multiple-files')} - - ); - } - const { fileKey, contentType } = record.value[0]; - if (contentType !== DOCX_CONTENT_TYPE) { - return ( - - {t('name')}: {t('errors.template-file-must-be-a-docx', { contentType })} - - ); - } + const config = loadConfig(pluginId); + const template = React.use(cachedConfigCheck(config, event.record)); + const properties = React.use(cachedFormFieldsProperties(appId)); const handleOnClickOutputButton = (e: React.MouseEvent) => { e.preventDefault(); - const client = new KintoneRestAPIClient(); - client.file - .downloadFile({ fileKey }) + downloadFile(config, template) .then((content) => { const out = generateWordFileData(content, properties, event.record, language); saveAs(out, 'output.docx'); diff --git a/src/desktop/checkConfig.ts b/src/desktop/checkConfig.ts new file mode 100644 index 0000000..1462132 --- /dev/null +++ b/src/desktop/checkConfig.ts @@ -0,0 +1,92 @@ +import { KintoneRestAPIClient } from '@kintone/rest-api-client'; +import i18n from 'i18next'; +import { + type PluginConfig, + type PluginConfigApp, + type PluginConfigSelf, + PluginConfigType, + type PluginConfigUrl, +} from '../common/config'; +import { DOCX_CONTENT_TYPE } from '../common/constants'; + +const checkConfigSelf = (config: PluginConfigSelf, record: kintone.types.SavedFields): string => { + const { template } = config; + if (template === '') { + throw new Error(i18n.t('errors.template-field-is-required')); + } + const field = record[template]; + if (!field) { + throw new Error(i18n.t('errors.template-field-is-not-available')); + } + if (field.type !== 'FILE') { + throw new Error(i18n.t('errors.template-field-must-be-an-attachment-field')); + } + const files = Array.isArray(field.value) ? (field.value as kintone.fieldTypes.File['value']) : []; + if (files.length === 0) { + throw new Error(i18n.t('errors.template-field-does-not-contain-any-files')); + } + if (files.length > 1) { + throw new Error(i18n.t('errors.template-field-contains-multiple-files')); + } + if (files[0].contentType !== DOCX_CONTENT_TYPE) { + throw new Error(i18n.t('errors.template-file-must-be-a-docx', { contentType: files[0].contentType })); + } + return files[0].fileKey; +}; + +const checkConfigApp = async (config: PluginConfigApp): Promise => { + const { appId, template, recordId } = config; + if (appId === '') { + throw new Error(i18n.t('errors.app-id-is-required')); + } + if (recordId === '') { + throw new Error(i18n.t('errors.record-id-is-required')); + } + if (template === '') { + throw new Error(i18n.t('errors.template-field-is-required')); + } + const client = new KintoneRestAPIClient(); + const { record } = await client.record.getRecord({ app: appId, id: recordId }).catch((_error) => { + throw new Error(i18n.t('errors.record-is-not-available')); + }); + const field = record[template]; + if (!field) { + throw new Error(i18n.t('errors.template-field-is-not-available')); + } + if (field.type !== 'FILE') { + throw new Error(i18n.t('errors.template-field-must-be-an-attachment-field')); + } + const files = Array.isArray(field.value) ? (field.value as kintone.fieldTypes.File['value']) : []; + if (files.length === 0) { + throw new Error(i18n.t('errors.template-field-does-not-contain-any-files')); + } + if (files.length > 1) { + throw new Error(i18n.t('errors.template-field-contains-multiple-files')); + } + if (files[0].contentType !== DOCX_CONTENT_TYPE) { + throw new Error(i18n.t('errors.template-file-must-be-a-docx', { contentType: files[0].contentType })); + } + return files[0].fileKey; +}; + +const checkConfigUrl = (config: PluginConfigUrl): string => { + const { template } = config; + if (template === '') { + throw new Error(i18n.t('errors.template-field-is-required')); + } + if (!template.startsWith('http://') && !template.startsWith('https://')) { + throw new Error(i18n.t('errors.template-field-must-be-a-valid-url')); + } + return template; +}; + +export const checkConfig = async (config: PluginConfig, record: kintone.types.SavedFields): Promise => { + if (config.type === PluginConfigType.SELF) { + return checkConfigSelf(config, record); + } else if (config.type === PluginConfigType.APP) { + return checkConfigApp(config); + } else if (config.type === PluginConfigType.URL) { + return checkConfigUrl(config); + } + throw new Error(i18n.t('errors.unexpected-error-occurred')); +}; diff --git a/src/desktop/locales/en.json b/src/desktop/locales/en.json index 76dbecd..8803749 100644 --- a/src/desktop/locales/en.json +++ b/src/desktop/locales/en.json @@ -1,12 +1,17 @@ { "name": "Word output plugin", "errors": { - "template-field-is-not-set": "Template field is not set. Please configure the plugin.", - "template-field-is-not-available": "Template field is not available in this app.", - "template-field-must-be-an-attachment-field": "Template field must be an attachment field.", - "template-field-does-not-contain-any-files": "Template field does not contain any files.", - "template-field-contains-multiple-files": "Template field contains multiple files. Please ensure it contains only one file.", - "template-file-must-be-a-docx": "The template file must be a Word (.docx) file. 'Mime-Type: {{contentType}}'." + "template-field-is-required": "The template field is not configured. Please check the plugin settings.", + "template-field-is-not-available": "The template field is not available. Please check the plugin settings.", + "template-field-must-be-an-attachment-field": "The template field must be an attachment field. Please check the plugin settings.", + "template-field-does-not-contain-any-files": "The template field does not contain any files.", + "template-field-contains-multiple-files": "The template field contains multiple files. Please attach only one file.", + "template-file-must-be-a-docx": "The template file must be a Word (.docx) file. 'Mime-Type: {{contentType}}'.", + "app-id-is-required": "The app ID for the template file is required. Please check the plugin settings.", + "record-id-is-required": "The record ID for the template file is required. Please check the plugin settings.", + "record-is-not-available": "The record is not available. Please check the plugin settings.", + "template-field-must-be-a-valid-url": "The template field must be a valid URL. Please check the plugin settings.", + "unexpected-error-occurred": "An unexpected error occurred." }, "buttons": { "output": "Output Word" diff --git a/src/desktop/locales/ja.json b/src/desktop/locales/ja.json index 51ba155..d942bdc 100644 --- a/src/desktop/locales/ja.json +++ b/src/desktop/locales/ja.json @@ -1,12 +1,17 @@ { "name": "Word出力プラグイン", "errors": { - "template-field-is-not-set": "雛形ファイルが設定されていません。プラグインの設定を見直してください。", + "template-field-is-required": "雛形ファイルが設定されていません。プラグインの設定を見直してください。", "template-field-is-not-available": "雛形ファイルに設定された添付ファイルフィールドが見つかりませんでした。プラグインの設定を見直してください。", "template-field-must-be-an-attachment-field": "雛形ファイルに設定されたフィールド型が添付ファイルフィールドではありません。プラグインの設定を見直してください。", "template-field-does-not-contain-any-files": "雛形ファイルが添付されていません。", - "template-field-contains-multiple-files": "雛形ファイルとして複数登録されています。1つのみを指定してください。", - "template-file-must-be-a-docx": "雛形ファイルがWord形式(.docx)ではありません。'Mime-Type: {{contentType}}'" + "template-field-contains-multiple-files": "雛形ファイルとして複数のファイルが添付されています。1つだけ添付してください。", + "template-file-must-be-a-docx": "雛形ファイルがWord形式(.docx)ではありません。'Mime-Type: {{contentType}}'", + "app-id-is-required": "雛形ファイルを格納するアプリIDが設定されていません。プラグインの設定を見直してください。", + "record-id-is-required": "雛形ファイルを格納するアプリのレコードIDが設定されていません。プラグインの設定を見直してください。", + "record-is-not-available": "雛形ファイルを格納されたアプリのレコードが取得できません。プラグインの設定を見直してください。", + "template-field-must-be-a-valid-url": "テンプレートフィールドは有効なURLである必要があります。プラグインの設定を見直してください。", + "unexpected-error-occurred": "予期しないエラーが発生しました。" }, "buttons": { "output": "Word出力" diff --git a/src/types/kintone.d.ts b/src/types/kintone.d.ts index 8c7f087..242faf1 100644 --- a/src/types/kintone.d.ts +++ b/src/types/kintone.d.ts @@ -98,4 +98,20 @@ declare namespace kintone { function getFormFields(): Promise; function getFormLayout(): Promise; } + + function proxy( + url: string, + method: string, + headers: Record, + data: Record | string, + ): Promise<[string, number, Record]>; + + function proxy( + url: string, + method: string, + headers: Record, + data: Record | string, + callback: (resp: [string, number, Record]) => void, + errback: (err: Error) => void, + ): void; }