support display format in number and calc fields.

This commit is contained in:
2025-05-31 21:45:13 +09:00
parent 5bc9976566
commit 4024914831
5 changed files with 233 additions and 139 deletions

View File

@@ -1,15 +1,16 @@
import React from 'react';
import { KintoneRestAPIClient } from '@kintone/rest-api-client';
import { KintoneFormFieldProperty, KintoneRestAPIClient } from '@kintone/rest-api-client';
import { Properties as KintoneFormFieldProperties } from '@kintone/rest-api-client/lib/src/client/types';
import { KintoneRecord } from '@shin-chan/kypes/types/page';
import dayjs from 'dayjs';
import Docxtemplater from 'docxtemplater';
import expressionParser from 'docxtemplater/expressions';
import { saveAs } from 'file-saver';
import moize from 'moize';
import PizZip from 'pizzip';
import invariant from 'tiny-invariant';
import { LANGUAGE, PLUGIN_ID } from '../common/global';
import { DOCX_CONTENTTYPE, LANGUAGE, PLUGIN_ID } from '../common/global';
import KintonePluginAlert from '../common/ui/KintonePluginAlert';
import KintonePluginButton from '../common/ui/KintonePluginButton';
@@ -19,8 +20,6 @@ interface TemplateData {
[key: string]: TemplateData | TemplateData[] | string | string[];
}
const DOCX_CONTENTTYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
interface MenuPanelProps {
event: kintone.events.AppRecordDetailShowEvent | kintone.events.MobileAppRecordDetailShowEvent;
}
@@ -31,6 +30,105 @@ const cachedFormFieldsProperties = moize.promise(async (appId: number): Promise<
return properties;
});
const formatNumberValue = (value: string, digit: boolean, scale?: number): string => {
if (value !== '' && !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): 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(!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(!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(
!isNaN(hours) && !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(
!isNaN(hours) && !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(
!isNaN(hours) && !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<KintoneRecord>): TemplateData => {
const data: TemplateData = {};
for (const key in record) {
@@ -46,13 +144,19 @@ const record2data = (properties: KintoneFormFieldProperties, record: Partial<Kin
name: value.name,
code: value.code,
};
} else if (type === 'CATEGORY' || type === 'CHECK_BOX' || type === 'MULTI_SELECT') {
} 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);
} else if (type === 'CHECK_BOX' || type === 'MULTI_SELECT' || type === 'CATEGORY') {
data[key] = value.map((v) => v);
} else if (
type === 'STATUS_ASSIGNEE' ||
type === 'USER_SELECT' ||
type === 'ORGANIZATION_SELECT' ||
type === 'GROUP_SELECT' ||
type === 'ORGANIZATION_SELECT'
type === 'STATUS_ASSIGNEE'
) {
data[key] = value.map((v) => {
return { name: v.name, code: v.code };
@@ -60,44 +164,17 @@ const record2data = (properties: KintoneFormFieldProperties, record: Partial<Kin
} 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));
} else if (type === 'CALC') {
invariant(property.type === 'CALC', `Expected property type to be CALC, but got ${property.type}`);
if (value === '') {
data[key] = '';
} else if (property.format === 'NUMBER_DIGIT') {
// "1234567" -> "1,234,567"
data[key] = Number(value).toLocaleString('en-US');
} else if (property.format === 'HOUR_MINUTE') {
// "49:30" -> en:"49 hours 30 minutes", ja: "49時間30分"
const [hours, minutes] = value.split(':').map((v) => Number(v));
invariant(
!isNaN(hours) && !isNaN(minutes),
`Expected hours and minutes to be numbers, but got hours: ${hours}, minutes: ${minutes}`,
);
if (LANGUAGE === 'ja') {
data[key] = `${hours}時間${minutes}`;
} else {
data[key] = `${hours} hours ${minutes} minutes`;
}
} else if (property.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(
!isNaN(hours) && !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;
if (LANGUAGE === 'ja') {
data[key] = `${days}${remainingHours}時間${minutes}`;
} else {
data[key] = `${days} days ${remainingHours} hours ${minutes} minutes`;
}
} else {
// NUMBER, DATETIME, DATE, TIME
data[key] = value;
}
} 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;
}
}
@@ -123,15 +200,15 @@ const MenuPanel: React.FC<MenuPanelProps> = (props) => {
return <KintonePluginAlert>WORD output plugin: Template field is not available in this app.</KintonePluginAlert>;
}
if (record.type !== 'FILE') {
return <KintonePluginAlert>WORD output plugin: The template field must be a file field.</KintonePluginAlert>;
return <KintonePluginAlert>WORD output plugin: Template field must be a file field.</KintonePluginAlert>;
}
if (record.value.length === 0) {
return <KintonePluginAlert>WORD output plugin: The template field does not contain any files.</KintonePluginAlert>;
return <KintonePluginAlert>WORD output plugin: Template field does not contain any files.</KintonePluginAlert>;
}
if (record.value.length > 1) {
return (
<KintonePluginAlert>
WORD output plugin: The template field contains multiple files. Only the first file will be used.
WORD output plugin: Template field contains multiple files. Please ensure it contains only one file.
</KintonePluginAlert>
);
}