Skip to content

Commit 82b6430

Browse files
jasonchung1871usingtechnologyRyanBirtch-aot
authored
feat: FORMS-2501 Add rounding support to number components (bcgov#1744)
* Add rounding support to number components Introduced a RoundingMixin to provide configurable rounding (method and decimal places) for SimpleNumber and SimpleNumberAdvanced components. Updated their schemas, edit forms, and value processing logic to support rounding. Also updated the deployment script to copy CSS files and added a CSS class for form layout. * Refactor number components and improve rounding mixin Refactored SimpleNumberAdvanced to extend SimpleNumberComponent, simplifying inheritance and schema extension. Improved RoundingMixin to cache original prototype methods for setValue, getValue, calculateValue, and getValueAsString, ensuring correct method resolution. Updated component-update.js to also copy .css.map files during deployment. * Refactor rounding mixin to class-based mixin for numbers Replaces the object-based RoundingMixin with a class-based mixin (WithRounding) for better TypeScript compatibility and cleaner inheritance. Updates the SimpleNumber component to use the new mixin pattern, improving maintainability and type safety. * Refactor SimpleNumber component typing and mixin usage Improves type safety by defining explicit interfaces for Formio component methods and constructors. Updates mixin and class usage to leverage these types, removes unnecessary type assertions, and clarifies the ParentComponent type. * Refactor number rounding and decimal config in components Reworks rounding and decimal configuration for SimpleNumber and related components. Moves decimal and rounding options into a unified fieldset, updates form layouts, and consolidates logic for formatting and rounding values. Removes duplicate config from edit forms and simplifies the component implementation. * Remove unused imports and fix rounding logic Cleaned up unused imports in SimpleNumber Component and corrected the rounding logic by removing an extraneous argument from Math.floor. This improves code clarity and correctness. * sonarcloud fixes * Sonarqube fixes * Last sonarqube fix --------- Co-authored-by: usingtechnology <39388115+usingtechnology@users.noreply.github.com> Co-authored-by: RyanBirtch-aot <104386035+RyanBirtch-aot@users.noreply.github.com> Co-authored-by: RyanBirtch-aot <ryan.birtch@aot-technologies.com>
1 parent 4b0684a commit 82b6430

9 files changed

Lines changed: 234 additions & 73 deletions

File tree

app/frontend/component-update.js

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// This script attempts to gracefully rebuild and update @bcgov/formio if necessary
2-
const fs = require('fs');
3-
const path = require('path');
2+
const fs = require('node:fs');
3+
const path = require('node:path');
44

55
const COMPONENTS_DIR = '../../components';
66
const FORMIO_DIR = 'src/formio';
@@ -81,10 +81,35 @@ function cleanComponents() {
8181
* @description Redeploy `@bcgov/formio` library
8282
*/
8383
function deployComponents() {
84-
console.log(`Redeploying ${TITLE}...`); // eslint-disable-line no-console
84+
console.log(`Redeploying ${TITLE}...`);
8585
if (fs.existsSync(FORMIO_DIR)) fs.rmSync(FORMIO_DIR, { recursive: true });
86-
copyDirRecursiveSync(`${COMPONENTS_DIR}/lib`, FORMIO_DIR);
87-
console.log(`${TITLE} has been redeployed`); // eslint-disable-line no-console
86+
87+
// Copy lib directory (JavaScript components)
88+
if (fs.existsSync(`${COMPONENTS_DIR}/lib`)) {
89+
copyDirRecursiveSync(`${COMPONENTS_DIR}/lib`, FORMIO_DIR);
90+
}
91+
92+
// Copy only CSS files from dist directory
93+
if (fs.existsSync(`${COMPONENTS_DIR}/dist`)) {
94+
const cssFiles = fs
95+
.readdirSync(`${COMPONENTS_DIR}/dist`)
96+
.filter((file) => file.endsWith('.css') || file.endsWith('.css.map'));
97+
98+
if (cssFiles.length > 0) {
99+
const cssDir = path.join(FORMIO_DIR, 'css');
100+
if (!fs.existsSync(cssDir)) {
101+
fs.mkdirSync(cssDir, { recursive: true });
102+
}
103+
104+
cssFiles.forEach((file) => {
105+
const sourcePath = path.join(`${COMPONENTS_DIR}/dist`, file);
106+
const targetPath = path.join(cssDir, file);
107+
copyFileSync(sourcePath, targetPath);
108+
});
109+
}
110+
}
111+
112+
console.log(`${TITLE} has been redeployed`);
88113
}
89114

90115
//
@@ -98,7 +123,7 @@ function deployComponents() {
98123
* @param {string} [cwd] Working directory of the command to run
99124
*/
100125
function runSync(cmd, cwd = undefined) {
101-
const { spawnSync } = require('child_process');
126+
const { spawnSync } = require('node:child_process');
102127
const parts = cmd.split(/\s+/g);
103128
const opts = { stdio: 'inherit', shell: true };
104129
if (cwd) opts.cwd = cwd;

app/frontend/src/main.js

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ app.config.globalProperties.$filters = {
4141
// has to be done BEFORE the keycloak adapter for some reason or it breaks the keycloak library on non-Chromium MS Edge (or IE11).
4242
// No idea why, probably a polyfill clash
4343
import BcGovFormioComponents from '~/formio/lib';
44+
import '~/formio/css/bcgov-formio-components.css';
4445
import { Formio } from '@formio/vue';
4546
Formio.use(BcGovFormioComponents);
4647

@@ -84,18 +85,20 @@ app.component('BasePanel', BasePanel);
8485
app.component('BasePrintButton', BasePrintButton);
8586
app.component('BaseSecure', BaseSecure);
8687

87-
// IE11 Detection (https://stackoverflow.com/a/21825207)
88-
if (!!window.MSInputMethodContext && !!document.documentMode) {
89-
document.write(`<div style="padding-top: 5em; text-align: center;">
88+
(async () => {
89+
// IE11 Detection (https://stackoverflow.com/a/21825207)
90+
if (!!window.MSInputMethodContext && !!document.documentMode) {
91+
document.write(`<div style="padding-top: 5em; text-align: center;">
9092
<h1>We're sorry but ${
9193
import.meta.env.VITE_TITLE
9294
} is not supported in Internet Explorer.</h1>
9395
<h1>Please use a modern browser instead (<a href="https://www.google.com/intl/en_ca/chrome/">Chrome</a>, <a href="https://www.mozilla.org/en-CA/firefox/">Firefox</a>, etc).</h1>
9496
</div>`);
95-
NProgress.done();
96-
} else {
97-
loadConfig();
98-
}
97+
NProgress.done();
98+
} else {
99+
await loadConfig();
100+
}
101+
})();
99102

100103
/**
101104
* @function initializeApp
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Rounding functionality mixin for FormIO number components
3+
*
4+
* This mixin implements a common JavaScript mixin pattern using object composition.
5+
* References:
6+
* - JavaScript Mixins: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign
7+
* - TypeScript Mixins: https://www.typescriptlang.org/docs/handbook/mixins.html
8+
* - FormIO Component Extension Patterns: https://github.com/formio/formio.js/wiki/Custom-Components
9+
*
10+
* The rounding logic addresses floating-point precision issues common in JavaScript:
11+
* - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/EPSILON
12+
* - https://stackoverflow.com/questions/11832914/how-to-round-to-at-most-2-decimal-places-if-necessary
13+
*/
14+
export interface RoundingConfig {
15+
method: 'round' | 'floor' | 'ceil';
16+
}
17+
18+
// Add this to your Rounding.mixin.ts file
19+
export const RoundingEditFormComponents = [
20+
{
21+
type: 'fieldset',
22+
title: 'Number Precision & Rounding',
23+
key: 'precision-fieldset',
24+
components: [
25+
{
26+
type: 'checkbox',
27+
key: 'requireDecimal',
28+
label: 'Require Decimal',
29+
tooltip: 'Always show decimals, even if trailing zeros.',
30+
input: true,
31+
weight: 100,
32+
defaultValue: false
33+
},
34+
{
35+
type: 'number',
36+
input: true,
37+
weight: 80,
38+
key: 'decimalLimit',
39+
label: 'Decimal Places',
40+
tooltip: 'The maximum number of decimal places',
41+
validate: {
42+
integer: true,
43+
},
44+
conditional: {
45+
show: true,
46+
when: 'requireDecimal',
47+
eq: true
48+
},
49+
},
50+
{
51+
type: 'select',
52+
key: 'rounding.method',
53+
label: 'Rounding Method',
54+
data: {
55+
values: [
56+
{ label: 'Round (standard)', value: 'round' },
57+
{ label: 'Floor (round down)', value: 'floor' },
58+
{ label: 'Ceiling (round up)', value: 'ceil' }
59+
]
60+
},
61+
defaultValue: 'round',
62+
input: true,
63+
weight: 101,
64+
conditional: {
65+
show: true,
66+
when: 'requireDecimal',
67+
eq: true
68+
}
69+
}
70+
]
71+
}
72+
];
73+
74+
/**
75+
* Add rounding configuration to schema
76+
*/
77+
export function addRoundingToSchema(schema: any) {
78+
return {
79+
...schema,
80+
rounding: {
81+
method: 'round' as const,
82+
}
83+
};
84+
}

components/src/components/SimpleNumber/Component.form.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import EditValidation from './editForm/Component.edit.validation';
66

77
import SimpleApi from '../Common/Simple.edit.api';
88
import SimpleConditional from '../Common/Simple.edit.conditional';
9+
import { RoundingEditFormComponents } from '../Common/Rounding.mixin';
910

1011
export default function(...extend) {
1112
return baseEditForm([
1213
EditDisplay,
1314
{
1415
key: 'data',
15-
ignore: true,
16+
ignore: true
1617
},
1718
{
1819
key: 'api',
@@ -38,7 +39,10 @@ export default function(...extend) {
3839
label: 'Data',
3940
key: 'customData',
4041
weight: 10,
41-
components: EditData
42+
components: [
43+
...EditData,
44+
...RoundingEditFormComponents,
45+
],
4246
},
4347
{
4448
label: 'Validation',
@@ -57,6 +61,6 @@ export default function(...extend) {
5761
key: 'customConditional',
5862
weight: 40,
5963
components: SimpleConditional
60-
}
64+
},
6165
], ...extend);
6266
}
Lines changed: 63 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,75 @@
11
/* tslint:disable */
22
import { Components } from 'formiojs';
3-
const ParentComponent = (Components as any).components.number;
43
import editForm from './Component.form';
5-
4+
import { addRoundingToSchema } from '../Common/Rounding.mixin';
65
import { Constants } from '../Common/Constants';
76

7+
const ParentComponent = (Components as any).components.number;
8+
89
const ID = 'simplenumber';
910
const DISPLAY = 'Number';
1011

12+
// Apply the mixin to create the final component class
1113
export default class Component extends (ParentComponent as any) {
12-
static schema(...extend) {
13-
return ParentComponent.schema({
14-
type: ID,
15-
label: DISPLAY,
16-
key: ID,
17-
validate: {
18-
min: '',
19-
max: '',
20-
step: 'any',
21-
integer: ''
22-
}
23-
}, ...extend);
24-
}
14+
static schema(...extend: any[]) {
15+
const baseSchema = ParentComponent.schema(
16+
{
17+
type: ID,
18+
label: DISPLAY,
19+
key: ID,
20+
validate: {
21+
min: '',
22+
max: '',
23+
step: 'any',
24+
integer: '',
25+
},
26+
},
27+
...extend
28+
);
2529

26-
public static editForm = editForm;
27-
28-
static get builderInfo() {
29-
return {
30-
title: DISPLAY,
31-
group: 'simple',
32-
icon: 'hashtag',
33-
weight: 10,
34-
documentation: Constants.DEFAULT_HELP_LINK,
35-
schema: Component.schema()
36-
};
30+
return addRoundingToSchema(baseSchema);
31+
}
32+
33+
public static editForm = editForm;
34+
35+
static get builderInfo() {
36+
return {
37+
title: DISPLAY,
38+
group: 'simple',
39+
icon: 'hashtag',
40+
weight: 10,
41+
documentation: Constants.DEFAULT_HELP_LINK,
42+
schema: Component.schema(),
43+
};
44+
}
45+
46+
formatValue(value: any) {
47+
if (
48+
this.component.requireDecimal &&
49+
value !== null &&
50+
value !== undefined &&
51+
value !== ''
52+
) {
53+
const decimalPlaces = this.component.decimalLimit || 2;
54+
const multiplier = Math.pow(10, decimalPlaces);
55+
let numValue = Number.parseFloat(value);
56+
if (!Number.isNaN(numValue)) {
57+
switch (this.component.rounding.method) {
58+
case 'floor':
59+
numValue = Math.floor(numValue * multiplier) / multiplier;
60+
break;
61+
case 'ceil':
62+
numValue = Math.ceil(numValue * multiplier) / multiplier;
63+
break;
64+
case 'round':
65+
default:
66+
numValue = Math.round(numValue * multiplier) / multiplier;
67+
break;
68+
}
69+
return numValue.toFixed(decimalPlaces);
70+
}
3771
}
72+
73+
return super.formatValue(value);
74+
}
3875
}

components/src/components/SimpleNumber/editForm/Component.edit.data.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,4 @@ export default [
99
label: 'Use Thousands Separator',
1010
tooltip: 'Separate thousands by local delimiter.'
1111
},
12-
{
13-
type: 'number',
14-
input: true,
15-
weight: 80,
16-
key: 'decimalLimit',
17-
label: 'Decimal Places',
18-
tooltip: 'The maximum number of decimal places.'
19-
},
20-
{
21-
type: 'checkbox',
22-
input: true,
23-
weight: 90,
24-
key: 'requireDecimal',
25-
label: 'Require Decimal',
26-
tooltip: 'Always show decimals, even if trailing zeros.'
27-
},
2812
];

components/src/components/SimpleNumberAdvanced/Component.form.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,37 @@
11
import baseEditForm from 'formiojs/components/number/Number.form';
2+
3+
import AdvancedEditDisplay from '../Common/Advanced.edit.display';
4+
import AdvancedEditData from '../Common/Advanced.edit.data';
25
import EditValidation from './editForm/Component.edit.validation';
6+
import { RoundingEditFormComponents } from '../Common/Rounding.mixin';
37

48
export default function(...extend) {
59
return baseEditForm([
10+
{
11+
key: 'display',
12+
ignore: true,
13+
},
14+
{
15+
label: 'Display',
16+
key: 'customDisplay',
17+
weight: 0,
18+
components: [
19+
...AdvancedEditDisplay,
20+
],
21+
},
22+
{
23+
key: 'data',
24+
ignore: true,
25+
},
26+
{
27+
label: 'Data',
28+
key: 'customData',
29+
weight: 5,
30+
components: [
31+
...AdvancedEditData,
32+
...RoundingEditFormComponents,
33+
],
34+
},
635
{
736
key: 'validation',
837
ignore: true
@@ -12,6 +41,6 @@ export default function(...extend) {
1241
key: 'customValidation',
1342
weight: 15,
1443
components: EditValidation
15-
}
44+
},
1645
], ...extend);
1746
}

0 commit comments

Comments
 (0)