Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"ConnectionStrings": {
"Default": "Data Source=.; Initial Catalog=functional-shesha-test;Integrated Security=SSPI;TrustServerCertificate=True;"
"Default": "Data Source=.; Initial Catalog=main20jan;Integrated Security=SSPI;TrustServerCertificate=True;"
},
"App": {
"ServerRootAddress": "http://localhost:21021",
Expand Down
3 changes: 3 additions & 0 deletions shesha-reactjs/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,9 @@ const makeStrictConfig = (path) => {
}

export default [
{
ignores: [".next/**", "dist/**"],
},
{
...baseTsConfig,
files: [
Expand Down
15 changes: 15 additions & 0 deletions shesha-reactjs/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@
"numberField": "https://docs.shesha.io/docs/front-end-basics/form-components/data-entry/numberfield",

"panel": "https://docs.shesha.io/docs/front-end-basics/form-components/Layouts/panel",
"passwordCombo": "https://docs.shesha.io/docs/front-end-basics/form-components/Advanced/password-combo",
"progress": "https://docs.shesha.io/docs/front-end-basics/form-components/Advanced/progress",

"queryBuilder": "https://docs.shesha.io/docs/category/form-components",
Expand Down
143 changes: 102 additions & 41 deletions shesha-reactjs/src/designer-components/passwordCombo/index.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,124 @@
import React from 'react';
import {
confirmModel,
defaultStyles,
getDefaultModel,
getFormItemProps,
getInputProps,
IPasswordComponentProps,
} from './utils';
import { DataTypes, StringFormats } from '@/interfaces/dataTypes';
import { IToolboxComponent } from '@/interfaces';
import { LockOutlined } from '@ant-design/icons';
import { migrateCustomFunctions, migratePropertyName, migrateReadOnly } from '@/designer-components/_common-migrations/migrateSettings';
import { PasswordCombo } from './passwordCombo';
import { IInputStyles, useForm } from '@/providers';
import { IInputStyles } from '@/providers';
import { validateConfigurableComponentSettings } from '@/providers/form/utils';
import { migrateFormApi } from '../_common-migrations/migrateFormApi1';
import { ValidationErrors } from '@/components/validationErrors';
import { isValidGuid } from '@/components/formDesigner/components/utils';
import { migratePrevStyles } from '../_common-migrations/migrateStyles';
import { getSettings } from './settingsForm';
import { useStyles } from './styles';
import { ITextFieldComponentProps } from '../textField/interfaces';
import { defaultStyles as textFieldDefaultStyles } from '../textField/utils';
import { SettingsMigrationContext } from '@/interfaces/formDesigner';
import { nanoid } from '@/utils/uuid';

const PasswordComboComponent: IToolboxComponent<IPasswordComponentProps> = {
type: 'passwordCombo',
isInput: true,
name: 'Password combo',
preserveDimensionsInDesigner: true,
icon: <LockOutlined />,
dataTypeSupported: ({ dataType, dataFormat }) =>
dataType === DataTypes.string && dataFormat === StringFormats.password,
Factory: ({ model }) => {
const defaultModel = getDefaultModel(model);
const { placeholder, confirmPlaceholder, message, minLength, repeatPropertyName } = defaultModel || {};
const { formData } = useForm();
const CONFIRM_MATCH_VALIDATOR = `
try {
const formValues = form?.getFieldsValue() ?? {};
const confirmValue = formValues['__PROPERTY_NAME__Confirm'] ?? formValues['__PROPERTY_NAME__confirm'];
if (value && confirmValue && value !== confirmValue) {
return Promise.reject('Passwords do not match');
} else {
return Promise.resolve();
}
} catch (e) {
return Promise.resolve();
}
`;

const options = { hidden: model.hidden, formData };
export const migratePasswordComboToTextField = (
prev: IPasswordComponentProps,
context: SettingsMigrationContext,
): ITextFieldComponentProps => {
const { flatStructure } = context;

const { styles } = useStyles({ fontFamily: model.font?.type, fontWeight: model.font?.weight, textAlign: model.font?.align });
const confirmId = nanoid();
const confirmComponent: ITextFieldComponentProps = {
id: confirmId,
type: 'textField',
propertyName: prev.repeatPropertyName || `${prev.propertyName}Confirm`,
label: prev.confirmLabel || 'Confirm Password',
placeholder: prev.confirmPlaceholder || 'Confirm password',
description: prev.confirmDescription,
textType: 'password',
hidden: prev.hidden,
hideLabel: true,
editMode: prev.editMode,
readOnly: prev.readOnly,
parentId: prev.parentId,
validate: {
required: prev.validate?.required,
validator: CONFIRM_MATCH_VALIDATOR.replace('__PROPERTY_NAME__', prev.propertyName),
message: prev.message || 'Passwords do not match',
},
version: 6,
...textFieldDefaultStyles(),
};

flatStructure.allComponents[confirmId] = confirmComponent;

if (model?.background?.type === 'storedFile' && model.background.storedFile?.id && !isValidGuid(model?.background.storedFile.id)) {
return <ValidationErrors error="The provided StoredFileId is invalid" />;
const parentId = prev.parentId || 'root';
const siblings = flatStructure.componentRelations[parentId];
if (siblings) {
const originalIndex = siblings.indexOf(prev.id);
if (originalIndex !== -1) {
siblings.splice(originalIndex + 1, 0, confirmId);
} else {
siblings.push(confirmId);
}
}

return (
const converted: ITextFieldComponentProps = {
id: prev.id,
type: 'textField',
propertyName: prev.propertyName,
label: prev.label,
placeholder: prev.placeholder,
description: prev.description,
textType: 'password',
hidden: prev.hidden,
editMode: prev.editMode,
readOnly: prev.readOnly,
parentId: prev.parentId,
validate: {
required: prev.validate?.required,
},
version: 6,
...textFieldDefaultStyles(),
desktop: prev.desktop,
tablet: prev.tablet,
mobile: prev.mobile,
font: prev.font,
background: prev.background,
border: prev.border,
shadow: prev.shadow,
dimensions: prev.dimensions,
stylingBox: prev.stylingBox,
};

<PasswordCombo
inputProps={{ ...getInputProps(defaultModel, formData), disabled: defaultModel.readOnly, className: styles.passwordCombo }}
placeholder={placeholder}
confirmPlaceholder={confirmPlaceholder}
formItemProps={getFormItemProps(defaultModel, options)}
formItemConfirmProps={getFormItemProps(confirmModel(defaultModel), options)}
passwordLength={minLength}
errorMessage={message}
style={model.allStyles.fullStyle}
className={styles.passwordCombo}
repeatPropertyName={repeatPropertyName}
/>
);
},
return converted;
};

/**
* @deprecated Use the textField component with textType: 'password' instead.
* This component is kept only for migration of existing forms.
* All instances are automatically migrated to two separate textField components (password + confirm) via the migrator.
*/
const PasswordComboComponent: IToolboxComponent<IPasswordComponentProps> = {
type: 'passwordCombo',
isInput: true,
isHidden: true,
name: 'Password combo (Legacy)',
preserveDimensionsInDesigner: true,
icon: <LockOutlined />,
dataTypeSupported: ({ dataType, dataFormat }) =>
dataType === DataTypes.string && dataFormat === StringFormats.password,
Factory: () => null,
settingsFormMarkup: getSettings,
validateSettings: (model) => validateConfigurableComponentSettings(getSettings, model),
migrator: (m) => m
Expand All @@ -74,7 +134,8 @@ const PasswordComboComponent: IToolboxComponent<IPasswordComponentProps> = {

return { ...prev, desktop: { ...styles }, tablet: { ...styles }, mobile: { ...styles } };
})
.add<IPasswordComponentProps>(7, (prev) => ({ ...migratePrevStyles(prev, defaultStyles()), editMode: 'inherited' })),
.add<IPasswordComponentProps>(7, (prev) => ({ ...migratePrevStyles(prev, defaultStyles()), editMode: 'inherited' }))
.add<ITextFieldComponentProps>(8, (prev: IPasswordComponentProps, context) => migratePasswordComboToTextField(prev, context)),
};

export default PasswordComboComponent;
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ export const getSettings: SettingsFormMarkupFactory = ({ fbf }) => {
.addSettingsInputRow({
id: nanoid(),
parentId: validationTabId,
hidden: {
_code: 'return getSettingValue(data?.textType) === "password";',
_mode: 'code',
_value: false,
} as any,
inputs: [
{
type: 'numberField',
Expand Down
10 changes: 10 additions & 0 deletions shesha-reactjs/src/designer-components/textField/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,19 @@ export const useStyles = createStyles(({ css, cx, token }, { fontWeight, fontFam
:hover {
border-color: ${token.colorPrimary} !important;
}
`);

const passwordFieldWrapper = cx("sha-password-field-wrapper", css`
.ant-form-item-explain-error {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: default;
}
`);

return {
textField,
passwordFieldWrapper,
};
});
64 changes: 53 additions & 11 deletions shesha-reactjs/src/designer-components/textField/textField.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CodeOutlined } from '@ant-design/icons';
import { Input } from 'antd';
import { Input, Tooltip } from 'antd';
import { InputProps } from 'antd/lib/input';
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import { ConfigurableFormItem } from '@/components/formDesigner/components/formItem';
import { getAllEventHandlers } from '@/components/formDesigner/components/utils';
import { DataTypes, StringFormats } from '@/interfaces/dataTypes';
Expand All @@ -16,7 +16,7 @@ import { IconType, ShaIcon } from '@/components/shaIcon';
import { useStyles } from './styles';
import { migratePrevStyles } from '../_common-migrations/migrateStyles';
import { getSettings } from './settingsForm';
import { defaultStyles } from './utils';
import { defaultStyles, buildPasswordValidatorString, usePasswordComplexitySettings, validatePasswordValue } from './utils';

const TextFieldComponent: TextFieldComponentDefinition = {
type: 'textField',
Expand Down Expand Up @@ -51,7 +51,29 @@ const TextFieldComponent: TextFieldComponentDefinition = {
console.warn(`Invalid regExp pattern for '${model.propertyName}':`, model, error);
return null;
}
}, [model.regExp]);
}, [model]);

const isPassword = model.textType === 'password';
const passwordComplexity = usePasswordComplexitySettings();
const [passwordError, setPasswordError] = useState<string | null>(null);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use one source of truth for password validation.

Line 66 skips injecting the password validator when model.validate?.validator already exists, but Lines 106-110 still compute passwordError from the complexity rules, and Lines 134-139 surface that state in the tooltip. This can make the tooltip warn about a rule the form is not actually enforcing, and because that state is only updated inside onChangeInternal, it can also drift after resets or other external value updates. Please drive the tooltip from the committed value and the effective validator set, or compose the password validator into the existing validator before both paths use it.

Also applies to: 65-76, 97-110, 134-139

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shesha-reactjs/src/designer-components/textField/textField.tsx` at line 58,
The tooltip and passwordError state drift because password complexity is
evaluated independently in onChangeInternal instead of using the effective
validator that the form actually commits; compose the password validator into
model.validate.validator (or into the effective validator used by the component)
so there is a single source of truth, then derive the tooltip message by running
that composed validator against the committed value (or by computing
passwordError from validate(value)) inside a useEffect that listens to
value/model.validate changes rather than only in onChangeInternal; remove or
stop updating the separate passwordError state ad-hoc and ensure components like
passwordError, setPasswordError, onChangeInternal, model.validate?.validator and
the tooltip rendering all read from the composed validator result.


const passwordValidator = useMemo(() =>
isPassword ? buildPasswordValidatorString(passwordComplexity) : null,
[isPassword, passwordComplexity],
);

const modelWithValidation = useMemo(() => {
if (!isPassword || !passwordValidator || model.validate?.validator) return model;
return {
...model,
validate: {
...(model.validate || {}),
minLength: undefined,
maxLength: undefined,
validator: passwordValidator,
},
};
}, [model, isPassword, passwordValidator]);

if (model.hidden) return null;

Expand All @@ -66,18 +88,28 @@ const TextFieldComponent: TextFieldComponentDefinition = {
readOnly: model.readOnly,
spellCheck: model.spellCheck,
style: model.allStyles.fullStyle,
maxLength: model.validate?.maxLength,
max: model.validate?.maxLength,
minLength: model.validate?.minLength,
};

return (
<ConfigurableFormItem model={model}>
const fieldContent = (
<ConfigurableFormItem model={modelWithValidation}>
{(value, onChange) => {
const customEvents = calculatedModel.eventHandlers;
const onChangeInternal = (...args: any[]): void => {
const inputValue: string | undefined = args[0]?.currentTarget?.value?.toString();
const onChangeInternal = (...args: unknown[]): void => {
const arg = args[0];
const inputValue: string | undefined =
arg !== null && typeof arg === 'object' && 'currentTarget' in arg &&
arg.currentTarget !== null && typeof arg.currentTarget === 'object' && 'value' in arg.currentTarget
? arg.currentTarget.value?.toString()
: undefined;
const isEmpty = inputValue === undefined || inputValue === null || inputValue === '';

if (isPassword && inputValue) {
const errors = validatePasswordValue(inputValue, passwordComplexity);
setPasswordError(errors.length > 0 ? `Password must contain ${errors.join(', ')}` : null);
} else if (isPassword) {
setPasswordError(null);
}

const isRegExpMatch = regExpObj && Boolean(inputValue?.match(regExpObj));
if ((!isEmpty && isRegExpMatch) || !regExpObj || isEmpty) {
const changedValue = customEvents.onChange({ value: inputValue }, args[0]);
Expand All @@ -98,6 +130,16 @@ const TextFieldComponent: TextFieldComponentDefinition = {
}}
</ConfigurableFormItem>
);

if (isPassword) {
return (
<Tooltip title={passwordError ?? undefined} placement="bottom">
<div className={styles.passwordFieldWrapper}>{fieldContent}</div>
</Tooltip>
);
}

return fieldContent;
},
settingsFormMarkup: getSettings,
validateSettings: (model) => validateConfigurableComponentSettings(getSettings, model),
Expand Down
Loading
Loading