Compare commits

5 Commits

Author SHA1 Message Date
2ff6cc474a chore: update dependencies and remove obsolete biome-ignore comments
- Updated @kintone/rest-api-client to version 6.0.0
- Updated react-i18next to version 16.0.1
- Updated devDependencies:
  - @biomejs/biome to version 2.2.6
  - @kintone/dts-gen to version 9.0.0
  - @kintone/plugin-uploader to version 10.0.0
  - @kintone/webpack-plugin-kintone-plugin to version 9.0.0
  - @types/node to version 24.8.1
  - @types/react-dom to version 19.2.2
- Removed biome-ignore comments for React import in multiple UI components
2025-10-17 13:45:35 +09:00
b7d4cd40f6 chore: update dependencies and add development guidelines
- Updated `docxtemplater` from 3.66.4 to 3.66.7
- Updated `i18next` from 25.5.3 to 25.6.0
- Updated `@biomejs/biome` from 2.2.4 to 2.2.5
- Updated TypeScript types for `node`, `react`, and `react-dom`
- Added AGENTS.md for development guidelines and best practices
- Added README.md with installation, usage, and development instructions
2025-10-12 14:02:31 +09:00
5f18c0b791 chore: update dependencies in package-lock.json for improved stability and security 2025-10-02 13:28:21 +09:00
94b7277b3a feat: Upgrade plugin version to 2.0.0 and refactor configuration handling
- Updated version numbers in package.json, package-lock.json, and manifest.json to 2.0.0.
- Refactored configuration types to use data sources (SELF, APP) instead of type-based configurations.
- Enhanced settings UI to allow selection between using the current record or another app's record.
- Improved error handling and validation for configuration settings.
- Added new input component for better handling of text inputs in settings.
- Updated localization files for new settings structure and error messages.
- Optimized API calls with memoization for fetching form field properties and checking configurations.
2025-10-02 13:26:46 +09:00
99251b4748 refactor: enhance configuration handling and error checking for template fields in the Word output plugin 2025-10-02 13:26:13 +09:00
30 changed files with 1123 additions and 450 deletions

View File

@@ -5,6 +5,7 @@
"words": [
"blankspace",
"docxtemplater",
"errback",
"Heiti",
"Kaku",
"kintone",

197
AGENTS.md Normal file
View File

@@ -0,0 +1,197 @@
# AGENTS.md - Development Guidelines for Kintone Word Output Plugin
This document provides guidelines and useful information for AI agents and developers working on the Kintone Word Output Plugin project. It includes setup instructions, coding standards, and best practices to ensure efficient and consistent development.
## Project Overview
- **Name**: Kintone Word Output Plugin
- **Purpose**: A Kintone plugin that formats and downloads data using Word (.docx) templates.
- **Tech Stack**:
- Frontend: React (TypeScript)
- Build Tool: rspack
- Linting/Formatting: Biome
- Template Engine: docxtemplater with angular-expressions
- Kintone Integration: @kintone/rest-api-client
- **Languages**: TypeScript, JavaScript
- **Package Manager**: npm
## Development Environment Setup
### Prerequisites
- Node.js 18+
- npm (comes with Node.js)
### Initial Setup
1. Clone the repository
2. Run `npm install` to install dependencies
3. Run `npm run gen:key` to generate a private key for development
4. Run `npm start` to start the development server
### Useful Scripts
- `npm run build`: Development build
- `npm run build:prod`: Production build
- `npm run develop`: Watch mode build
- `npm run lint`: Run Biome linter
- `npm run format`: Format code with Biome
- `npm run cspell`: Check spelling
- `npm run upload`: Upload plugin to Kintone (requires env setup)
## Coding Standards
### TypeScript
- Use strict TypeScript settings (see tsconfig.json)
- Prefer interfaces over types for object definitions
- Use proper typing for Kintone API responses
- Avoid `any` type; use specific types or `unknown`
### React Components
- Use functional components with hooks
- Follow React best practices for state management
- Use TypeScript for prop types
- Implement error boundaries where appropriate
### File Structure
- `src/common/`: Shared utilities and components
- `src/config/`: Configuration UI components
- `src/desktop/`: Desktop app components and logic
- `src/types/`: TypeScript type definitions
- `plugin/`: Kintone plugin manifest and assets
- `scripts/`: Build and utility scripts
### Naming Conventions
- Use camelCase for variables and functions
- Use PascalCase for components and classes
- Use kebab-case for file names
- Prefix custom hooks with `use`
## Code Quality Tools
### Biome
- Used for linting and formatting
- Configuration in `biome.json`
- Run `npm run lint` to check code
- Run `npm run format` to auto-fix issues
### TypeScript Compiler
- Strict mode enabled
- Check for errors with `npm run build`
### Spell Checker
- Uses cspell for spell checking
- Configuration in cspell config files
- Run `npm run cspell` to check
## Testing
### Current Testing Setup
- No automated tests are currently implemented
- Manual testing required for:
- Plugin installation in Kintone
- Template rendering with various data types
- Error handling scenarios
### Recommended Testing Approach
- Add unit tests for utility functions (e.g., data formatting)
- Add integration tests for template generation
- Test with different Kintone field types
- Test error scenarios (invalid templates, missing data)
## Build and Deployment
### Build Process
1. Pre-build: Generate keys and validate
2. Build with rspack (dev or prod mode)
3. Output: `dist/plugin.zip` for Kintone upload
### Deployment
- Use `npm run upload` for development
- Manual upload via Kintone Plugin Uploader for production
- Requires proper environment variables for authentication
## Kintone-Specific Considerations
### Plugin Architecture
- Separate config and desktop JS files
- Config handles settings UI
- Desktop handles record view functionality
- Uses Kintone's plugin API
### Data Handling
- Fields are mapped from Kintone records to template data
- Supports all Kintone field types
- Custom formatting for numbers, dates, calculations
- Handles subtable data as arrays
### Template Engine
- Uses docxtemplater for .docx processing
- Custom filters available (see generateWordFileData.ts)
- Supports loops, conditionals, and expressions
## Common Development Tasks
### Adding a New Filter
1. Add filter function in `generateWordFileData.ts`
2. Update filters object in expressionParser.configure()
3. Document in README.md
4. Test with sample templates
### Adding a New Field Type Support
1. Update `record2templateData` function
2. Handle formatting in appropriate helper functions
3. Add type checks and error handling
4. Test with sample data
### Updating UI Components
1. Use existing UI components from `src/common/ui/`
2. Follow i18n pattern for text
3. Test in both config and desktop contexts
## Troubleshooting
### Common Issues
- Build fails: Check Node.js version, run `npm install`
- Plugin not loading: Verify manifest.json, check browser console
- Template errors: Validate .docx file, check field codes
- Upload fails: Check environment variables, Kintone permissions
### Debug Tips
- Use browser dev tools for client-side debugging
- Check Kintone app logs for server-side issues
- Validate templates with docxtemplater documentation
- Test with minimal data sets first
## Contributing
### Pull Request Process
1. Create a feature branch
2. Make changes following coding standards
3. Run linting and build checks
4. Test manually in Kintone environment
5. Submit PR with clear description
### Code Review Checklist
- [ ] Code follows TypeScript and React best practices
- [ ] No linting errors
- [ ] Proper error handling
- [ ] Tested with various data scenarios
- [ ] Documentation updated if needed
## Resources
- [Kintone Developer Documentation](https://developer.kintone.io/)
- [docxtemplater Documentation](https://docxtemplater.com/)
- [Biome Documentation](https://biomejs.dev/)
- [React Documentation](https://react.dev/)
## AI Agent Guidelines
When working on this project:
- Always check existing code patterns before implementing new features
- Use the provided UI components and utilities
- Follow the established file structure
- Test changes in a Kintone environment
- Update documentation for any user-facing changes
- Prefer TypeScript over JavaScript for new code
- Use Biome for code quality checks
- Always use English for comments embedded in the code
- Respond to chat messages in the language used in the input

106
README.md Normal file
View File

@@ -0,0 +1,106 @@
# Kintone Word Output Plugin
A Kintone plugin that allows users to format and download data using Word template files (.docx). This plugin integrates with Kintone's record data and uses the docxtemplater library to populate templates with dynamic content.
## Features
- **Template-Based Output**: Use pre-designed Word (.docx) templates to format your Kintone data.
- **Data Source Flexibility**: Choose data from the current record or from another Kintone app.
- **Dynamic Content Population**: Leverage angular-expressions for complex data manipulation within templates.
- **Multi-Language Support**: Includes English and Japanese localization.
- **Error Handling**: Comprehensive error messages for configuration and runtime issues.
## Installation
1. Download the plugin package from the [releases page](https://github.com/your-repo/kintone-plugin-docx/releases).
2. Upload the plugin to your Kintone environment via the Kintone Plugin Uploader.
3. Enable the plugin in your desired Kintone app.
## Usage
### Configuration
1. In the app settings, navigate to the plugin configuration.
2. Select the data source:
- **Own Record**: Use an attachment field in the current app that contains the Word template.
- **Record from Another App**: Specify the App ID, Record ID, and Attachment Field Code for the template.
3. Save the settings.
### Outputting Word Files
1. Open a record in the Kintone app where the plugin is enabled.
2. Click the "Output Word" button in the record view.
3. The plugin will generate and download a Word file based on the configured template and record data.
## Template Requirements
- Templates must be in .docx format.
- Use placeholders like `{{fieldCode}}` in the template to insert data.
- Supports angular-expressions for advanced formatting (e.g., `{{fieldCode | uppercase}}`).
- **Custom Filters**: The plugin provides the following custom filters for use in templates:
- `toUpperCase`: Converts a string to uppercase (e.g., `{{fieldCode | toUpperCase}}`).
- `toLowerCase`: Converts a string to lowercase (e.g., `{{fieldCode | toLowerCase}}`).
- `regexMatch`: Checks if a string matches a regex pattern (e.g., `{{fieldCode | regexMatch:'pattern'}}`).
- `regexFilter`: Filters an array of strings based on a regex pattern (e.g., `{{arrayField | regexFilter:'pattern'}}`).
- `includes`: Checks if an array or string includes a substring (e.g., `{{arrayField | includes:'value'}}`).
- `indexOf`: Returns the index of a substring in an array or string (e.g., `{{arrayField | indexOf:'value'}}`).
## Development
### Prerequisites
- Node.js (version 18 or higher)
- npm or yarn
### Setup
1. Clone the repository:
```bash
git clone https://github.com/your-repo/kintone-plugin-docx.git
cd kintone-plugin-docx
```
2. Install dependencies:
```bash
npm install
```
3. Generate private key for development:
```bash
npm run gen:key
```
4. Start development server:
```bash
npm start
```
### Build
- Development build: `npm run build`
- Production build: `npm run build:prod`
### Upload to Kintone
```bash
npm run upload
```
## Dependencies
- `@kintone/rest-api-client`: For interacting with Kintone REST API.
- `docxtemplater`: For processing Word templates.
- `angular-expressions`: For template expressions.
- `react`: For the plugin's UI components.
## License
This project is licensed under the MIT License. See the [LICENSE.txt](LICENSE.txt) file for details.
## Contributing
Contributions are welcome! Please open an issue or submit a pull request.
## Support
For support, please check the [Kintone Developer Network](https://developer.kintone.io/) or open an issue in this repository.

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.6/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
@@ -28,6 +28,7 @@
}
},
"javascript": {
"jsxRuntime": "reactClassic",
"formatter": {
"quoteStyle": "single"
}

666
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "kintone-plugin-docx",
"version": "1.0.2",
"version": "2.0.0",
"scripts": {
"gen": "run-s gen:*",
"gen:key": "node scripts/generate-private-key.js",
@@ -16,32 +16,32 @@
"upload": "env-cmd --silent kintone-plugin-uploader dist/plugin.zip --watch --waiting-dialog-ms 3000"
},
"dependencies": {
"@kintone/rest-api-client": "^5.7.5",
"@kintone/rest-api-client": "^6.0.0",
"angular-expressions": "^1.5.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.18",
"docxtemplater": "^3.66.4",
"docxtemplater": "^3.66.7",
"file-saver": "^2.0.5",
"i18next": "^25.5.2",
"i18next": "^25.6.0",
"moize": "^6.1.6",
"pizzip": "^3.2.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
"react-i18next": "^16.0.0",
"react-i18next": "^16.0.1",
"tiny-invariant": "^1.3.3"
},
"devDependencies": {
"@biomejs/biome": "^2.2.4",
"@kintone/dts-gen": "^8.1.3",
"@kintone/plugin-uploader": "^9.1.5",
"@kintone/webpack-plugin-kintone-plugin": "^8.0.11",
"@biomejs/biome": "^2.2.6",
"@kintone/dts-gen": "^9.0.0",
"@kintone/plugin-uploader": "^10.0.0",
"@kintone/webpack-plugin-kintone-plugin": "^9.0.0",
"@rspack/cli": "^1.5.8",
"@rspack/core": "^1.5.8",
"@types/file-saver": "^2.0.7",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@types/node": "^24.8.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"cross-env": "^10.1.0",
"cspell": "^9.2.1",
"env-cmd": "^11.0.0",
@@ -49,6 +49,6 @@
"npm-run-all": "^4.1.5",
"prettier": "^3.6.2",
"ts-node": "^10.9.2",
"typescript": "^5.9.2"
"typescript": "^5.9.3"
}
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/kintone/js-sdk/%40kintone/plugin-manifest-validator%4010.2.0/packages/plugin-manifest-validator/manifest-schema.json",
"manifest_version": 1,
"version": "1.0.2",
"version": "2.0.0",
"type": "APP",
"desktop": {
"js": ["js/desktop.js"],
@@ -15,8 +15,7 @@
"config": {
"html": "html/config.html",
"js": ["js/config.js"],
"css": ["js/config.css"],
"required_params": ["template"]
"css": ["js/config.css"]
},
"name": {
"en": "Word output plugin",

View File

@@ -1,4 +1,3 @@
// biome-ignore lint/style/useImportType: React is required in scope for the old JSX transform.
import React from 'react';
import KintonePluginAlert from './ui/KintonePluginAlert';
@@ -8,11 +7,6 @@ interface Props {
const ErrorFallback: React.FC<Props> = (props) => {
const { error } = props;
return (
<KintonePluginAlert>
<p>Something went wrong:</p>
<pre>{error.message}</pre>
</KintonePluginAlert>
);
return <KintonePluginAlert>{error.message}</KintonePluginAlert>;
};
export default ErrorFallback;

View File

@@ -1,4 +1,3 @@
// biome-ignore lint/style/useImportType: React is required in scope for the old JSX transform.
import React from 'react';
import DynamicPortal from './DynamicPortal';

41
src/common/config.ts Normal file
View File

@@ -0,0 +1,41 @@
export const PluginConfigDataSource = {
SELF: 'self',
APP: 'app',
} as const;
export type PluginConfigDataSource = (typeof PluginConfigDataSource)[keyof typeof PluginConfigDataSource];
export interface PluginConfigSelf {
dataSource: typeof PluginConfigDataSource.SELF;
fieldCode: string;
}
export interface PluginConfigApp {
dataSource: typeof PluginConfigDataSource.APP;
appId: string;
recordId: string;
fieldCode: string;
}
export type PluginConfig = PluginConfigSelf | PluginConfigApp;
const DEFAULT_CONFIG: PluginConfig = {
dataSource: PluginConfigDataSource.SELF,
fieldCode: '',
} as const;
export const saveConfig = (config: PluginConfig, callback?: () => void): void => {
kintone.plugin.app.setConfig(config, callback);
};
export const loadConfig = (pluginId: string): PluginConfig => {
const config = kintone.plugin.app.getConfig(pluginId);
if (!config || Object.keys(config).length === 0) {
// initial state
return { ...DEFAULT_CONFIG };
}
if (config.dataSource != null) {
// current version of config format
return config as PluginConfig;
}
// old version of config format
return { dataSource: PluginConfigDataSource.SELF, fieldCode: config.template || '' };
};

View File

@@ -1,5 +1,4 @@
import clsx from 'clsx';
// biome-ignore lint/style/useImportType: React is required in scope for the old JSX transform.
import React from 'react';
export type KintonePluginAlertProps = React.PropsWithChildren<{

View File

@@ -1,5 +1,4 @@
import clsx from 'clsx';
// biome-ignore lint/style/useImportType: React is required in scope for the old JSX transform.
import React from 'react';
export type KintonePluginButtonProps = React.PropsWithChildren<{

View File

@@ -1,5 +1,4 @@
import clsx from 'clsx';
// biome-ignore lint/style/useImportType: React is required in scope for the old JSX transform.
import React from 'react';
export type KintonePluginDescProps = React.PropsWithChildren<{

View File

@@ -0,0 +1,45 @@
import clsx from 'clsx';
import React from 'react';
export type KintonePluginInputTextProps = {
id?: string;
className?: string;
value: string;
size?: number;
maxLength?: number;
disabled?: boolean;
required?: boolean;
placeholder?: string;
onChange: (value: string) => void;
};
const KintonePluginInputText: React.FC<KintonePluginInputTextProps> = (props) => {
const { id, className, value, size, maxLength, disabled, required, placeholder, onChange } = props;
const handleOnChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
onChange(e.target.value);
};
return (
<div className={clsx('kintoneplugin-input-outer', className)}>
<input
id={id}
className="kintoneplugin-input-text"
type="text"
value={value}
size={size}
maxLength={maxLength}
disabled={disabled}
required={required}
placeholder={placeholder}
aria-required={required}
aria-disabled={disabled}
aria-invalid={required && value === ''}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
onChange={handleOnChange}
/>
</div>
);
};
export default KintonePluginInputText;

View File

@@ -1,5 +1,4 @@
import clsx from 'clsx';
// biome-ignore lint/style/useImportType: React is required in scope for the old JSX transform.
import React from 'react';
export type KintonePluginLabelProps = React.PropsWithChildren<{

View File

@@ -1,5 +1,4 @@
import clsx from 'clsx';
// biome-ignore lint/style/useImportType: React is required in scope for the old JSX transform.
import React from 'react';
export type KintonePluginRequire = React.PropsWithChildren<{

View File

@@ -1,5 +1,4 @@
import clsx from 'clsx';
// biome-ignore lint/style/useImportType: React is required in scope for the old JSX transform.
import React from 'react';
export type KintonePluginRowProps = React.PropsWithChildren<{

View File

@@ -1,5 +1,4 @@
import clsx from 'clsx';
// biome-ignore lint/style/useImportType: React is required in scope for the old JSX transform.
import React from 'react';
export type KintonePluginTitleProps = React.PropsWithChildren<{

View File

@@ -1,19 +1,34 @@
import { KintoneRestAPIClient } from '@kintone/rest-api-client';
import moize from 'moize';
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import invariant from 'tiny-invariant';
import ErrorFallback from '../common/ErrorFallback';
import Loading from '../common/Loading';
import type { KintoneFormFieldProperties } from '../common/types';
import Settings from './Settings';
interface ConfigAppProps {
pluginId: string;
}
const cachedFormFieldsProperties = moize.promise(async (appId: number): Promise<KintoneFormFieldProperties> => {
const client = new KintoneRestAPIClient();
const { properties } = await client.app.getFormFields({ app: appId, preview: true });
return properties;
});
const ConfigApp: React.FC<ConfigAppProps> = (props) => {
const { pluginId } = props;
const appId = kintone.app.getId();
invariant(appId, 'The app ID is not available. Please ensure you are on a Kintone app page.');
const propertiesPromise = cachedFormFieldsProperties(appId);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<React.Suspense fallback={<Loading />}>
<Settings pluginId={pluginId} />
<Settings pluginId={pluginId} appId={appId} propertiesPromise={propertiesPromise} />
</React.Suspense>
</ErrorBoundary>
);

View File

@@ -1,3 +1,10 @@
.settings .app {
display: flex;
gap: 2em;
align-items: center;
margin-bottom: 1em;
}
.buttons > *:not(:last-child) {
margin-right: 0.5em;
}

View File

@@ -1,13 +1,13 @@
import { KintoneRestAPIClient } from '@kintone/rest-api-client';
import moize from 'moize';
import React from 'react';
import { useTranslation } from 'react-i18next';
import invariant from 'tiny-invariant';
import type { PluginConfig } from '../common/config';
import { loadConfig, PluginConfigDataSource, saveConfig } from '../common/config';
import { naturalCompare } from '../common/stringUtils';
import type { KintoneFormFieldProperties } from '../common/types';
import KintonePluginAlert from '../common/ui/KintonePluginAlert';
import KintonePluginButton from '../common/ui/KintonePluginButton';
import KintonePluginDesc from '../common/ui/KintonePluginDesc';
import KintonePluginInputText from '../common/ui/KintonePluginInputText';
import KintonePluginLabel from '../common/ui/KintonePluginLabel';
import KintonePluginRequire from '../common/ui/KintonePluginRequire';
import KintonePluginRow from '../common/ui/KintonePluginRow';
@@ -16,70 +16,161 @@ import KintonePluginTitle from '../common/ui/KintonePluginTitle';
import styles from './Settings.module.css';
const cachedFormFieldsProperties = moize.promise(async (appId: number): Promise<KintoneFormFieldProperties> => {
const client = new KintoneRestAPIClient();
const { properties } = await client.app.getFormFields({ app: appId, preview: true });
return properties;
});
interface SettingsProps {
pluginId: string;
appId: number;
propertiesPromise: Promise<KintoneFormFieldProperties>;
}
const Settings: React.FC<SettingsProps> = (props) => {
const { pluginId } = props;
const { pluginId, appId, propertiesPromise } = props;
const { t } = useTranslation();
const config = kintone.plugin.app.getConfig(pluginId);
const appId = kintone.app.getId();
invariant(appId, 'The app ID is not available. Please ensure you are on a Kintone app page.');
const properties = React.use(cachedFormFieldsProperties(appId));
const config = loadConfig(pluginId);
const properties = React.use(propertiesPromise);
// data source options
const dataSourceOptions = [
{ value: PluginConfigDataSource.SELF, label: t('settings.datasource.labels.self') },
{ value: PluginConfigDataSource.APP, label: t('settings.datasource.labels.app') },
];
const [dataSource, setDataSource] = React.useState<
(typeof PluginConfigDataSource)[keyof typeof PluginConfigDataSource]
>(config.dataSource);
// states for SELF data source
const fileFields = Object.values(properties)
.filter((property) => property.type === 'FILE')
.sort((a, b) => naturalCompare(`${a.label} (${a.code})`, `${b.label} (${b.code})`));
const options: KintonePluginSelectOptionData[] = [
{ value: '', label: t('settings.template.messages.select-an-attachment-fields'), disabled: true }, // Default option
{ value: '', label: t('settings.self.messages.select-an-attachment-fields'), disabled: true }, // Default option
...fileFields.map((property) => ({
value: property.code,
label: `${property.label} (${property.code})`,
})),
];
const [fieldCode, setFieldCode] = React.useState<string>(
() =>
options.find((option) => config.dataSource === PluginConfigDataSource.SELF && option.value === config.fieldCode)
?.value ?? '',
);
const [template, setTemplate] = React.useState<string>(
() => options.find((option) => option.value === (config.template ?? ''))?.value ?? '',
// states for APP data source
const [otherAppId, setOtherAppId] = React.useState<string>(
config.dataSource === PluginConfigDataSource.APP ? config.appId : '',
);
const [otherRecordId, setOtherRecordId] = React.useState<string>(
config.dataSource === PluginConfigDataSource.APP ? config.recordId : '',
);
const [otherFieldCode, setOtherFieldCode] = React.useState<string>(
config.dataSource === PluginConfigDataSource.APP ? config.fieldCode : '',
);
const handleOnSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
kintone.plugin.app.setConfig({ template }, () => {
let configToSave: PluginConfig;
if (dataSource === PluginConfigDataSource.SELF) {
configToSave = { dataSource: PluginConfigDataSource.SELF, fieldCode };
} else if (dataSource === PluginConfigDataSource.APP) {
configToSave = {
dataSource: PluginConfigDataSource.APP,
appId: otherAppId,
fieldCode: otherFieldCode,
recordId: otherRecordId,
};
} else {
return;
}
saveConfig(configToSave, () => {
alert(t('on-saved'));
window.location.href = `../../flow?app=${appId}`;
});
};
const handleOnChangeTemplate = (value: string) => {
setTemplate(value);
};
const handleOnClickCancel = () => {
window.location.href = `../../${appId}/plugin/`;
};
return (
<section className="settings">
<div className={styles.settings}>
<KintonePluginLabel>{t('title')}</KintonePluginLabel>
<form onSubmit={handleOnSubmit}>
<KintonePluginRow>
<KintonePluginTitle>
{t('settings.template.title')}
{t('settings.datasource.title')}
<KintonePluginRequire>*</KintonePluginRequire>
</KintonePluginTitle>
<KintonePluginDesc> {t('settings.template.description')}</KintonePluginDesc>
{fileFields.length === 0 ? (
<KintonePluginAlert>{t('settings.template.errors.no-attachment-field-found')}</KintonePluginAlert>
) : (
<KintonePluginSelect value={template} onChange={handleOnChangeTemplate} options={options} />
)}
<KintonePluginDesc>{t('settings.datasource.description')}</KintonePluginDesc>
<KintonePluginSelect
value={String(dataSource)}
onChange={(value) => {
const newDataSource = value as PluginConfigDataSource;
if (Object.values(PluginConfigDataSource).includes(newDataSource)) {
setDataSource(newDataSource);
}
}}
options={dataSourceOptions.map((opt) => ({ value: String(opt.value), label: opt.label }))}
/>
</KintonePluginRow>
{dataSource === PluginConfigDataSource.SELF && (
<KintonePluginRow>
<KintonePluginTitle>
{t('settings.self.title')}
<KintonePluginRequire>*</KintonePluginRequire>
</KintonePluginTitle>
<KintonePluginDesc>{t('settings.self.description')}</KintonePluginDesc>
{fileFields.length === 0 ? (
<KintonePluginAlert>{t('settings.self.errors.no-attachment-field-found')}</KintonePluginAlert>
) : (
<KintonePluginSelect value={fieldCode} onChange={(value) => setFieldCode(value)} options={options} />
)}
</KintonePluginRow>
)}
{dataSource === PluginConfigDataSource.APP && (
<KintonePluginRow>
<KintonePluginTitle>
{t('settings.app.title')}
<KintonePluginRequire>*</KintonePluginRequire>
</KintonePluginTitle>
<KintonePluginDesc>{t('settings.app.description')}</KintonePluginDesc>
<div className={styles.app}>
<label htmlFor="otherAppIdInput">
{t('settings.app.labels.app-id')} <KintonePluginRequire>*</KintonePluginRequire>
<br />
<KintonePluginInputText
id="otherAppIdInput"
value={otherAppId}
required
onChange={(value) => {
setOtherAppId(value);
}}
/>
</label>
<label htmlFor="otherRecordIdInput">
{t('settings.app.labels.record-id')} <KintonePluginRequire>*</KintonePluginRequire>
<br />
<KintonePluginInputText
id="otherRecordIdInput"
value={otherRecordId}
required
onChange={(value) => {
setOtherRecordId(value);
}}
/>
</label>
<label htmlFor="otherFieldCodeInput">
{t('settings.app.labels.field-code')} <KintonePluginRequire>*</KintonePluginRequire>
<br />
<KintonePluginInputText
id="otherFieldCodeInput"
value={otherFieldCode}
required
onChange={(value) => {
setOtherFieldCode(value);
}}
/>
</label>
</div>
</KintonePluginRow>
)}
<KintonePluginRow className={styles.buttons}>
<KintonePluginButton variant="dialog-cancel" type="button" onClick={handleOnClickCancel}>
{t('buttons.cancel')}
@@ -89,7 +180,7 @@ const Settings: React.FC<SettingsProps> = (props) => {
</KintonePluginButton>
</KintonePluginRow>
</form>
</section>
</div>
);
};
export default Settings;

View File

@@ -1,15 +1,32 @@
{
"title": "Settings for the Kintone Word output plugin",
"settings": {
"template": {
"datasource": {
"title": "Select data source for Word template file",
"description": "Please select the data source for the Word template file.",
"labels": {
"self": "Own record",
"app": "Record from another app"
}
},
"self": {
"title": "Template",
"description": "Select a attachment field that contains the Word template file.",
"description": "Select an attachment field that contains the Word template file.",
"messages": {
"select-an-attachment-fields": "(Select an attachment field)"
},
"errors": {
"no-attachment-field-found": "No attachment fields found in the app. Please add an attachment field to use this plugin."
}
},
"app": {
"title": "Attachment file info from another app",
"description": "Enter App ID, Record ID, and Attachment Field Code.",
"labels": {
"app-id": "App ID",
"record-id": "Record ID",
"field-code": "Attachment Field Code"
}
}
},
"buttons": {

View File

@@ -1,7 +1,15 @@
{
"title": "Word出力プラグイン設定",
"settings": {
"template": {
"datasource": {
"title": "WORD雛形ファイルのデータソース選択",
"description": "WORD雛形ファイルのデータソースを選択してください。",
"labels": {
"self": "自レコード",
"app": "他アプリのレコード"
}
},
"self": {
"title": "雛形ファイル",
"description": "Word雛形ファイルを保存する添付ファイルフィールドを選択してください。",
"messages": {
@@ -10,6 +18,15 @@
"errors": {
"no-attachment-field-found": "このアプリに添付ファイルフィールドが見つかりません。フォームに添付ファイルフィールドを追加してください。"
}
},
"app": {
"title": "他アプリの添付ファイル情報",
"description": "アプリID・レコード番号・添付ファイルフィールドコードを入力してください。",
"labels": {
"app-id": "アプリID",
"record-id": "レコード番号",
"field-code": "添付ファイルフィールドコード"
}
}
},
"buttons": {

View File

@@ -1,7 +1,12 @@
import { KintoneRestAPIClient } from '@kintone/rest-api-client';
import moize from 'moize';
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { loadConfig, type PluginConfig } from '../common/config';
import ErrorFallback from '../common/ErrorFallback';
import Loading from '../common/Loading';
import type { KintoneFormFieldProperties } from '../common/types';
import { checkConfig } from './checkConfig';
import MenuPanel from './MenuPanel';
interface DesktopAppProps {
@@ -9,12 +14,34 @@ interface DesktopAppProps {
event: kintone.events.AppRecordDetailShowEvent | kintone.events.MobileAppRecordDetailShowEvent;
}
const cachedFormFieldsProperties = moize.promise(async (appId: number): Promise<KintoneFormFieldProperties> => {
const client = new KintoneRestAPIClient();
const { properties } = await client.app.getFormFields({ app: appId, preview: false });
return properties;
});
const cachedConfigCheck = moize.promise(
async (config: PluginConfig, record: kintone.types.SavedFields): Promise<string> => {
return await checkConfig(config, record);
},
);
const DesktopApp: React.FC<DesktopAppProps> = (props) => {
const { pluginId, event } = props;
const config = loadConfig(pluginId);
const propertiesPromise = cachedFormFieldsProperties(event.appId);
const fileKeyPromise = cachedConfigCheck(config, event.record);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<React.Suspense fallback={<Loading />}>
<MenuPanel pluginId={pluginId} event={event} />
<MenuPanel
pluginId={pluginId}
event={event}
propertiesPromise={propertiesPromise}
fileKeyPromise={fileKeyPromise}
/>
</React.Suspense>
</ErrorBoundary>
);

View File

@@ -1,78 +1,24 @@
import { KintoneRestAPIClient } from '@kintone/rest-api-client';
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 type { KintoneFormFieldProperties } from '../common/types';
import KintonePluginAlert from '../common/ui/KintonePluginAlert';
import KintonePluginButton from '../common/ui/KintonePluginButton';
import generateWordFileData from './generateWordFileData';
export interface MenuPanelProps {
pluginId: string;
event: kintone.events.AppRecordDetailShowEvent | kintone.events.MobileAppRecordDetailShowEvent;
propertiesPromise: Promise<KintoneFormFieldProperties>;
fileKeyPromise: Promise<string>;
}
const cachedFormFieldsProperties = moize.promise(async (appId: number): Promise<KintoneFormFieldProperties> => {
const client = new KintoneRestAPIClient();
const { properties } = await client.app.getFormFields({ app: appId, preview: false });
return properties;
});
const MenuPanel: React.FC<MenuPanelProps> = (props) => {
const { pluginId, event } = props;
const { event, propertiesPromise, fileKeyPromise } = 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 (
<KintonePluginAlert>
{t('name')}: {t('errors.template-field-is-not-set')}
</KintonePluginAlert>
);
}
const record = event.record[template];
if (record == null) {
return (
<KintonePluginAlert>
{t('name')}: {t('errors.template-field-is-not-available')}
</KintonePluginAlert>
);
}
if (record.type !== 'FILE') {
return (
<KintonePluginAlert>
{t('name')}: {t('errors.template-field-must-be-an-attachment-field')}
</KintonePluginAlert>
);
}
if (record.value.length === 0) {
return (
<KintonePluginAlert>
{t('name')}: {t('errors.template-field-does-not-contain-any-files')}
</KintonePluginAlert>
);
}
if (record.value.length > 1) {
return (
<KintonePluginAlert>
{t('name')}: {t('errors.template-field-contains-multiple-files')}
</KintonePluginAlert>
);
}
const { fileKey, contentType } = record.value[0];
if (contentType !== DOCX_CONTENT_TYPE) {
return (
<KintonePluginAlert>
{t('name')}: {t('errors.template-file-must-be-a-docx', { contentType })}
</KintonePluginAlert>
);
}
const properties = React.use(propertiesPromise);
const fileKey = React.use(fileKeyPromise);
const handleOnClickOutputButton = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();

View File

@@ -0,0 +1,78 @@
import { KintoneRestAPIClient } from '@kintone/rest-api-client';
import i18n from 'i18next';
import {
type PluginConfig,
type PluginConfigApp,
PluginConfigDataSource,
type PluginConfigSelf,
} from '../common/config';
import { DOCX_CONTENT_TYPE } from '../common/constants';
const checkConfigSelf = (config: PluginConfigSelf, record: kintone.types.SavedFields): string => {
const { fieldCode } = config;
if (fieldCode === '') {
throw new Error(i18n.t('errors.template-field-is-required'));
}
const field = record[fieldCode];
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<string> => {
const { appId, fieldCode, 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 (fieldCode === '') {
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[fieldCode];
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;
};
export const checkConfig = async (config: PluginConfig, record: kintone.types.SavedFields): Promise<string> => {
if (config.dataSource === PluginConfigDataSource.SELF) {
return checkConfigSelf(config, record);
} else if (config.dataSource === PluginConfigDataSource.APP) {
return checkConfigApp(config);
}
throw new Error(i18n.t('errors.unexpected-error-occurred'));
};

View File

@@ -179,10 +179,41 @@ const generateWordFileData = (
language: string,
) => {
const zip = new PizZip(content);
const parser = expressionParser.configure({
filters: {
toUpperCase: (str: string): string => str.toUpperCase(),
toLowerCase: (str: string): string => str.toLowerCase(),
regexMatch: (str: string, regex: string): boolean | string[] => {
const pattern = new RegExp(regex);
return pattern.test(str);
},
regexFilter: (arr: string[], regex: string): string[] => {
const pattern = new RegExp(regex);
if (Array.isArray(arr)) {
return arr.filter((item) => pattern.test(item));
}
return [];
},
includes: (arr: string[] | string, str: string): boolean => {
if (Array.isArray(arr)) {
return arr.includes(str);
} else {
return arr === str;
}
},
indexOf: (arr: string[] | string, str: string): number => {
if (Array.isArray(arr)) {
return arr.indexOf(str);
} else {
return arr === str ? 0 : -1;
}
},
},
});
const doc = new Docxtemplater(zip, {
paragraphLoop: true,
linebreaks: true,
parser: expressionParser,
parser: parser,
nullGetter: () => '',
});
doc.render(record2templateData(properties, record, language));

View File

@@ -1,12 +1,16 @@
{
"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.",
"unexpected-error-occurred": "An unexpected error occurred."
},
"buttons": {
"output": "Output Word"

View File

@@ -1,12 +1,16 @@
{
"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": "雛形ファイルを格納されたアプリのレコードが取得できません。プラグインの設定を見直してください。",
"unexpected-error-occurred": "予期しないエラーが発生しました。"
},
"buttons": {
"output": "Word出力"

View File

@@ -98,4 +98,20 @@ declare namespace kintone {
function getFormFields(): Promise<unknown>;
function getFormLayout(): Promise<unknown>;
}
function proxy(
url: string,
method: string,
headers: Record<string, string>,
data: Record<string, unknown> | string,
): Promise<[string, number, Record<string, string>]>;
function proxy(
url: string,
method: string,
headers: Record<string, string>,
data: Record<string, unknown> | string,
callback: (resp: [string, number, Record<string, string>]) => void,
errback: (err: Error) => void,
): void;
}