Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement OnyxForm with disabled state injection #1902

Merged
merged 28 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8154312
implement onyxform with form-context
JoCa96 Sep 30, 2024
3f87481
rename onyxform.ts to oynxform.core.ts
JoCa96 Sep 30, 2024
0606262
add test
JoCa96 Sep 30, 2024
a352590
fix mistakes
JoCa96 Sep 30, 2024
ab81ab3
add storybook support
JoCa96 Sep 30, 2024
1587a7b
update description
JoCa96 Sep 30, 2024
3b26684
add unit test for form injection
JoCa96 Sep 30, 2024
6e3bfee
remove any's
JoCa96 Oct 1, 2024
7473f69
implement onyxform with form-context
JoCa96 Sep 30, 2024
9a62abd
rename onyxform.ts to oynxform.core.ts
JoCa96 Sep 30, 2024
c7079a0
add test
JoCa96 Sep 30, 2024
575335e
fix mistakes
JoCa96 Sep 30, 2024
e3213ee
add storybook support
JoCa96 Sep 30, 2024
c56271a
update description
JoCa96 Sep 30, 2024
d1c8364
add unit test for form injection
JoCa96 Sep 30, 2024
990b539
remove any's
JoCa96 Oct 1, 2024
a40a59d
Merge branch '757-onyx-form' of https://github.com/SchwarzIT/onyx int…
JoCa96 Oct 1, 2024
bcad4dd
Update packages/sit-onyx/src/components/OnyxForm/OnyxForm.vue
JoCa96 Oct 1, 2024
15dfb31
chore: implement review feedback
JoCa96 Oct 1, 2024
ed122ed
Merge branch '757-onyx-form' of https://github.com/SchwarzIT/onyx int…
JoCa96 Oct 1, 2024
ed5442d
impl createSymbolArgTypeEnhancer
JoCa96 Oct 1, 2024
0433fe0
add description and example
JoCa96 Oct 1, 2024
43d0e69
docs(changeset): New createSymbolArgTypeEnhancer which adds descripti…
JoCa96 Oct 1, 2024
9a01624
docs(changeset): Implement OnyxForm which allows setting of disabled …
JoCa96 Oct 1, 2024
ebd8a1b
review: remove getter
JoCa96 Oct 1, 2024
c0604f4
Merge branch 'main' of https://github.com/SchwarzIT/onyx into 757-ony…
JoCa96 Oct 1, 2024
8445b57
fix prettier
JoCa96 Oct 1, 2024
cee3b9a
Merge branch 'main' into 757-onyx-form
JoCa96 Oct 2, 2024
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
49 changes: 49 additions & 0 deletions packages/sit-onyx/.storybook/formInjected.ts
JoCa96 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { walkTree } from "@sit-onyx/storybook-utils";
import type {
ArgTypesEnhancer,
InputType,
SBType,
StrictInputType,
} from "storybook/internal/types";

const SB_TYPE_CONTROL_MAP: Partial<Record<SBType["name"], InputType["control"]>> = {
boolean: { type: "boolean" },
string: { type: "text" },
number: { type: "number" },
};

const getFormInjectedParent = (inputType?: StrictInputType) => {
if (!inputType?.type || inputType.table?.defaultValue?.summary !== "FORM_INJECTED_SYMBOL") {
return undefined;
}

return walkTree(inputType.type, (elem, parent) =>
elem.name === "symbol" || (elem.name === "other" && elem.value === "unique symbol")
? parent
: undefined,
);
};

export const enhanceFormInjectedSymbol: ArgTypesEnhancer = (context) => {
Object.values(context.argTypes)
.map((argType) => {
const parent = getFormInjectedParent(argType);
return { argType, parent };
})
.filter(({ parent }) => parent)
.forEach(({ argType, parent }) => {
const firstAvailableControl = walkTree(
parent || argType.type!,
(sb) => SB_TYPE_CONTROL_MAP[sb.name],
);

if (firstAvailableControl && argType.table?.defaultValue) {
argType.control = firstAvailableControl;
argType.table.defaultValue.detail =
"If no value (or `undefined`) is provided, `FORM_INJECTED_SYMBOL` is the internal default value for this prop.\n" +
"In that case the props value will be derived from it's parent form (if it exists).\n";
}
});

return context.argTypes;
};
3 changes: 2 additions & 1 deletion packages/sit-onyx/.storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import "@fontsource-variable/source-sans-3";
import "@sit-onyx/storybook-utils/style.css";
import "../src/styles/index.scss";
import "./docs-template.scss";
import { enhanceFormInjectedSymbol } from "./formInjected";
import { enhanceManagedSymbol } from "./managed";
import { withOnyxVModelDecorator } from "./vModel";

const basePreview = createPreview({
argTypesEnhancers: [enhanceManagedSymbol],
argTypesEnhancers: [enhanceManagedSymbol, enhanceFormInjectedSymbol],
parameters: {
docs: {
page: docsTemplate,
Expand Down
6 changes: 4 additions & 2 deletions packages/sit-onyx/src/components/OnyxButton/OnyxButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import OnyxLoadingIndicator from "../OnyxLoadingIndicator/OnyxLoadingIndicator.v
import OnyxRipple from "../OnyxRipple/OnyxRipple.vue";
import OnyxSkeleton from "../OnyxSkeleton/OnyxSkeleton.vue";
import type { OnyxButtonProps } from "./types";
import { FORM_INJECTED_SYMBOL, useFormContext } from "../OnyxForm/OnyxForm.core";

const props = withDefaults(defineProps<OnyxButtonProps>(), {
disabled: false,
disabled: FORM_INJECTED_SYMBOL,
loading: false,
type: "button",
color: "primary",
Expand All @@ -17,6 +18,7 @@ const props = withDefaults(defineProps<OnyxButtonProps>(), {
});

const { densityClass } = useDensity(props);
const { disabled } = useFormContext(props);

const rippleRef = ref<ComponentInstance<typeof OnyxRipple>>();
const rippleEvents = computed(() => rippleRef.value?.events ?? {});
Expand All @@ -33,7 +35,7 @@ const rippleEvents = computed(() => rippleRef.value?.events ?? {});
{ 'onyx-button--loading': props.loading },
densityClass,
]"
:disabled="props.disabled || props.loading"
:disabled="disabled || props.loading"
:type="props.type"
:aria-label="props.loading ? props.label : undefined"
:autofocus="props.autofocus"
Expand Down
3 changes: 2 additions & 1 deletion packages/sit-onyx/src/components/OnyxButton/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { DensityProp } from "../../composables/density";
import type { AutofocusProp } from "../../types";
import type { FormInjected } from "../OnyxForm/OnyxForm.core";

export type OnyxButtonProps = DensityProp &
AutofocusProp & {
Expand All @@ -10,7 +11,7 @@ export type OnyxButtonProps = DensityProp &
/**
* If the button should be disabled or not.
*/
disabled?: boolean;
disabled?: FormInjected<boolean>;
/**
* Shows a loading indicator.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import OnyxErrorTooltip from "../OnyxErrorTooltip/OnyxErrorTooltip.vue";
import OnyxLoadingIndicator from "../OnyxLoadingIndicator/OnyxLoadingIndicator.vue";
import OnyxSkeleton from "../OnyxSkeleton/OnyxSkeleton.vue";
import type { OnyxCheckboxProps } from "./types";
import { FORM_INJECTED_SYMBOL, useFormContext } from "../OnyxForm/OnyxForm.core";

const props = withDefaults(defineProps<OnyxCheckboxProps<TValue>>(), {
modelValue: false,
indeterminate: false,
disabled: false,
disabled: FORM_INJECTED_SYMBOL,
loading: false,
required: false,
skeleton: false,
Expand All @@ -37,6 +38,7 @@ const { requiredMarkerClass, requiredTypeClass } = useRequired(props);
const { densityClass } = useDensity(props);

const { vCustomValidity, errorMessages } = useCustomValidity({ props, emit });
const { disabled } = useFormContext(props);

const title = computed(() => {
return props.hideLabel ? props.label : undefined;
Expand All @@ -49,7 +51,7 @@ const title = computed(() => {
<OnyxSkeleton v-if="!props.hideLabel" class="onyx-checkbox-skeleton__label" />
</div>

<OnyxErrorTooltip v-else :disabled="props.disabled" :error-messages="errorMessages">
<OnyxErrorTooltip v-else :disabled="disabled" :error-messages="errorMessages">
<label class="onyx-checkbox" :class="[requiredTypeClass, densityClass]" :title="title">
<div class="onyx-checkbox__container">
<OnyxLoadingIndicator v-if="props.loading" class="onyx-checkbox__loading" type="circle" />
Expand All @@ -61,7 +63,7 @@ const title = computed(() => {
class="onyx-checkbox__input"
type="checkbox"
:indeterminate="props.indeterminate"
:disabled="props.disabled"
:disabled="disabled"
:required="props.required"
:value="props.value"
:autofocus="props.autofocus"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ import type { SelectOptionValue } from "../../types";
import OnyxCheckbox from "../OnyxCheckbox/OnyxCheckbox.vue";
import OnyxHeadline from "../OnyxHeadline/OnyxHeadline.vue";
import type { OnyxCheckboxGroupProps } from "./types";
import { FORM_INJECTED_SYMBOL, useFormContext } from "../OnyxForm/OnyxForm.core";

const props = withDefaults(defineProps<OnyxCheckboxGroupProps<TValue>>(), {
modelValue: () => [],
direction: "vertical",
withCheckAll: false,
disabled: false,
disabled: FORM_INJECTED_SYMBOL,
truncation: "ellipsis",
});

Expand All @@ -38,6 +39,8 @@ const enabledOptionValues = computed(() =>
props.options.filter((i) => !i.disabled && !i.skeleton).map(({ value }) => value),
);

const { disabled } = useFormContext(props);

const checkAll = useCheckAll(
enabledOptionValues,
computed(() => props.modelValue),
Expand All @@ -54,7 +57,7 @@ const checkAllLabel = computed(() => {
<template>
<fieldset
:class="['onyx-checkbox-group', densityClass]"
:disabled="props.disabled"
:disabled="disabled"
:aria-label="props.label"
>
<legend v-if="!props.hideLabel" class="onyx-checkbox-group__label">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { DensityProp } from "../../composables/density";
import type { RequiredMarkerProp } from "../../composables/required";
import type { CustomValidityProp } from "../../composables/useCustomValidity";
import type { AutofocusProp, BaseSelectOption, Direction, SelectOptionValue } from "../../types";
import type { FormInjected } from "../OnyxForm/OnyxForm.core";
import type { OnyxFormElementProps } from "../OnyxFormElement/types";

export type OnyxCheckboxGroupProps<TValue extends SelectOptionValue = SelectOptionValue> =
Expand Down Expand Up @@ -36,7 +37,7 @@ export type OnyxCheckboxGroupProps<TValue extends SelectOptionValue = SelectOpti
/**
* Whether all checkboxes should be disabled.
*/
disabled?: boolean;
disabled?: FormInjected<boolean>;
/**
* If set, the specified number of skeleton radio buttons will be shown.
*/
Expand Down
95 changes: 95 additions & 0 deletions packages/sit-onyx/src/components/OnyxForm/OnyxForm.core.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { expect, it, vi } from "vitest";
import { reactive, toValue } from "vue";
import {
FORM_INJECTED_SYMBOL,
provideFormContext,
useFormContext,
type FORM_INJECTED,
type FormInjected,
type FormInjectedProps,
} from "./OnyxForm.core";

let injected: (args: unknown[]) => void;

vi.mock("vue", async (importOriginal) => {
const mod = await importOriginal<typeof import("vue")>();
return {
...mod,
inject: () => injected,
provide: (_: symbol, ctx: (...args: unknown[]) => void) => (injected = ctx),
};
});

it.for([
{
formProps: { disabled: true },
localProps: { disabled: true },
expected: { disabled: true },
},
{
formProps: { disabled: false },
localProps: { disabled: true },
expected: { disabled: true },
},
{
formProps: { disabled: true },
localProps: { disabled: false },
expected: { disabled: false },
},
{
formProps: { disabled: false },
localProps: { disabled: false },
expected: { disabled: false },
},
{
formProps: { disabled: true },
localProps: { disabled: FORM_INJECTED_SYMBOL as FORM_INJECTED },
expected: { disabled: true },
},
{
formProps: { disabled: false },
localProps: { disabled: FORM_INJECTED_SYMBOL as FORM_INJECTED },
expected: { disabled: false },
},
{
formProps: undefined,
localProps: { disabled: FORM_INJECTED_SYMBOL as FORM_INJECTED },
expected: { disabled: false },
},
{
formProps: undefined,
localProps: { disabled: true },
expected: { disabled: true },
},
{
formProps: undefined,
localProps: { disabled: false },
expected: { disabled: false },
},
])("it should derive expected state when correctly", ({ formProps, localProps, expected }) => {
provideFormContext(formProps);
const result = useFormContext(localProps);
Object.entries(expected).forEach(([key, value]) => {
const resultValue = toValue(result[key as keyof FormInjectedProps]);

expect(
resultValue,
`Expected "${value}", got "${resultValue}" for formProps "${formProps}" and localProps "${localProps}"`,
).toBe(value);
});
});

it("should update when changed", async () => {
const formProps = reactive({ disabled: false });
provideFormContext(formProps);

const localProps = reactive({ disabled: FORM_INJECTED_SYMBOL as FormInjected<boolean> });
const { disabled } = useFormContext(localProps);
expect(disabled.value).toBe(false);

formProps.disabled = true;
localProps.disabled = true;
expect(disabled.value).toBe(true);
localProps.disabled = false;
expect(disabled.value).toBe(false);
});
101 changes: 101 additions & 0 deletions packages/sit-onyx/src/components/OnyxForm/OnyxForm.core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { computed, inject, provide, type InjectionKey, type Reactive, type Ref } from "vue";

const FORM_INJECTION_KEY = Symbol() as InjectionKey<ReturnType<typeof createFormInjectionContext>>;

/**
* Props on the `OnyxForm` component.
* These are injected, so that they can be used in the form child components.
*/
export type FormInjectedProps = {
disabled: boolean;
JoCa96 marked this conversation as resolved.
Show resolved Hide resolved
};

/**
* Props that may be used by the form child components.
*/
type LocalProps = {
[TKey in keyof FormInjectedProps]?: FormInjected<FormInjectedProps[TKey]>;
};

/**
* Symbol for the injected form injected properties.
*/
export const FORM_INJECTED_SYMBOL = Symbol("FORM_INJECTED_SYMBOL");
export type FORM_INJECTED = typeof FORM_INJECTED_SYMBOL;
/**
* Prop type used by form child elements, which indicates that the prop value is taken from the parent form by default.
* The props **MUST** use `FORM_INJECTED_SYMBOL` as default value.
* `useFormContext` is used to access the injected form properties.
*
* @example
* ```ts
* const props = withDefaults(defineProps<OnyxComponentProps>(), {
* readonly: FORM_INJECTED_SYMBOL,
* disabled: FORM_INJECTED_SYMBOL,
* });
*
* const { disabled, readonly } = useFormContext(props);
* ```
*/
export type FormInjected<T> = T | FORM_INJECTED;

const createCompute = <
TKey extends keyof FormInjectedProps,
TValue extends FormInjectedProps[keyof FormInjectedProps],
>(
formProps: FormInjectedProps | undefined,
props: LocalProps,
key: TKey,
defaultValue: TValue,
): Readonly<Ref<FormInjectedProps[TKey]>> =>
computed(() => {
const prop = props[key] as FormInjected<FormInjectedProps[TKey]> | undefined;
if (prop != undefined && prop !== FORM_INJECTED_SYMBOL) {
return prop;
}
const formProp = formProps?.[key];
if (formProp !== undefined) {
return formProp;
}
return defaultValue;
JoCa96 marked this conversation as resolved.
Show resolved Hide resolved
});

const createFormInjectionContext =
(formProps?: FormInjectedProps) =>
(
props: Reactive<LocalProps>,
): { [TKey in keyof FormInjectedProps]: Ref<FormInjectedProps[TKey]> } => ({
get disabled() {
return createCompute(formProps, props, "disabled", false);
JoCa96 marked this conversation as resolved.
Show resolved Hide resolved
},
});

export const provideFormContext = (formProps: Reactive<FormInjectedProps> | undefined) =>
provide(FORM_INJECTION_KEY, createFormInjectionContext(formProps));

const DEFAULT_FORM_INJECTION_CONTEXT = createFormInjectionContext();
/**
* Provides the injected form properties (if available).
* Otherwise a defined default is used.
* A prop defined on the child component will always take precedence over the injected form properties.
*
* The props **MUST** use `FORM_INJECTED_SYMBOL` as default value.
* The type `FormInjected<T>` can be used as PropType wrapper.
*
* @example
* ```ts
* const props = withDefaults(defineProps<OnyxComponentProps>(), {
* readonly: FORM_INJECTED_SYMBOL, // By default, the forms injected value is used
* disabled: FORM_INJECTED_SYMBOL, // By default, the forms injected value is used
* });
*
* const { disabled, readonly } = useFormContext(props);
* ```
*/
export const useFormContext = (props: Reactive<LocalProps>) => {
return inject(
FORM_INJECTION_KEY,
/** Default */
DEFAULT_FORM_INJECTION_CONTEXT,
)(props);
};
Loading
Loading