diff --git a/docs/src/core-concepts/validation-properties.md b/docs/src/core-concepts/validation-properties.md index 28c5769..02cc9f3 100644 --- a/docs/src/core-concepts/validation-properties.md +++ b/docs/src/core-concepts/validation-properties.md @@ -132,3 +132,28 @@ Will reset both your validation state and your form state to their initial value Clears the $externalResults state back to an empty object. + + +## Specific properties for fields + +### `$rules` +- Type: `Record` + +This is reactive tree containing all the declared rules of your field. +To know more about the rule properties check the [rules properties section](/core-concepts/rules-properties) + + +## Specific properties for nested objects + +### `$fields` +- Type: `Record` + +This represents all the children of your object. You can access any nested child at any depth to get the relevant data you need for your form. + + +## Specific properties for collections + + +### `$each` + +### `$field` diff --git a/packages/core/src/core/createRule/createRule.ts b/packages/core/src/core/createRule/createRule.ts index c13e3a4..e0c226d 100644 --- a/packages/core/src/core/createRule/createRule.ts +++ b/packages/core/src/core/createRule/createRule.ts @@ -1,9 +1,4 @@ -import type { - InferRegleRule, - RegleRuleInit, - RegleRuleMetadataDefinition, - RegleUniversalParams, -} from '../../types'; +import type { InferRegleRule, RegleRuleInit, RegleRuleMetadataDefinition, RegleUniversalParams } from '../../types'; import { defineRuleProcessors } from './defineRuleProcessors'; import { getFunctionParametersLength } from './unwrapRuleParameters'; @@ -63,12 +58,14 @@ export function createRule< ruleFactory.validator = staticProcessors.validator; ruleFactory.message = staticProcessors.message; ruleFactory.active = staticProcessors.active; + ruleFactory.tooltip = staticProcessors.tooltip; ruleFactory.type = staticProcessors.type; ruleFactory.exec = staticProcessors.exec; ruleFactory._validator = staticProcessors.validator; ruleFactory._message = staticProcessors.message; ruleFactory._active = staticProcessors.active; + ruleFactory._tooltip = staticProcessors.tooltip; ruleFactory._type = definition.type; ruleFactory._patched = false; ruleFactory._async = isAsync as TAsync; diff --git a/packages/core/src/core/createRule/defineRuleProcessors.ts b/packages/core/src/core/createRule/defineRuleProcessors.ts index 706b1c7..aadac28 100644 --- a/packages/core/src/core/createRule/defineRuleProcessors.ts +++ b/packages/core/src/core/createRule/defineRuleProcessors.ts @@ -11,7 +11,7 @@ export function defineRuleProcessors( definition: $InternalRegleRuleInit, ...params: any[] ): $InternalRegleRuleDefinition { - const { message, validator, active, type, ...properties } = definition; + const { validator, type } = definition; const isAsync = type === InternalRuleType.Async || validator.constructor.name === 'AsyncFunction'; @@ -29,7 +29,6 @@ export function defineRuleProcessors( return definition.message; } }, - active(value: any, metadata: $InternalRegleRuleMetadataConsumer) { if (typeof definition.active === 'function') { return definition.active(value, { @@ -40,6 +39,16 @@ export function defineRuleProcessors( return definition.active ?? true; } }, + tooltip(value: any, metadata: $InternalRegleRuleMetadataConsumer) { + if (typeof definition.tooltip === 'function') { + return definition.tooltip(value, { + ...metadata, + $params: unwrapRuleParameters(metadata.$params?.length ? metadata.$params : params), + }); + } else { + return definition.tooltip ?? []; + } + }, exec(value: any) { const validator = definition.validator(value, ...unwrapRuleParameters(params)); let rawResult: RegleRuleMetadataDefinition; @@ -68,16 +77,14 @@ export function defineRuleProcessors( const processors = { ...defaultProcessors, - ...properties, - ...{ - _validator: definition.validator as any, - _message: definition.message, - _active: definition.active, - _type: definition.type, - _patched: false, - _async: isAsync, - _params: createReactiveParams(params), - }, + _validator: definition.validator as any, + _message: definition.message, + _active: definition.active, + _tooltip: definition.tooltip, + _type: definition.type, + _patched: false, + _async: isAsync, + _params: createReactiveParams(params), }; return processors; diff --git a/packages/core/src/core/useRegle/useErrors.ts b/packages/core/src/core/useRegle/useErrors.ts index 71210a7..40b50d2 100644 --- a/packages/core/src/core/useRegle/useErrors.ts +++ b/packages/core/src/core/useRegle/useErrors.ts @@ -27,3 +27,16 @@ export function extractRulesErrors({ }, []) .concat(field.$dirty ? (field.$externalErrors ?? []) : []); } + +export function extractRulesTooltips({ field }: { field: Pick<$InternalRegleFieldStatus, '$rules'> }): string[] { + return Object.entries(field.$rules ?? {}) + .map(([ruleKey, rule]) => rule.$tooltip) + .filter((tooltip): tooltip is string | string[] => !!tooltip) + .reduce((acc, value) => { + if (typeof value === 'string') { + return acc?.concat([value]); + } else { + return acc?.concat(value); + } + }, []); +} diff --git a/packages/core/src/core/useRegle/useStateProperties/createReactiveFieldStatus.ts b/packages/core/src/core/useRegle/useStateProperties/createReactiveFieldStatus.ts index d88607e..d77f499 100644 --- a/packages/core/src/core/useRegle/useStateProperties/createReactiveFieldStatus.ts +++ b/packages/core/src/core/useRegle/useStateProperties/createReactiveFieldStatus.ts @@ -11,7 +11,7 @@ import type { RegleShortcutDefinition, } from '../../../types'; import { debounce, isVueSuperiorOrEqualTo3dotFive, resetFieldValue } from '../../../utils'; -import { extractRulesErrors } from '../useErrors'; +import { extractRulesErrors, extractRulesTooltips } from '../useErrors'; import type { CommonResolverOptions, CommonResolverScopedState } from './common/common-types'; import { createReactiveRuleStatus } from './createReactiveRuleStatus'; @@ -46,6 +46,7 @@ export function createReactiveFieldStatus({ $clearExternalErrorsOnChange: ComputedRef; $errors: ComputedRef; $silentErrors: ComputedRef; + $tooltips: ComputedRef; $haveAnyAsyncRule: ComputedRef; $ready: ComputedRef; $shortcuts: ToRefs; @@ -207,6 +208,17 @@ export function createReactiveFieldStatus({ return []; }); + const $tooltips = computed(() => { + if ($error.value) { + return extractRulesTooltips({ + field: { + $rules: $rules.value, + }, + }); + } + return []; + }); + const $silentErrors = computed(() => { return extractRulesErrors({ field: { @@ -261,7 +273,7 @@ export function createReactiveFieldStatus({ const $haveAnyAsyncRule = computed(() => { return Object.entries($rules.value).some(([key, ruleResult]) => { - return ruleResult._haveAsync; + return ruleResult.$haveAsync; }); }); @@ -288,6 +300,7 @@ export function createReactiveFieldStatus({ $ready, $silentErrors, $anyDirty, + $tooltips, $name, }) ); @@ -327,6 +340,7 @@ export function createReactiveFieldStatus({ $haveAnyAsyncRule, $shortcuts, $validating, + $tooltips, } satisfies ScopeReturnState; })!; diff --git a/packages/core/src/core/useRegle/useStateProperties/createReactiveRuleStatus.ts b/packages/core/src/core/useRegle/useStateProperties/createReactiveRuleStatus.ts index 0e14c95..3c53fc1 100644 --- a/packages/core/src/core/useRegle/useStateProperties/createReactiveRuleStatus.ts +++ b/packages/core/src/core/useRegle/useStateProperties/createReactiveRuleStatus.ts @@ -39,6 +39,7 @@ export function createReactiveRuleStatus({ type ScopeState = { $active: ComputedRef; $message: ComputedRef; + $tooltip: ComputedRef; $type: ComputedRef; $validator: ComputedRef< RegleRuleDefinitionProcessor> @@ -51,7 +52,7 @@ export function createReactiveRuleStatus({ let $unwatchState: WatchStopHandle; - const _haveAsync = ref(false); + const $haveAsync = ref(false); const { $pending, $valid, $metadata, $validating } = storage.trySetRuleStatusRef(`${path}.${ruleKey}`); @@ -75,26 +76,31 @@ export function createReactiveRuleStatus({ } }); - const $message = computed(() => { - let message: string | string[] = ''; - const customMessageRule = customMessages ? customMessages[ruleKey]?.message : undefined; + function computeRuleProcessor(key: 'message' | 'tooltip'): string | string[] { + let result: string | string[] = ''; + const customMessageRule = customMessages ? customMessages[ruleKey]?.[key] : undefined; if (customMessageRule) { if (typeof customMessageRule === 'function') { - message = customMessageRule(state.value, $defaultMetadata.value); + result = customMessageRule(state.value, $defaultMetadata.value); } else { - message = customMessageRule; + result = customMessageRule; } } if (isFormRuleDefinition(rule)) { if (!(customMessageRule && !rule.value._patched)) { - if (typeof rule.value.message === 'function') { - message = rule.value.message(state.value, $defaultMetadata.value); + if (typeof rule.value[key] === 'function') { + result = rule.value[key](state.value, $defaultMetadata.value); } else { - message = rule.value.message; + result = rule.value[key] ?? ''; } } } + return result; + } + + const $message = computed(() => { + let message = computeRuleProcessor('message'); if (isEmpty(message)) { message = 'Error'; @@ -104,6 +110,10 @@ export function createReactiveRuleStatus({ return message; }); + const $tooltip = computed(() => { + return computeRuleProcessor('tooltip'); + }); + const $type = computed(() => { if (isFormRuleDefinition(rule) && rule.value.type) { return rule.value.type; @@ -137,6 +147,7 @@ export function createReactiveRuleStatus({ $validator, $params, $path, + $tooltip, } satisfies ScopeState; })!; @@ -245,7 +256,7 @@ export function createReactiveRuleStatus({ $pending, $valid, $metadata, - _haveAsync, + $haveAsync, $validating, $validate, $unwatch, diff --git a/packages/core/src/types/rules/rule.definition.type.ts b/packages/core/src/types/rules/rule.definition.type.ts index dcfdb90..48b1fcf 100644 --- a/packages/core/src/types/rules/rule.definition.type.ts +++ b/packages/core/src/types/rules/rule.definition.type.ts @@ -20,12 +20,9 @@ export interface RegleRuleDefinition< TParams, TAsync extends false ? TMetaData : Promise >; - message: RegleRuleDefinitionWithMetadataProcessor< - TFilteredValue, - PossibleRegleRuleMetadataConsumer, - string | string[] - >; - active: RegleRuleDefinitionWithMetadataProcessor; + message: (value: Maybe, metadata: PossibleRegleRuleMetadataConsumer) => string | string[]; + active: (value: Maybe, metadata: PossibleRegleRuleMetadataConsumer) => boolean; + tooltip: (value: Maybe, metadata: PossibleRegleRuleMetadataConsumer) => string | string[]; type?: string; exec: (value: Maybe) => TAsync extends false ? TMetaData : Promise; } @@ -37,6 +34,7 @@ export interface $InternalRegleRuleDefinition extends RegleInternalRuleDefs; active: RegleRuleDefinitionWithMetadataProcessor; + tooltip: RegleRuleDefinitionWithMetadataProcessor; type?: string; exec: (value: any) => RegleRuleMetadataDefinition | Promise; } diff --git a/packages/core/src/types/rules/rule.init.types.ts b/packages/core/src/types/rules/rule.init.types.ts index 7e7d9a5..6e0a798 100644 --- a/packages/core/src/types/rules/rule.init.types.ts +++ b/packages/core/src/types/rules/rule.init.types.ts @@ -5,6 +5,13 @@ import type { RegleRuleMetadataDefinition, } from './rule.definition.type'; +export type RegleInitPropertyGetter< + TValue, + TReturn, + TParams extends [...any[]], + TMetadata extends RegleRuleMetadataDefinition, +> = TReturn | ((value: Maybe, metadata: RegleRuleMetadataConsumer) => TReturn); + /** * @argument * createRule arguments options @@ -16,13 +23,11 @@ export interface RegleRuleInit< TMetadata extends RegleRuleMetadataDefinition = RegleRuleMetadataDefinition, TAsync extends boolean = TReturn extends Promise ? true : false, > { - type?: string; validator: (value: Maybe, ...args: TParams) => TReturn; - message: - | string - | string[] - | ((value: Maybe, metadata: RegleRuleMetadataConsumer) => string | string[]); - active?: boolean | ((value: Maybe, metadata: RegleRuleMetadataConsumer) => boolean); + message: RegleInitPropertyGetter; + active?: RegleInitPropertyGetter; + tooltip?: RegleInitPropertyGetter; + type?: string; } /** @@ -36,10 +41,9 @@ export interface RegleRuleCore< TMetadata extends RegleRuleMetadataDefinition = boolean, > { validator: (value: Maybe, ...args: TParams) => TAsync extends false ? TMetadata : Promise; - message: - | string - | ((value: Maybe, metadata: RegleRuleMetadataConsumer) => string | string[]); - active?: boolean | ((value: Maybe, metadata: RegleRuleMetadataConsumer) => boolean); + message: RegleInitPropertyGetter; + active?: RegleInitPropertyGetter; + tooltip?: RegleInitPropertyGetter; type?: string; } @@ -49,8 +53,9 @@ export interface RegleRuleCore< */ export interface $InternalRegleRuleInit { validator: (value: any, ...args: any[]) => RegleRuleMetadataDefinition | Promise; - message: string | ((value: any, metadata: $InternalRegleRuleMetadataConsumer) => string | string[]); + message: string | string[] | ((value: any, metadata: $InternalRegleRuleMetadataConsumer) => string | string[]); active?: boolean | ((value: any, metadata: $InternalRegleRuleMetadataConsumer) => boolean); + tooltip?: string | string[] | ((value: any, metadata: $InternalRegleRuleMetadataConsumer) => string | string[]); type?: string; } diff --git a/packages/core/src/types/rules/rule.internal.types.ts b/packages/core/src/types/rules/rule.internal.types.ts index f2a84c5..8837d9b 100644 --- a/packages/core/src/types/rules/rule.internal.types.ts +++ b/packages/core/src/types/rules/rule.internal.types.ts @@ -17,6 +17,10 @@ export interface RegleInternalRuleDefs< | string[] | ((value: Maybe, metadata: PossibleRegleRuleMetadataConsumer) => string | string[]); _active?: boolean | ((value: Maybe, metadata: PossibleRegleRuleMetadataConsumer) => boolean); + _tooltip?: + | string + | string[] + | ((value: Maybe, metadata: PossibleRegleRuleMetadataConsumer) => string | string[]); _type?: string; _patched: boolean; _params?: RegleUniversalParams; diff --git a/packages/core/src/types/rules/rule.status.types.ts b/packages/core/src/types/rules/rule.status.types.ts index f2796c6..5adc290 100644 --- a/packages/core/src/types/rules/rule.status.types.ts +++ b/packages/core/src/types/rules/rule.status.types.ts @@ -6,12 +6,10 @@ import type { $InternalRegleResult, AllRulesDeclarations, ArrayElement, - DeepSafeFormState, ExtractFromGetter, FieldRegleBehaviourOptions, InlineRuleDeclaration, Maybe, - Prettify, RegleCollectionErrors, RegleCollectionRuleDecl, RegleCollectionRuleDefinition, @@ -25,7 +23,6 @@ import type { RegleShortcutDefinition, RegleValidationGroupEntry, RegleValidationGroupOutput, - SafeFieldProperty, } from '..'; /** @@ -133,6 +130,7 @@ export type RegleFieldStatus< readonly $errors: string[]; readonly $silentErrors: string[]; readonly $externalErrors: string[]; + readonly $tooltips: string[]; $extractDirtyFields: (filterNullishValues?: boolean) => Maybe; $validate: () => Promise>; readonly $rules: { @@ -200,6 +198,7 @@ export type RegleRuleStatus< > = { readonly $type: string; readonly $message: string | string[]; + readonly $tooltip: string | string[]; readonly $active: boolean; readonly $valid: boolean; readonly $pending: boolean; @@ -232,6 +231,7 @@ export type RegleRuleStatus< export interface $InternalRegleRuleStatus { $type: string; $message: string | string[]; + $tooltip: string | string[]; $active: boolean; $valid: boolean; $pending: boolean; @@ -239,7 +239,7 @@ export interface $InternalRegleRuleStatus { $externalErrors?: string[]; $params?: any[]; $metadata: any; - _haveAsync: boolean; + $haveAsync: boolean; $validating: boolean; $validator(value: any, ...args: any[]): RegleRuleMetadataDefinition | Promise; $validate(): Promise; @@ -256,10 +256,7 @@ export type RegleCollectionStatus< TRules extends ReglePartialRuleTree> = Record, TFieldRule extends RegleCollectionRuleDecl = never, TShortcuts extends RegleShortcutDefinition = {}, -> = Omit< - RegleFieldStatus, - '$errors' | '$silentErrors' | '$extractDirtyFields' | '$externalErrors' | '$rules' | '$value' | '$validate' -> & { +> = Omit, '$value'> & { $value: Maybe; readonly $each: Array, NonNullable, number, TShortcuts>>; readonly $field: RegleFieldStatus; diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index c07cd67..83760be 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -22,7 +22,7 @@ export default defineNuxtModule({ addImportsSources({ from: '@regle/rules', - imports: ['ruleHelpers', 'withAsync', 'withMessage', 'withParams'] as Array< + imports: ['ruleHelpers', 'withAsync', 'withMessage', 'withParams', 'withTooltip'] as Array< keyof typeof import('@regle/rules') >, }); diff --git a/packages/rules/src/helpers/applyIf.ts b/packages/rules/src/helpers/applyIf.ts index 0318999..0484bcf 100644 --- a/packages/rules/src/helpers/applyIf.ts +++ b/packages/rules/src/helpers/applyIf.ts @@ -12,16 +12,12 @@ import { createRule, InternalRuleType, unwrapRuleParameters } from '@regle/core' export function applyIf< TValue extends any, TParams extends any[], - TReturn extends - | RegleRuleMetadataDefinition - | Promise = RegleRuleMetadataDefinition, + TReturn extends RegleRuleMetadataDefinition | Promise = RegleRuleMetadataDefinition, TMetadata extends RegleRuleMetadataDefinition = TReturn extends Promise ? M : TReturn, TAsync extends boolean = TReturn extends Promise ? true : false, >( _condition: ParamDecl, - rule: - | InlineRuleDeclaration - | RegleRuleDefinition + rule: InlineRuleDeclaration | RegleRuleDefinition ): RegleRuleDefinition { let _type: string | undefined; let validator: RegleRuleDefinitionProcessor; diff --git a/packages/rules/src/helpers/index.ts b/packages/rules/src/helpers/index.ts index 6ac31d2..400efc9 100644 --- a/packages/rules/src/helpers/index.ts +++ b/packages/rules/src/helpers/index.ts @@ -1,4 +1,5 @@ export { withMessage } from './withMessage'; +export { withTooltip } from './withTooltip'; export { withAsync } from './withAsync'; export { withParams } from './withParams'; export { applyIf } from './applyIf'; diff --git a/packages/rules/src/helpers/tests/withTooltip.spec.ts b/packages/rules/src/helpers/tests/withTooltip.spec.ts new file mode 100644 index 0000000..6a5fbbc --- /dev/null +++ b/packages/rules/src/helpers/tests/withTooltip.spec.ts @@ -0,0 +1,16 @@ +import { required } from '../../rules'; +import { withTooltip } from '../withTooltip'; + +describe('withTooltip', () => { + it('should register tooltips to an inline rule', () => { + const inlineRule = withTooltip((value) => true, 'Hello tooltip'); + expect(inlineRule.tooltip?.(null, { $invalid: false })).toBe('Hello tooltip'); + expect(inlineRule._tooltip).toBe('Hello tooltip'); + }); + + it('should register tooltips to an rule definition', () => { + const inlineRule = withTooltip(required, 'Hello tooltip'); + expect(inlineRule.tooltip?.(null, { $invalid: false })).toBe('Hello tooltip'); + expect(inlineRule._tooltip).toBe('Hello tooltip'); + }); +}); diff --git a/packages/rules/src/helpers/withParams.ts b/packages/rules/src/helpers/withParams.ts index 5aba8ec..8a789e1 100644 --- a/packages/rules/src/helpers/withParams.ts +++ b/packages/rules/src/helpers/withParams.ts @@ -1,4 +1,3 @@ -import { ref, type Ref } from 'vue'; import type { InlineRuleDeclaration, RegleRuleDefinition, @@ -6,6 +5,7 @@ import type { UnwrapRegleUniversalParams, } from '@regle/core'; import { createRule, InternalRuleType } from '@regle/core'; +import { type Ref } from 'vue'; export function withParams< TValue, diff --git a/packages/rules/src/helpers/withTooltip.ts b/packages/rules/src/helpers/withTooltip.ts new file mode 100644 index 0000000..e1a4030 --- /dev/null +++ b/packages/rules/src/helpers/withTooltip.ts @@ -0,0 +1,93 @@ +import type { + InlineRuleDeclaration, + RegleRuleDefinition, + RegleRuleDefinitionProcessor, + RegleRuleDefinitionWithMetadataProcessor, + RegleRuleMetadataConsumer, + RegleRuleMetadataDefinition, + RegleRuleRaw, + RegleRuleWithParamsDefinition, + InferRegleRule, +} from '@regle/core'; +import { createRule, InternalRuleType } from '@regle/core'; + +export function withTooltip< + TValue extends any, + TParams extends any[], + TReturn extends RegleRuleMetadataDefinition | Promise, + TAsync extends boolean = TReturn extends Promise ? true : false, +>( + rule: InlineRuleDeclaration, + newMessage: RegleRuleDefinitionWithMetadataProcessor< + TValue, + RegleRuleMetadataConsumer ? M : TReturn>, + string | string[] + > +): InferRegleRule ? M : TReturn>; +export function withTooltip< + TValue extends any, + TParams extends any[], + TMetadata extends RegleRuleMetadataDefinition, + TReturn extends TMetadata | Promise, + TAsync extends boolean = TReturn extends Promise ? true : false, +>( + rule: RegleRuleWithParamsDefinition, + newMessage: RegleRuleDefinitionWithMetadataProcessor< + TValue, + RegleRuleMetadataConsumer, + string | string[] + > +): RegleRuleWithParamsDefinition; +export function withTooltip< + TValue extends any, + TParams extends any[], + TMetadata extends RegleRuleMetadataDefinition, + TReturn extends TMetadata | Promise, + TAsync extends boolean, +>( + rule: RegleRuleDefinition, + newMessage: RegleRuleDefinitionWithMetadataProcessor< + TValue, + RegleRuleMetadataConsumer, + string | string[] + > +): RegleRuleDefinition; +export function withTooltip( + rule: RegleRuleRaw | InlineRuleDeclaration, + newTooltip: RegleRuleDefinitionWithMetadataProcessor, string | string[]> +): RegleRuleWithParamsDefinition | RegleRuleDefinition { + let _type: string | undefined; + let validator: RegleRuleDefinitionProcessor>; + let _active: + | boolean + | RegleRuleDefinitionWithMetadataProcessor, boolean> + | undefined; + let _params: any[] | undefined; + let _message: any; + + if (typeof rule === 'function' && !('_validator' in rule)) { + _type = InternalRuleType.Inline; + validator = rule; + } else { + ({ _type, validator, _active, _params, _message } = rule); + } + + const newRule = createRule({ + type: _type as any, + validator: validator as any, + active: _active as any, + message: _message, + tooltip: newTooltip, + }); + + const newParams = [...(_params ?? [])]; + + newRule._params = newParams as any; + + if (typeof newRule === 'function') { + const executedRule = newRule(...newParams); + return executedRule; + } else { + return newRule; + } +} diff --git a/tests/unit/useRegle/errors/errorsMessages.spec.ts b/tests/unit/useRegle/errors/errorsMessages.spec.ts new file mode 100644 index 0000000..53d9e8f --- /dev/null +++ b/tests/unit/useRegle/errors/errorsMessages.spec.ts @@ -0,0 +1,52 @@ +import { defineRegleConfig, useRegle } from '@regle/core'; +import { withMessage } from '@regle/rules'; +import { ref } from 'vue'; +import { createRegleComponent } from '../../../utils/test.utils'; + +function errorsRules() { + const ruleWithOneError = withMessage(() => false, 'Error'); + const ruleWithMultipleErrors = withMessage(() => false, ['Error 1.1', 'Error 1.2']); + const ruleFunctionWithOneError = withMessage( + () => false, + () => 'Error 2' + ); + const ruleFunctionWithMultipleError = withMessage( + () => false, + () => ['Error 2.1', 'Error 2.2'] + ); + + const form = ref({ + email: '', + user: { + firstName: '', + }, + contacts: [{ name: '' }], + }); + + return useRegle(form, { + email: { ruleWithOneError, ruleWithMultipleErrors, ruleFunctionWithOneError, ruleFunctionWithMultipleError }, + user: { + firstName: { ruleWithOneError, ruleWithMultipleErrors, ruleFunctionWithOneError, ruleFunctionWithMultipleError }, + }, + contacts: { + $each: { + name: { ruleWithOneError, ruleWithMultipleErrors, ruleFunctionWithOneError, ruleFunctionWithMultipleError }, + }, + }, + }); +} + +describe('errors', () => { + it('should report any error format for rules', async () => { + const { vm } = createRegleComponent(errorsRules); + + vm.r$.$touch(); + await vm.$nextTick(); + + const expectedErrors = ['Error', 'Error 1.1', 'Error 1.2', 'Error 2', 'Error 2.1', 'Error 2.2']; + + expect(vm.r$.$fields.email.$errors).toStrictEqual(expectedErrors); + expect(vm.r$.$fields.user.$fields.firstName.$errors).toStrictEqual(expectedErrors); + expect(vm.r$.$fields.contacts.$each[0].$fields.name.$errors).toStrictEqual(expectedErrors); + }); +}); diff --git a/tests/unit/useRegle/externalErrors/external.spec.ts b/tests/unit/useRegle/errors/externalErrors.spec.ts similarity index 100% rename from tests/unit/useRegle/externalErrors/external.spec.ts rename to tests/unit/useRegle/errors/externalErrors.spec.ts diff --git a/tests/unit/useRegle/errors/metadata.spec.ts b/tests/unit/useRegle/errors/metadata.spec.ts new file mode 100644 index 0000000..e21f9b9 --- /dev/null +++ b/tests/unit/useRegle/errors/metadata.spec.ts @@ -0,0 +1,5 @@ +describe('metadata', () => { + it('should work', () => { + expect(true).toBe(true); + }); +}); diff --git a/tests/unit/useRegle/errors/tooltips.spec.ts b/tests/unit/useRegle/errors/tooltips.spec.ts new file mode 100644 index 0000000..fb974a9 --- /dev/null +++ b/tests/unit/useRegle/errors/tooltips.spec.ts @@ -0,0 +1,5 @@ +describe('tooltips', () => { + it('should work', () => { + expect(true).toBe(true); + }); +}); diff --git a/ui-tests/fixtures/ui-vue3/src/assets/main.scss b/ui-tests/fixtures/ui-vue3/src/assets/main.scss index e05cc99..94671a1 100644 --- a/ui-tests/fixtures/ui-vue3/src/assets/main.scss +++ b/ui-tests/fixtures/ui-vue3/src/assets/main.scss @@ -31,46 +31,270 @@ h1 { margin-bottom: 20px; } -.password-strength { - margin: 8px 8px 0 8px; - width: calc(100% - 15px); - height: 4px; - border-radius: 4px; - border: 1px solid var(--vp-c-border); +form { position: relative; + padding: 20px 16px; + background-color: var(--vp-c-bg-soft); + border-radius: 8px; + font-size: 16px; + width: 600px; + max-width: 100%; + box-shadow: 0 0 20px rgb(0, 0, 0, 0.1); + padding: 40px; - &::before { - content: ''; - position: absolute; - left: 0; - top: 0; - width: 0; - height: 100%; - background-color: var(--vp-c-form-error); - transition: width 0.2s ease; + .fields { + display: flex; + flex-flow: column nowrap; + gap: 20px; } - &.level-0 { - &::before { - width: 10%; + .projects { + display: flex; + flex-flow: column nowrap; + align-items: flex-start; + overflow-x: auto; + border: 1px solid var(--vp-c-border); + padding: 10px; + border-radius: 8px; + gap: 16px; + + .project { + display: flex; + flex-flow: column nowrap; + padding: 20px; + border: 1px solid var(--vp-c-border); + border-radius: 8px; + font-size: 14px; + width: 100%; + gap: 8px; + flex: 0 0 auto; + + label { + margin-bottom: 4px; + } + input { + padding: 4px 8px; + font-size: 14px; + } + + .delete { + position: absolute; + right: 8px; + top: 8px; + cursor: pointer; + } + } + .add { + display: flex; + height: 100%; + justify-content: center; + padding: 0 20px; + align-self: center; + flex: 0 0 auto; + button { + flex: 0 0 auto; + } } } - &.level-1 { - &::before { - width: 40%; - background-color: var(--vp-c-warning-2); + + .button-list { + display: flex; + width: 100%; + justify-content: space-between; + margin-top: 30px; + } + + * { + position: relative; + } + + div.input-container { + width: 100%; + display: flex; + flex-flow: column nowrap; + + label { + margin-bottom: 8px; + + .required-mark { + color: var(--vp-c-form-error); + } } } - &.level-2 { - &::before { - width: 75%; - background-color: var(--vp-c-brand-1); + + input, + textarea { + background-color: var(--vp-c-divider); + padding: 12px 16px; + border-radius: 6px; + border: 1px solid var(--vp-c-border); + font-family: var(--vp-font-family-base); + + font-size: 16px; + + &:not([type='checkbox']) { + width: 100%; + } + + &.valid { + border-color: var(--vp-c-brand-1); + } + + &.error { + border-color: var(--vp-c-form-error); + } + + &.pending { + border-color: var(--vp-c-warning-2); + } + + & + button { + margin-left: 4px; + } + } + + .block { + margin-bottom: 8px; + } + + .list { + display: grid; + gap: 8px; + max-width: none; + + .item { + padding: 15px; + border: 1px solid var(--vp-c-border); + border-radius: 8px; + width: max-content; + + .field { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: 8px; + } + } + } + + .row { + display: flex; + gap: 8px; + align-items: flex-start; + } + + ul.errors { + margin-top: 4px; + margin-bottom: 0; + color: var(--vp-c-form-error); + padding-left: 20px; + font-size: 14px; + max-width: 100%; + list-style: disc; + + li + li { + margin-top: 4px; + } + + &.standalone { + margin-top: 0px; + } + } + + ul.tooltips { + margin-top: 4px; + margin-bottom: 0; + color: #959595; + padding-left: 20px; + font-size: 14px; + max-width: 100%; + list-style: disc; + + li + li { + margin-top: 4px; + } + + &.standalone { + margin-top: 0px; } } - &.level-3 { + + button { + border-radius: 8px; + color: var(--vp-c-text-1); + padding: 8px 16px; + font-size: 16px; + background-color: var(--vp-c-indigo-3); + cursor: pointer; + + &:hover:not(:disabled) { + background-color: var(--vp-c-indigo-2); + } + + &:disabled { + cursor: not-allowed; + color: var(--vp-c-gray-1); + } + + + button { + margin-left: 4px; + } + } + + & + .demo-container { + margin-top: 20px; + } + + .success { + margin-top: 8px; + color: var(--vp-c-brand-1); + } + + .pending-text { + color: var(--vp-c-warning-2); + } + + .password-strength { + margin: 8px 8px 0 8px; + width: calc(100% - 15px); + height: 4px; + border-radius: 4px; + border: 1px solid var(--vp-c-border); + position: relative; + &::before { - width: 100%; - background-color: var(--vp-c-brand-1); + content: ''; + position: absolute; + left: 0; + top: 0; + width: 0; + height: 100%; + background-color: var(--vp-c-form-error); + transition: width 0.2s ease; + } + + &.level-0 { + &::before { + width: 10%; + } + } + &.level-1 { + &::before { + width: 40%; + background-color: var(--vp-c-warning-2); + } + } + &.level-2 { + &::before { + width: 75%; + background-color: var(--vp-c-brand-1); + } + } + &.level-3 { + &::before { + width: 100%; + background-color: var(--vp-c-brand-1); + } } } } diff --git a/ui-tests/fixtures/ui-vue3/src/components/Password.vue b/ui-tests/fixtures/ui-vue3/src/components/Password.vue index ff7adaf..fbc64e8 100644 --- a/ui-tests/fixtures/ui-vue3/src/components/Password.vue +++ b/ui-tests/fixtures/ui-vue3/src/components/Password.vue @@ -12,9 +12,9 @@ class="password-strength" :class="[`level-${field.$rules.strongPassword.$metadata.result?.id}`]" > -
    -
  • - {{ error }} +
      +
    • + {{ tooltip }}
    Your password is strong enough
    diff --git a/ui-tests/fixtures/ui-vue3/src/components/regle.global.config.ts b/ui-tests/fixtures/ui-vue3/src/components/regle.global.config.ts index 575aa55..7919a1b 100644 --- a/ui-tests/fixtures/ui-vue3/src/components/regle.global.config.ts +++ b/ui-tests/fixtures/ui-vue3/src/components/regle.global.config.ts @@ -2,7 +2,15 @@ import { createTimeout } from '@/utils/timeout'; import type { Maybe } from '@regle/core'; import { createRule, defineRegleConfig } from '@regle/core'; import { required, ruleHelpers, withMessage } from '@regle/rules'; -import { passwordStrength, type Options } from 'check-password-strength'; +import { passwordStrength, type DiversityType, type Options } from 'check-password-strength'; + +const diversityTypes: DiversityType[] = ['lowercase', 'uppercase', 'symbol', 'number']; +const diversityMessages: Record = { + lowercase: 'At least one owercase letter (a-z)', + uppercase: 'At least one uppercase letter (A-Z)', + number: 'At least one number (0-9)', + symbol: 'At least one symbol ($€@&..)', +}; function randomBoolean(): boolean { return [1, 2][Math.floor(Math.random() * 2)] === 1 ? true : false; @@ -36,6 +44,15 @@ export const strongPassword = createRule({ message(_, { result }) { return `Your password is ${result?.value.toLocaleLowerCase()}`; }, + tooltip(_, { result }) { + let diversity = diversityTypes + .filter((f) => !result?.contains.includes(f)) + .map((value) => diversityMessages[value]); + if ((result?.length ?? 0) < 10) { + diversity.push('At least 10 characters'); + } + return diversity; + }, }); export const { useRegle: useCustomRegle } = defineRegleConfig({ diff --git a/ui-tests/specs/base.uispec.ts b/ui-tests/specs/base.uispec.ts index 0fb1b71..b4dcc24 100644 --- a/ui-tests/specs/base.uispec.ts +++ b/ui-tests/specs/base.uispec.ts @@ -27,32 +27,24 @@ test('it should render the page correctly', async ({ index }) => { await index.page.isVisible('[data-testid=name] .errors'); - await expect(index.page.locator('[data-testid=name] .errors')).toContainText( - 'You need to provide a value' - ); - await expect(index.page.locator('[data-testid=email] .errors')).toContainText( - 'You need to provide a value' - ); + await expect(index.page.locator('[data-testid=name] .errors')).toContainText('You need to provide a value'); + await expect(index.page.locator('[data-testid=email] .errors')).toContainText('You need to provide a value'); expect(await index.page.$('[data-testid=pseudo] .errors')).toBeNull(); expect(await index.page.$('[data-testid=description] .errors')).toBeNull(); - await expect(index.page.locator('[data-testid=project-0-name] .errors')).toContainText( - 'You need to provide a value' - ); + await expect(index.page.locator('[data-testid=project-0-name] .errors')).toContainText('You need to provide a value'); await expect(index.page.locator('[data-testid=project-0-price] .errors')).toContainText( 'You need to provide a value' ); expect(await index.page.$('[data-testid=project-0-url] .errors')).toBeNull(); - await expect(index.page.locator('[data-testid=password] .errors')).toContainText( - 'You need to provide a value' + await expect(index.page.locator('[data-testid=password] .tooltips')).toContainText( + `At least one owercase letter (a-z)At least one uppercase letter (A-Z)At least one symbol ($€@&..)At least one number (0-9)At least 10 characters` ); await expect(index.page.locator('[data-testid=confirmPassword] .errors')).toContainText( 'You need to provide a value' ); - await expect(index.page.locator('[data-testid=acceptTC] .errors')).toContainText( - 'You need to accept T&C' - ); + await expect(index.page.locator('[data-testid=acceptTC] .errors')).toContainText('You need to accept T&C'); // Fill form @@ -69,7 +61,7 @@ test('it should render the page correctly', async ({ index }) => { expect(await index.page.$('[data-testid=project-0-price] .errors')).toBeNull(); await index.page.locator('[data-testid=password] input').fill('abcABC$$1'); - expect(await index.page.$('[data-testid=password] .errors')).toBeNull(); + expect(await index.page.$('[data-testid=password] .tooltips')).toBeNull(); await index.page.locator('[data-testid=confirmPassword] input').fill('abcABC$$1'); expect(await index.page.$('[data-testid=confirmPassword] .errors')).toBeNull();