refactor: streamline MenuPanel by removing unused functions and introducing generateWordFileData for DOCX generation
This commit is contained in:
@@ -1,23 +1,15 @@
|
||||
import { type KintoneFormFieldProperty, KintoneRestAPIClient } from '@kintone/rest-api-client';
|
||||
import dayjs from 'dayjs';
|
||||
import Docxtemplater from 'docxtemplater';
|
||||
import expressionParser from 'docxtemplater/expressions';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { KintoneRestAPIClient } from '@kintone/rest-api-client';
|
||||
import saveAs from 'file-saver';
|
||||
import moize from 'moize';
|
||||
import PizZip from 'pizzip';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import invariant from 'tiny-invariant';
|
||||
import { DOCX_CONTENT_TYPE } from '../common/constants';
|
||||
import type { KintoneFormFieldProperties } from '../common/types';
|
||||
import KintonePluginAlert from '../common/ui/KintonePluginAlert';
|
||||
import KintonePluginButton from '../common/ui/KintonePluginButton';
|
||||
import generateWordFileData from './generateWordFileData';
|
||||
|
||||
interface TemplateData {
|
||||
[key: string]: TemplateData | TemplateData[] | string | string[];
|
||||
}
|
||||
|
||||
interface MenuPanelProps {
|
||||
export interface MenuPanelProps {
|
||||
pluginId: string;
|
||||
event: kintone.events.AppRecordDetailShowEvent | kintone.events.MobileAppRecordDetailShowEvent;
|
||||
}
|
||||
@@ -28,167 +20,6 @@ const cachedFormFieldsProperties = moize.promise(async (appId: number): Promise<
|
||||
return properties;
|
||||
});
|
||||
|
||||
const formatNumberValue = (value: string, digit: boolean, scale?: number): string => {
|
||||
if (value !== '' && !Number.isNaN(Number(value))) {
|
||||
const numberValue = Number(value);
|
||||
const options: Intl.NumberFormatOptions = {
|
||||
useGrouping: digit,
|
||||
};
|
||||
if (scale != null && scale >= 0) {
|
||||
options.minimumFractionDigits = scale;
|
||||
options.maximumFractionDigits = scale;
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', options).format(numberValue);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const formatValueWithUnit = (
|
||||
value: string,
|
||||
property: KintoneFormFieldProperty.Number | KintoneFormFieldProperty.Calc,
|
||||
): string => {
|
||||
if (value !== '' && property.unit !== '') {
|
||||
if (property.unitPosition === 'BEFORE') {
|
||||
return `${property.unit} ${value}`;
|
||||
} else if (property.unitPosition === 'AFTER') {
|
||||
return `${value} ${property.unit}`;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const formatNumberRecordValue = (
|
||||
value: string,
|
||||
property: KintoneFormFieldProperty.Number | KintoneFormFieldProperty.Lookup,
|
||||
): string => {
|
||||
if ('lookup' in property) {
|
||||
return value;
|
||||
}
|
||||
const scale = property.displayScale !== '' ? Number(property.displayScale) : undefined;
|
||||
const digit = property.digit ?? false;
|
||||
const formattedValue = formatNumberValue(value, digit, scale);
|
||||
return formatValueWithUnit(formattedValue, property);
|
||||
};
|
||||
|
||||
const formatCalculatedRecordValue = (
|
||||
value: string,
|
||||
property: KintoneFormFieldProperty.Calc,
|
||||
language: string,
|
||||
): string => {
|
||||
const { format } = property;
|
||||
if (value === '') {
|
||||
return '';
|
||||
}
|
||||
const scale = property.displayScale !== '' ? Number(property.displayScale) : undefined;
|
||||
let formattedValue: string;
|
||||
if (format === 'NUMBER') {
|
||||
formattedValue = formatNumberValue(value, false, scale);
|
||||
} else if (format === 'NUMBER_DIGIT') {
|
||||
formattedValue = formatNumberValue(value, true, scale);
|
||||
} else if (format === 'DATETIME') {
|
||||
// "2025-05-30T00:00:00Z" -> "2025-05-30 9:00" (browser local timezone)
|
||||
const date = new Date(value);
|
||||
invariant(!Number.isNaN(date.getTime()), `Expected value to be a valid date string, but got ${value}`);
|
||||
formattedValue = dayjs(date).format('YYYY-MM-DD h:mm');
|
||||
} else if (format === 'DATE') {
|
||||
// "2025-05-30T00:00:00Z" -> "2025-05-30" (browser local timezone)
|
||||
const date = new Date(value);
|
||||
invariant(!Number.isNaN(date.getTime()), `Expected value to be a valid date string, but got ${value}`);
|
||||
formattedValue = dayjs(date).format('YYYY-MM-DD');
|
||||
} else if (format === 'TIME') {
|
||||
// "00:30" -> "0:30"
|
||||
const [hours, minutes] = value.split(':').map((v) => Number(v));
|
||||
invariant(
|
||||
!Number.isNaN(hours) && !Number.isNaN(minutes),
|
||||
`Expected hours and minutes to be numbers, but got hours: ${hours}, minutes: ${minutes}`,
|
||||
);
|
||||
formattedValue = `${hours}:${String(minutes).padStart(2, '0')}`;
|
||||
} else if (format === 'HOUR_MINUTE') {
|
||||
// "49:30" -> en:"49 hours 30 minutes", ja: "49時間30分"
|
||||
const [hours, minutes] = value.split(':').map((v) => Number(v));
|
||||
invariant(
|
||||
!Number.isNaN(hours) && !Number.isNaN(minutes),
|
||||
`Expected hours and minutes to be numbers, but got hours: ${hours}, minutes: ${minutes}`,
|
||||
);
|
||||
formattedValue = language === 'ja' ? `${hours}時間${minutes}分` : `${hours} hours ${minutes} minutes`;
|
||||
} else if (format === 'DAY_HOUR_MINUTE') {
|
||||
// "49:30" -> en: "2 days 1 hour 30 minutes", ja: "2日1時間30分
|
||||
const [hours, minutes] = value.split(':').map((v) => Number(v));
|
||||
invariant(
|
||||
!Number.isNaN(hours) && !Number.isNaN(minutes),
|
||||
`Expected hours and minutes to be numbers, but got hours: ${hours}, minutes: ${minutes}`,
|
||||
);
|
||||
const days = Math.floor(Number(hours) / 24);
|
||||
const remainingHours = Number(hours) % 24;
|
||||
formattedValue =
|
||||
language === 'ja'
|
||||
? `${days}日${remainingHours}時間${minutes}分`
|
||||
: `${days} days ${remainingHours} hours ${minutes} minutes`;
|
||||
} else {
|
||||
// DATETIME, DATE, TIME
|
||||
formattedValue = value;
|
||||
}
|
||||
return formatValueWithUnit(formattedValue, property);
|
||||
};
|
||||
|
||||
const record2data = (
|
||||
properties: KintoneFormFieldProperties,
|
||||
record: Partial<kintone.types.GenericFields>,
|
||||
language: string,
|
||||
): TemplateData => {
|
||||
const data: TemplateData = {};
|
||||
for (const key in record) {
|
||||
// biome-ignore lint/suspicious/noPrototypeBuiltins: avoid prototype pollution
|
||||
if (Object.prototype.hasOwnProperty.call(record, key) && Object.prototype.hasOwnProperty.call(properties, key)) {
|
||||
const item = record[key];
|
||||
const property = properties[key];
|
||||
if (item == null) continue;
|
||||
const { type, value } = item;
|
||||
if (value == null) {
|
||||
data[key] = '';
|
||||
} else if (type === 'CREATOR' || type === 'MODIFIER') {
|
||||
data[key] = {
|
||||
name: value.name,
|
||||
code: value.code,
|
||||
};
|
||||
} else if (type === 'NUMBER') {
|
||||
invariant(property.type === 'NUMBER', `Expected property type to be NUMBER, but got ${property.type}`);
|
||||
data[key] = formatNumberRecordValue(value, property);
|
||||
} else if (type === 'CALC') {
|
||||
invariant(property.type === 'CALC', `Expected property type to be CALC, but got ${property.type}`);
|
||||
data[key] = formatCalculatedRecordValue(value, property, language);
|
||||
} else if (type === 'CHECK_BOX' || type === 'MULTI_SELECT' || type === 'CATEGORY') {
|
||||
data[key] = value.map((v) => v);
|
||||
} else if (
|
||||
type === 'USER_SELECT' ||
|
||||
type === 'ORGANIZATION_SELECT' ||
|
||||
type === 'GROUP_SELECT' ||
|
||||
type === 'STATUS_ASSIGNEE'
|
||||
) {
|
||||
data[key] = value.map((v) => {
|
||||
return { name: v.name, code: v.code };
|
||||
});
|
||||
} else if (type === 'SUBTABLE') {
|
||||
invariant(property.type === 'SUBTABLE', `Expected property type to be SUBTABLE, but got ${property.type}`);
|
||||
data[key] = value.map((subRecord) => record2data(property.fields, subRecord.value, language));
|
||||
} else if (type === 'FILE') {
|
||||
invariant(property.type === 'FILE', `Expected property type to be FILE, but got ${property.type}`);
|
||||
data[key] = value.map((file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
contentType: file.contentType,
|
||||
fileKey: file.fileKey,
|
||||
}));
|
||||
} else {
|
||||
// RECORD_NUMBER, __ID__, __REVISION__, CREATED_TIME, UPDATED_TIME
|
||||
// SINGLE_LINE_TEXT, MULTI_LINE_TEXT, RICH_TEXT, RADIO_BUTTON, DROP_DOWN, DATE, TIME, DATETIME, LINK, STATUS
|
||||
data[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const MenuPanel: React.FC<MenuPanelProps> = (props) => {
|
||||
const { pluginId, event } = props;
|
||||
const { t } = useTranslation();
|
||||
@@ -249,15 +80,7 @@ const MenuPanel: React.FC<MenuPanelProps> = (props) => {
|
||||
client.file
|
||||
.downloadFile({ fileKey })
|
||||
.then((content) => {
|
||||
const zip = new PizZip(content);
|
||||
const doc = new Docxtemplater(zip, {
|
||||
paragraphLoop: true,
|
||||
linebreaks: true,
|
||||
parser: expressionParser,
|
||||
nullGetter: () => '',
|
||||
});
|
||||
doc.render(record2data(properties, event.record, language));
|
||||
const out = doc.getZip().generate({ type: 'blob', mimeType: DOCX_CONTENT_TYPE });
|
||||
const out = generateWordFileData(content, properties, event.record, language);
|
||||
saveAs(out, 'output.docx');
|
||||
})
|
||||
.catch((error) => {
|
||||
|
192
src/desktop/generateWordFileData.ts
Normal file
192
src/desktop/generateWordFileData.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import type { KintoneFormFieldProperty } from '@kintone/rest-api-client';
|
||||
import dayjs from 'dayjs';
|
||||
import Docxtemplater from 'docxtemplater';
|
||||
import expressionParser from 'docxtemplater/expressions';
|
||||
import PizZip from 'pizzip';
|
||||
import invariant from 'tiny-invariant';
|
||||
import { DOCX_CONTENT_TYPE } from '../common/constants';
|
||||
import type { KintoneFormFieldProperties } from '../common/types';
|
||||
|
||||
interface TemplateData {
|
||||
[key: string]: TemplateData | TemplateData[] | string | string[];
|
||||
}
|
||||
|
||||
const formatNumberValue = (value: string, digit: boolean, scale?: number): string => {
|
||||
if (value !== '' && !Number.isNaN(Number(value))) {
|
||||
const numberValue = Number(value);
|
||||
const options: Intl.NumberFormatOptions = {
|
||||
useGrouping: digit,
|
||||
};
|
||||
if (scale != null && scale >= 0) {
|
||||
options.minimumFractionDigits = scale;
|
||||
options.maximumFractionDigits = scale;
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', options).format(numberValue);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const formatValueWithUnit = (
|
||||
value: string,
|
||||
property: KintoneFormFieldProperty.Number | KintoneFormFieldProperty.Calc,
|
||||
): string => {
|
||||
if (value !== '' && property.unit !== '') {
|
||||
if (property.unitPosition === 'BEFORE') {
|
||||
return `${property.unit} ${value}`;
|
||||
} else if (property.unitPosition === 'AFTER') {
|
||||
return `${value} ${property.unit}`;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const formatNumberRecordValue = (
|
||||
value: string,
|
||||
property: KintoneFormFieldProperty.Number | KintoneFormFieldProperty.Lookup,
|
||||
): string => {
|
||||
if ('lookup' in property) {
|
||||
return value;
|
||||
}
|
||||
const scale = property.displayScale !== '' ? Number(property.displayScale) : undefined;
|
||||
const digit = property.digit ?? false;
|
||||
const formattedValue = formatNumberValue(value, digit, scale);
|
||||
return formatValueWithUnit(formattedValue, property);
|
||||
};
|
||||
|
||||
const formatCalculatedRecordValue = (
|
||||
value: string,
|
||||
property: KintoneFormFieldProperty.Calc,
|
||||
language: string,
|
||||
): string => {
|
||||
const { format } = property;
|
||||
if (value === '') {
|
||||
return '';
|
||||
}
|
||||
const scale = property.displayScale !== '' ? Number(property.displayScale) : undefined;
|
||||
let formattedValue: string;
|
||||
if (format === 'NUMBER') {
|
||||
formattedValue = formatNumberValue(value, false, scale);
|
||||
} else if (format === 'NUMBER_DIGIT') {
|
||||
formattedValue = formatNumberValue(value, true, scale);
|
||||
} else if (format === 'DATETIME') {
|
||||
// "2025-05-30T00:00:00Z" -> "2025-05-30 9:00" (browser local timezone)
|
||||
const date = new Date(value);
|
||||
invariant(!Number.isNaN(date.getTime()), `Expected value to be a valid date string, but got ${value}`);
|
||||
formattedValue = dayjs(date).format('YYYY-MM-DD h:mm');
|
||||
} else if (format === 'DATE') {
|
||||
// "2025-05-30T00:00:00Z" -> "2025-05-30" (browser local timezone)
|
||||
const date = new Date(value);
|
||||
invariant(!Number.isNaN(date.getTime()), `Expected value to be a valid date string, but got ${value}`);
|
||||
formattedValue = dayjs(date).format('YYYY-MM-DD');
|
||||
} else if (format === 'TIME') {
|
||||
// "00:30" -> "0:30"
|
||||
const [hours, minutes] = value.split(':').map((v) => Number(v));
|
||||
invariant(
|
||||
!Number.isNaN(hours) && !Number.isNaN(minutes),
|
||||
`Expected hours and minutes to be numbers, but got hours: ${hours}, minutes: ${minutes}`,
|
||||
);
|
||||
formattedValue = `${hours}:${String(minutes).padStart(2, '0')}`;
|
||||
} else if (format === 'HOUR_MINUTE') {
|
||||
// "49:30" -> en:"49 hours 30 minutes", ja: "49時間30分"
|
||||
const [hours, minutes] = value.split(':').map((v) => Number(v));
|
||||
invariant(
|
||||
!Number.isNaN(hours) && !Number.isNaN(minutes),
|
||||
`Expected hours and minutes to be numbers, but got hours: ${hours}, minutes: ${minutes}`,
|
||||
);
|
||||
formattedValue = language === 'ja' ? `${hours}時間${minutes}分` : `${hours} hours ${minutes} minutes`;
|
||||
} else if (format === 'DAY_HOUR_MINUTE') {
|
||||
// "49:30" -> en: "2 days 1 hour 30 minutes", ja: "2日1時間30分
|
||||
const [hours, minutes] = value.split(':').map((v) => Number(v));
|
||||
invariant(
|
||||
!Number.isNaN(hours) && !Number.isNaN(minutes),
|
||||
`Expected hours and minutes to be numbers, but got hours: ${hours}, minutes: ${minutes}`,
|
||||
);
|
||||
const days = Math.floor(Number(hours) / 24);
|
||||
const remainingHours = Number(hours) % 24;
|
||||
formattedValue =
|
||||
language === 'ja'
|
||||
? `${days}日${remainingHours}時間${minutes}分`
|
||||
: `${days} days ${remainingHours} hours ${minutes} minutes`;
|
||||
} else {
|
||||
// DATETIME, DATE, TIME
|
||||
formattedValue = value;
|
||||
}
|
||||
return formatValueWithUnit(formattedValue, property);
|
||||
};
|
||||
|
||||
const record2templateData = (
|
||||
properties: KintoneFormFieldProperties,
|
||||
record: Partial<kintone.types.GenericFields>,
|
||||
language: string,
|
||||
): TemplateData => {
|
||||
const data: TemplateData = {};
|
||||
for (const key in record) {
|
||||
// biome-ignore lint/suspicious/noPrototypeBuiltins: avoid prototype pollution
|
||||
if (Object.prototype.hasOwnProperty.call(record, key) && Object.prototype.hasOwnProperty.call(properties, key)) {
|
||||
const item = record[key];
|
||||
const property = properties[key];
|
||||
if (item == null) continue;
|
||||
const { type, value } = item;
|
||||
if (value == null) {
|
||||
data[key] = '';
|
||||
} else if (type === 'CREATOR' || type === 'MODIFIER') {
|
||||
data[key] = {
|
||||
name: value.name,
|
||||
code: value.code,
|
||||
};
|
||||
} else if (type === 'NUMBER') {
|
||||
invariant(property.type === 'NUMBER', `Expected property type to be NUMBER, but got ${property.type}`);
|
||||
data[key] = formatNumberRecordValue(value, property);
|
||||
} else if (type === 'CALC') {
|
||||
invariant(property.type === 'CALC', `Expected property type to be CALC, but got ${property.type}`);
|
||||
data[key] = formatCalculatedRecordValue(value, property, language);
|
||||
} else if (type === 'CHECK_BOX' || type === 'MULTI_SELECT' || type === 'CATEGORY') {
|
||||
data[key] = value.map((v) => v);
|
||||
} else if (
|
||||
type === 'USER_SELECT' ||
|
||||
type === 'ORGANIZATION_SELECT' ||
|
||||
type === 'GROUP_SELECT' ||
|
||||
type === 'STATUS_ASSIGNEE'
|
||||
) {
|
||||
data[key] = value.map((v) => {
|
||||
return { name: v.name, code: v.code };
|
||||
});
|
||||
} else if (type === 'SUBTABLE') {
|
||||
invariant(property.type === 'SUBTABLE', `Expected property type to be SUBTABLE, but got ${property.type}`);
|
||||
data[key] = value.map((subRecord) => record2templateData(property.fields, subRecord.value, language));
|
||||
} else if (type === 'FILE') {
|
||||
invariant(property.type === 'FILE', `Expected property type to be FILE, but got ${property.type}`);
|
||||
data[key] = value.map((file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
contentType: file.contentType,
|
||||
fileKey: file.fileKey,
|
||||
}));
|
||||
} else {
|
||||
// RECORD_NUMBER, __ID__, __REVISION__, CREATED_TIME, UPDATED_TIME
|
||||
// SINGLE_LINE_TEXT, MULTI_LINE_TEXT, RICH_TEXT, RADIO_BUTTON, DROP_DOWN, DATE, TIME, DATETIME, LINK, STATUS
|
||||
data[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const generateWordFileData = (
|
||||
content: ArrayBuffer,
|
||||
properties: KintoneFormFieldProperties,
|
||||
record: kintone.types.GenericFields,
|
||||
language: string,
|
||||
) => {
|
||||
const zip = new PizZip(content);
|
||||
const doc = new Docxtemplater(zip, {
|
||||
paragraphLoop: true,
|
||||
linebreaks: true,
|
||||
parser: expressionParser,
|
||||
nullGetter: () => '',
|
||||
});
|
||||
doc.render(record2templateData(properties, record, language));
|
||||
return doc.getZip().generate({ type: 'blob', mimeType: DOCX_CONTENT_TYPE });
|
||||
};
|
||||
|
||||
export default generateWordFileData;
|
Reference in New Issue
Block a user