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

Add new hooks used in migrating Fabric components to functional components #12629

Merged
merged 22 commits into from
Apr 21, 2020
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
42e75e0
Add new hooks used in migrating Fabric components to functional compo…
MLoughry Apr 9, 2020
ee05549
Change files
MLoughry Apr 9, 2020
9733ebd
Cherry pick revision from MLoughry:functional/checkbox
MLoughry Apr 9, 2020
abb37ef
Fix lint error
MLoughry Apr 9, 2020
09d9402
Merge branch 'master' of github.com:microsoft/fluentui into functiona…
MLoughry Apr 9, 2020
b84a0a8
Merge branch 'master' of github.com:microsoft/fluentui into functiona…
MLoughry Apr 13, 2020
92bd1a1
Address comments from dzearing
MLoughry Apr 13, 2020
9d94139
Fix launch.json for debugging tests
MLoughry Apr 13, 2020
3e0c282
Use common onChange call signature
MLoughry Apr 13, 2020
8f8bb37
Fix useControllableValue types in case with no onChange handler
MLoughry Apr 13, 2020
8fcb759
Merge branch 'master' of github.com:microsoft/fluentui into functiona…
MLoughry Apr 14, 2020
8e1e936
Merge branch 'master' of github.com:microsoft/fluentui into functiona…
MLoughry Apr 16, 2020
cf76ce0
Export ChangeCallback type
MLoughry Apr 16, 2020
68c226b
Persist the controlled vs uncontrolled state
MLoughry Apr 16, 2020
a1679ca
Update packages/react-hooks/src/useMergedRefs.ts
MLoughry Apr 16, 2020
a940f05
Update README
MLoughry Apr 16, 2020
8284656
Allow for undefined events in the onChange callback
MLoughry Apr 16, 2020
acebfde
Merge branch 'functional/hooks' of github.com:MLoughry/office-ui-fabr…
MLoughry Apr 16, 2020
a8c5d07
Fix error in isControlled value
MLoughry Apr 16, 2020
d6e3f6b
Delete test for deprecated behavior
MLoughry Apr 16, 2020
4fbb3e2
Update packages/react-hooks/README.md
MLoughry Apr 16, 2020
a7d0e42
Update packages/react-hooks/README.md
MLoughry Apr 16, 2020
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 .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"stopOnEntry": false,
"args": ["-i", "--testPathPattern=\\b${fileBasenameNoExtension}", "--watch"],
"runtimeExecutable": null,
"runtimeArgs": ["--nolazy", "--debug"],
"runtimeArgs": ["--nolazy", "--inspect"],
"env": {
"NODE_ENV": "development"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "minor",
"comment": "Add new hooks used in migrating Fabric components to functional components",
"packageName": "@uifabric/react-hooks",
"email": "[email protected]",
"commit": "42e75e0c956a1d2afb40f0cb0147c31156ea3ae0",
"dependentChangeType": "patch",
"date": "2020-04-09T17:52:46.940Z"
}
15 changes: 14 additions & 1 deletion packages/react-hooks/etc/react-hooks.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

```ts

import * as React from 'react';

// @public
export interface IUseBooleanCallbacks {
setFalse: () => void;
Expand All @@ -21,7 +23,18 @@ export function useConst<T>(initialValue: T | (() => T)): T;
export function useConstCallback<T extends (...args: any[]) => any>(callback: T): T;

// @public
export function useId(prefix?: string): string;
export function useControllableValue<TValue, TElement extends HTMLElement>(controlledValue: TValue | undefined, defaultUncontrolledValue: TValue | undefined): Readonly<[TValue | undefined, (newValue: TValue | undefined) => void]>;

// Warning: (ae-forgotten-export) The symbol "ChangeCallback" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
export function useControllableValue<TValue, TElement extends HTMLElement, TCallback extends ChangeCallback<TElement, TValue> | undefined>(controlledValue: TValue | undefined, defaultUncontrolledValue: TValue | undefined, onChange: TCallback): Readonly<[TValue | undefined, (newValue: TValue | undefined, ev: React.FormEvent<TElement>) => void]>;

// @public
export function useId(prefix?: string, providedId?: string): string;

// @public
export function useMergedRefs<T>(...refs: React.Ref<T>[]): (value: T) => void;


// (No @packageDocumentation comment for this package)
Expand Down
2 changes: 2 additions & 0 deletions packages/react-hooks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export * from './useBoolean';
export * from './useConst';
export * from './useConstCallback';
export * from './useId';
export * from './useMergedRefs';
export * from './useControllableValue';
76 changes: 76 additions & 0 deletions packages/react-hooks/src/useControllableValue.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import * as React from 'react';
import { mount } from 'enzyme';
import { useControllableValue } from './useControllableValue';

describe('useControllableValue', () => {
it('respects controlled value', () => {
let resultValue: boolean | undefined;
const TestComponent: React.FunctionComponent<{ value?: boolean; defaultValue?: boolean }> = ({
value,
defaultValue,
}) => {
[resultValue] = useControllableValue(value, defaultValue);
return <div />;
};

const wrapper1 = mount(<TestComponent value={true} />);
expect(resultValue!).toBe(true);

wrapper1.setProps({ value: false });
expect(resultValue!).toBe(false);

const wrapper2 = mount(<TestComponent value={false} defaultValue={true} />);
expect(resultValue!).toBe(false);

wrapper2.setProps({ value: true });
expect(resultValue!).toBe(true);
});

it('uses the default value if no controlled value is provided', () => {
let resultValue: boolean | undefined;
const TestComponent: React.FunctionComponent<{ value?: boolean; defaultValue?: boolean }> = ({
value,
defaultValue,
}) => {
[resultValue] = useControllableValue(value, defaultValue);
return <div />;
};

mount(<TestComponent defaultValue={true} />);
expect(resultValue!).toBe(true);
});

it('uses an updated controlled value over a default value', () => {
let resultValue: boolean | undefined;
const TestComponent: React.FunctionComponent<{ value?: boolean; defaultValue?: boolean }> = ({
value,
defaultValue,
}) => {
[resultValue] = useControllableValue(value, defaultValue);
return <div />;
};

const wrapper = mount(<TestComponent defaultValue={true} />);
expect(resultValue!).toBe(true);

wrapper.setProps({ value: false, defaultValue: true });
expect(resultValue!).toBe(false);
});

it('does not change value when the default value changes', () => {
let resultValue: boolean | undefined;
const TestComponent: React.FunctionComponent<{ value?: boolean; defaultValue?: boolean }> = ({
value,
defaultValue,
}) => {
[resultValue] = useControllableValue(value, defaultValue);
return <div />;
};

const wrapper = mount(<TestComponent defaultValue={true} />);
expect(resultValue!).toBe(true);

wrapper.setProps({ defaultValue: false });
expect(resultValue!).toBe(true);
});
});
51 changes: 51 additions & 0 deletions packages/react-hooks/src/useControllableValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as React from 'react';

type ChangeCallback<TElement extends HTMLElement, TValue> = (
ev: React.FormEvent<TElement>,
newValue: TValue | undefined,
) => void;

/**
* Hook to manage a value that could be either controlled or uncontrolled, such as a checked state or
* text box string.
* @param controlledValue- The controlled value passed in the props. This value will always be used if provided, and the
* internal state will be updated to reflect it.
* @param defaultUncontrolledValue- Initial value for the internal state in the uncontrolled case.
* @see https://reactjs.org/docs/uncontrolled-components.html
*/
export function useControllableValue<TValue, TElement extends HTMLElement>(
controlledValue: TValue | undefined,
defaultUncontrolledValue: TValue | undefined,
): Readonly<[TValue | undefined, (newValue: TValue | undefined) => void]>;
export function useControllableValue<
TValue,
TElement extends HTMLElement,
TCallback extends ChangeCallback<TElement, TValue> | undefined
>(
controlledValue: TValue | undefined,
defaultUncontrolledValue: TValue | undefined,
onChange: TCallback,
): Readonly<[TValue | undefined, (newValue: TValue | undefined, ev: React.FormEvent<TElement>) => void]>;
export function useControllableValue<
TValue,
TElement extends HTMLElement,
TCallback extends ChangeCallback<TElement, TValue> | undefined
>(controlledValue: TValue | undefined, defaultUncontrolledValue: TValue | undefined, onChange?: TCallback) {
const [value, setValue] = React.useState<TValue | undefined>(
controlledValue !== undefined ? controlledValue : defaultUncontrolledValue,
);

const setValueOrCallOnChange = React.useCallback(
(newValue: TValue | undefined, ev?: React.FormEvent<TElement>) => {
if (onChange) {
onChange(ev!, newValue);
}
if (controlledValue === undefined) {
setValue(newValue);
}
},
[onChange, controlledValue === undefined],
);

return [controlledValue !== undefined ? controlledValue : value, setValueOrCallOnChange] as const;
}
6 changes: 4 additions & 2 deletions packages/react-hooks/src/useId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { getId } from '@uifabric/utilities/lib/getId';
* Hook to generate a unique ID in the global scope (spanning across duplicate copies of the same library).
*
* @param prefix - Optional prefix for the ID
* @param providedId - Optional id provided by a parent component. Defaults to the provided value if present,
* without conditioning the hook call
* @returns The ID
*/
export function useId(prefix?: string): string {
export function useId(prefix?: string, providedId?: string): string {
// getId should only be called once since it updates the global constant for the next ID value.
// (While an extra update isn't likely to cause problems in practice, it's better to avoid it.)
const ref = React.useRef<string>();
const ref = React.useRef<string | undefined>(providedId);
if (!ref.current) {
ref.current = getId(prefix);
}
Expand Down
65 changes: 65 additions & 0 deletions packages/react-hooks/src/useMergedRefs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as React from 'react';
import { mount } from 'enzyme';
import { useMergedRefs } from './useMergedRefs';

describe('useMergedRefs', () => {
it('updates all provided refs', () => {
const refObject: React.RefObject<boolean> = React.createRef<boolean>();
let refValue: boolean | null = null;
const TestComponent: React.FunctionComponent = () => {
const mergedRef = useMergedRefs<boolean>(refObject, val => (refValue = val));
mergedRef(true);
return null;
};
mount(<TestComponent />);

expect(refObject.current).toBe(true);
expect(refValue).toBe(true);
});

it('reuses the same ref callback if refs remain stable', () => {
const refObject: React.RefObject<boolean> = React.createRef<boolean>();

// tslint:disable-next-line:no-empty
const refValueFunc = (val: boolean) => {};

let refCallback: Function | undefined = undefined;
const TestComponent: React.FunctionComponent = () => {
refCallback = useMergedRefs<boolean>(refObject, refValueFunc);
return null;
};

const wrapper = mount(<TestComponent />);

const firstRefCallback = refCallback;

// Re-render the component
wrapper.update();

expect(refCallback).toBe(firstRefCallback);
});

it('handles changing ref callbacks', () => {
const refObject: React.RefObject<boolean> = React.createRef<boolean>();

let firstRefValue: boolean | null = null;
let refValueFunc = (val: boolean) => (firstRefValue = val);

const TestComponent: React.FunctionComponent = () => {
const mergedRef = useMergedRefs<boolean>(refObject, refValueFunc);
mergedRef(true);
return null;
};

const wrapper = mount(<TestComponent />);

let secondRefValue: boolean | null = null;
refValueFunc = (val: boolean) => (secondRefValue = val);

// Re-render the component
wrapper.setProps({});

expect(firstRefValue).toBe(true);
expect(secondRefValue).toBe(true);
});
});
19 changes: 19 additions & 0 deletions packages/react-hooks/src/useMergedRefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as React from 'react';

/**
* React hook to merge multile React refs (either MutableRefObjects or ref callbacks) into a single ref callback that
* updates all provided refs
* @param refs- Refs to collectively update with one ref value.
*/
export function useMergedRefs<T>(...refs: React.Ref<T>[]) {
return React.useCallback((value: T) => {
refs.forEach(ref => {
if (typeof ref === 'function') {
ref(value);
} else if (ref) {
// work around the immutability of the React.Ref type
((ref as unknown) as React.MutableRefObject<T>).current = value;
}
});
}, refs);
}