Skip to content

Commit

Permalink
Merge pull request #50 from open-formulieren/feature/currency-component
Browse files Browse the repository at this point in the history
✨ Add `currency` component
  • Loading branch information
sergei-maertens authored Nov 9, 2023
2 parents 25f12c8 + 9242eb7 commit 5700d1c
Show file tree
Hide file tree
Showing 11 changed files with 322 additions and 25 deletions.
7 changes: 7 additions & 0 deletions src/components/formio/number.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ export const WithToolTip: Story = {
},
};

export const WithPrefix: Story = {
args: {
label: 'With prefix',
prefix: 'm<sup>3</sup>',
},
};

export const WithSuffix: Story = {
args: {
label: 'With suffix',
Expand Down
24 changes: 17 additions & 7 deletions src/components/formio/number.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface NumberProps {
required?: boolean;
tooltip?: string;
description?: string;
prefix?: string;
suffix?: string;
}

Expand All @@ -27,6 +28,7 @@ export const NumberField: React.FC<JSX.IntrinsicElements['input'] & NumberProps>
required = false,
tooltip = '',
description = '',
prefix = '',
suffix = '',
...props
}) => {
Expand All @@ -49,7 +51,7 @@ export const NumberField: React.FC<JSX.IntrinsicElements['input'] & NumberProps>
tooltip={tooltip}
>
<div>
<Wrapper suffix={suffix}>
<Wrapper suffix={suffix} prefix={prefix}>
<Field
name={name}
id={htmlId}
Expand Down Expand Up @@ -78,18 +80,26 @@ export const NumberField: React.FC<JSX.IntrinsicElements['input'] & NumberProps>
};

interface WrapperProps {
suffix: string;
prefix?: string;
suffix?: string;
children: React.ReactNode;
}

const Wrapper: React.FC<WrapperProps> = ({suffix, children}) => {
if (!suffix) return <>{children}</>;
const Wrapper: React.FC<WrapperProps> = ({prefix, suffix, children}) => {
if (!(prefix || suffix)) return <>{children}</>;
return (
<div className="input-group">
{prefix && (
<div className="input-group-prepend">
<Affix className="input-group-text">{prefix}</Affix>
</div>
)}
{children}
<div className="input-group-append">
<Affix className="input-group-text">{suffix}</Affix>
</div>
{suffix && (
<div className="input-group-append">
<Affix className="input-group-text">{suffix}</Affix>
</div>
)}
</div>
);
};
Expand Down
26 changes: 26 additions & 0 deletions src/registry/currency/edit-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {IntlShape} from 'react-intl';
import {z} from 'zod';

import {buildCommonSchema} from '@/registry/validation';

// undefined (optional) for unspecified, otherwise a finite numeric value. Note that
// null would be nicer, but formio's schema does not support null for validate.min,
// validate.max or defaultValue
const currencySchema = z.number().finite().optional();

const defaultValueSchema = z.object({defaultValue: currencySchema});

const currencySpecific = z.object({
decimalLimit: z.union(Array.from({length: 11}, (_, i) => z.literal(i)) as any).optional(),
validate: z
.object({
min: currencySchema,
max: currencySchema,
})
.optional(),
});

const schema = (intl: IntlShape) =>
buildCommonSchema(intl).and(defaultValueSchema).and(currencySpecific);

export default schema;
214 changes: 214 additions & 0 deletions src/registry/currency/edit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import {CurrencyComponentSchema} from '@open-formulieren/types';
import {useFormikContext} from 'formik';
import {FormattedMessage, useIntl} from 'react-intl';

import {
BuilderTabs,
ClearOnHide,
Description,
Hidden,
IsSensitiveData,
Key,
Label,
PresentationConfig,
Registration,
SimpleConditional,
Tooltip,
Translations,
Validate,
useDeriveComponentKey,
} from '@/components/builder';
import {LABELS} from '@/components/builder/messages';
import {Checkbox, NumberField, TabList, TabPanel, Tabs} from '@/components/formio';
import {getErrorNames} from '@/utils/errors';

import {EditFormDefinition} from '../types';

/**
* Form to configure a Formio 'currency' type component.
*/
const EditForm: EditFormDefinition<CurrencyComponentSchema> = () => {
const intl = useIntl();
const [isKeyManuallySetRef, generatedKey] = useDeriveComponentKey();
const {errors} = useFormikContext<CurrencyComponentSchema>();

const erroredFields = Object.keys(errors).length
? getErrorNames<CurrencyComponentSchema>(errors)
: [];
// TODO: pattern match instead of just string inclusion?
// TODO: move into more generically usuable utility when we implement other component
// types
const hasAnyError = (...fieldNames: string[]): boolean => {
if (!erroredFields.length) return false;
return fieldNames.some(name => erroredFields.includes(name));
};

Validate.useManageValidatorsTranslations<CurrencyComponentSchema>(['required', 'min', 'max']);

return (
<Tabs>
<TabList>
<BuilderTabs.Basic
hasErrors={hasAnyError(
'label',
'key',
'description',
'tooltip',
'showInSummary',
'showInEmail',
'showInPDF',
'hidden',
'clearOnHide',
'isSensitiveData',
'defaultValue',
'decimalLimit',
'allowNegative'
)}
/>
<BuilderTabs.Advanced hasErrors={hasAnyError('conditional')} />
<BuilderTabs.Validation hasErrors={hasAnyError('validate')} />
<BuilderTabs.Registration hasErrors={hasAnyError('registration')} />
<BuilderTabs.Translations hasErrors={hasAnyError('openForms.translations')} />
</TabList>

{/* Basic tab */}
<TabPanel>
<Label />
<Key isManuallySetRef={isKeyManuallySetRef} generatedValue={generatedKey} />
<Description />
<Tooltip />
<PresentationConfig />
<Hidden />
<ClearOnHide />
<IsSensitiveData />
<DefaultValue />
<DecimalLimit />
<AllowNegative />
</TabPanel>

{/* Advanced tab */}
<TabPanel>
<SimpleConditional />
</TabPanel>

{/* Validation tab */}
<TabPanel>
<Validate.Required />
<Validate.ValidatorPluginSelect />
<Validate.Min />
<Validate.Max />
<Validate.ValidationErrorTranslations />
</TabPanel>

{/* Registration tab */}
<TabPanel>
<Registration.RegistrationAttributeSelect />
</TabPanel>

{/* Translations */}
<TabPanel>
<Translations.ComponentTranslations<CurrencyComponentSchema>
propertyLabels={{
label: intl.formatMessage(LABELS.label),
description: intl.formatMessage(LABELS.description),
tooltip: intl.formatMessage(LABELS.tooltip),
}}
/>
</TabPanel>
</Tabs>
);
};

EditForm.defaultValues = {
currency: 'EUR',
// basic tab
label: '',
key: '',
description: '',
tooltip: '',
showInSummary: true,
showInEmail: false,
showInPDF: true,
hidden: false,
clearOnHide: true,
isSensitiveData: false,
defaultValue: undefined,
decimalLimit: undefined,
allowNegative: false,
// Advanced tab
conditional: {
show: undefined,
when: '',
eq: '',
},
// Validation tab
validate: {
required: false,
plugins: [],
min: undefined,
max: undefined,
},
translatedErrors: {},
// Registration tab
registration: {
attribute: '',
},
};

const DefaultValue: React.FC = () => {
const intl = useIntl();
const tooltip = intl.formatMessage({
description: "Tooltip for 'defaultValue' builder field",
defaultMessage: 'This will be the initial value for this field before user interaction.',
});
return (
<NumberField
name="defaultValue"
label={<FormattedMessage {...LABELS.defaultValue} />}
tooltip={tooltip}
prefix="€"
/>
);
};

const DecimalLimit: React.FC = () => {
const intl = useIntl();
const tooltip = intl.formatMessage({
description: "Tooltip for 'decimalLimit' builder field",
defaultMessage: 'The maximum number of decimal places.',
});
return (
<NumberField
name="decimalLimit"
label={
<FormattedMessage
description="Label for 'decimalLimit' builder field"
defaultMessage="Decimal places"
/>
}
tooltip={tooltip}
/>
);
};

const AllowNegative: React.FC = () => {
const intl = useIntl();
const tooltip = intl.formatMessage({
description: "Tooltip for 'allowNegative' builder field",
defaultMessage: 'Allow negative values.',
});
return (
<Checkbox
name="allowNegative"
label={
<FormattedMessage
description="Label for 'allowNegative' builder field"
defaultMessage="Allow negative values"
/>
}
tooltip={tooltip}
/>
);
};

export default EditForm;
10 changes: 10 additions & 0 deletions src/registry/currency/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import EditForm from './edit';
import validationSchema from './edit-validation';
import Preview from './preview';

export default {
edit: EditForm,
editSchema: validationSchema,
preview: Preview,
defaultValue: undefined, // formik field value
};
30 changes: 30 additions & 0 deletions src/registry/currency/preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {CurrencyComponentSchema} from '@open-formulieren/types';

import {NumberField} from '@/components/formio';

import {ComponentPreviewProps} from '../types';

/**
* Show a formio currency component preview.
*
* NOTE: for the time being, this is rendered in the default Formio bootstrap style,
* however at some point this should use the components of
* @open-formulieren/formio-renderer instead for a more accurate preview.
*/
const Preview: React.FC<ComponentPreviewProps<CurrencyComponentSchema>> = ({component}) => {
// FIXME: incorporate decimalLimit and allowNegative
const {key, label, description, tooltip, validate = {}} = component;
const {required = false} = validate;
return (
<NumberField
name={key}
label={label}
description={description}
tooltip={tooltip}
required={required}
prefix="€"
/>
);
};

export default Preview;
6 changes: 4 additions & 2 deletions src/registry/datetime/datetime-component.stories.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {expect} from '@storybook/jest';
import {Meta, StoryObj} from '@storybook/react';
import {userEvent, within} from '@storybook/testing-library';
import {userEvent, waitFor, within} from '@storybook/testing-library';

import ComponentEditForm from '@/components/ComponentEditForm';
import {withFormik} from '@/sb-decorators';
Expand Down Expand Up @@ -42,7 +42,9 @@ export const ValidateDeltaConstraintConfiguration: Story = {
await step('Navigate to validation tab and open maxDate configuration', async () => {
await userEvent.click(canvas.getByRole('link', {name: 'Validation'}));
await userEvent.click(canvas.getByText(/Maximum date/));
expect(await canvas.findByText('Mode preset')).toBeVisible();
await waitFor(async () => {
expect(await canvas.findByText('Mode preset')).toBeVisible();
});
});

await step('Configure relative to variable', async () => {
Expand Down
Loading

0 comments on commit 5700d1c

Please sign in to comment.