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

Tag Use Case #8

Merged
merged 30 commits into from
Jan 22, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
6b93e6e
add useDebounce
krfong916 Nov 17, 2021
845e5a0
update tag editor to use useCombobox hook edit type support for combobox
krfong916 Nov 17, 2021
0c9d2c0
fix onStateChange capitalization bug, add editor use case UI
krfong916 Nov 18, 2021
8d1b902
add spacing and screen-size standards for scss
krfong916 Nov 18, 2021
53bb3e0
implement loader, redo editor css, fix a11y bugs
krfong916 Nov 19, 2021
152a810
xed isopen state when user input changes, prevent default when user n…
krfong916 Nov 20, 2021
4d572e8
* fixed input isopen state when user input changes - our tests didn't…
krfong916 Nov 23, 2021
2d30eee
init useMultipleSelection, create generic types file
krfong916 Nov 23, 2021
41dc125
add keyboard tests for usemultiselect
krfong916 Nov 24, 2021
7166dd6
change keyboard navigation API for usemultiselect
krfong916 Nov 27, 2021
d4a79ee
implement all tests for usemultiselection
krfong916 Nov 29, 2021
0b1b344
fix combobox tests and cleanup type namespace
krfong916 Nov 29, 2021
a74ce35
move combobox folder
krfong916 Nov 29, 2021
5ffc4e7
implement tag use case, fix arrow navigation remove selectedItemListP…
krfong916 Nov 30, 2021
e033268
fix merge refs to include functions, this allows for composeable refs…
krfong916 Nov 30, 2021
96783d4
fix initial state, highlighted index
krfong916 Nov 30, 2021
67276b6
implement current item selection index and accessible navigation
krfong916 Nov 30, 2021
3b450a2
add cancel debounce callback and tests for tag editor
krfong916 Dec 1, 2021
655f397
add msw for mocking fetch and init tag tests, add event listeners for…
krfong916 Dec 2, 2021
aaded5a
add mock server setup for jest tests
krfong916 Dec 2, 2021
5e7e8b9
implement final tag editor tests
krfong916 Dec 2, 2021
d13e762
WIP: cursor position and highlighting selected items, implement creat…
krfong916 Dec 3, 2021
bad0890
fix keydown navigation for usemultislection, when we can focus a mult…
krfong916 Dec 6, 2021
9c6a1e9
WIP: question styling, implemented error-handling
krfong916 Jan 7, 2022
5434c43
implement focus for editor, button spacing and color, remove extran c…
krfong916 Jan 8, 2022
ff867ef
add link and editor bindings, refactor focus management for q compone…
krfong916 Jan 12, 2022
4e55f63
WIP: layout and styling, todo: spacing on large screens
krfong916 Jan 12, 2022
7d0d0b3
WIP: error handling, implement review and spacing
krfong916 Jan 13, 2022
f72f4ca
implement error handling, validate on change
krfong916 Jan 18, 2022
b9f7697
implement a11y for form and base tests, TODO: network error message
krfong916 Jan 22, 2022
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
8 changes: 5 additions & 3 deletions proof-of-concepts/features/stories/combobox/hooks/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default function bottomlineComboboxReducer<Item>(
state: BL.ComboboxState<Item>,
action: BL.ComboboxAction<Item>
): BL.ComboboxState<Item> {
const { type, getItemFromIndex, props, index, text } = action;
const { type, getItemFromIndex, props, index, inputValue } = action;
const { isOpen, highlightedIndex } = state;
switch (type) {
case BL.ComboboxActions.INPUT_KEYDOWN_ARROW_UP: {
Expand Down Expand Up @@ -68,9 +68,11 @@ export default function bottomlineComboboxReducer<Item>(
case BL.ComboboxActions.INPUT_VALUE_CHANGE: {
// does nothing, does not move the input cursor, only when the highlightedIndex is -1
// combobox can be open
console.log('[INTERNAL_REDUCER]: input change');
console.log('[INTERNAL_REDUCER]: ', inputValue);
const newState = { ...state };
if (newState.highlightedIndex === -1 && text) {
newState.inputValue = text;
if (newState.highlightedIndex === -1 && inputValue) {
newState.inputValue = inputValue;
}
newState.isOpen = true;

Expand Down
2 changes: 1 addition & 1 deletion proof-of-concepts/features/stories/combobox/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export namespace BL {
type: ComboBoxStateChangeTypes;
getItemFromIndex?: (index: number) => any;
Copy link
Owner Author

Choose a reason for hiding this comment

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

Should be item, woops.

index?: number;
text?: string;
inputValue?: string;
props?: ComboboxProps<Item>;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,6 @@ export function useCombobox<Item>(props: BL.ComboboxProps<Item> = {}) {
>(bottomlineComboboxReducer, computeInitialState<Item>(props), props);
const { isOpen, highlightedIndex, inputValue } = state;

React.useEffect(() => {
console.log('combobox state:', state);
});

/**
* ******
*
Expand Down Expand Up @@ -253,18 +249,19 @@ export function useCombobox<Item>(props: BL.ComboboxProps<Item> = {}) {

const inputChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.currentTarget.value;
console.log('[INPUT_HANDLER]:', val);
if (controlDispatch) {
const fn = () => {
dispatch({
type: BL.ComboboxActions.INPUT_VALUE_CHANGE,
text: val
inputValue: val
});
};
controlDispatch(fn);
} else {
dispatch({
type: BL.ComboboxActions.INPUT_VALUE_CHANGE,
text: e.currentTarget.value
inputValue: val
});
}
};
Expand Down
1 change: 1 addition & 0 deletions proof-of-concepts/features/stories/combobox/hooks/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export function useControlledReducer<
action,
changes: internalChanges
} as unknown) as ActionAndChanges);
console.log('[USER_RECOMMENDED_CHANGES]:', userRecommendedChanges);
return userRecommendedChanges;
}
return internalChanges;
Expand Down
1 change: 0 additions & 1 deletion proof-of-concepts/features/stories/tags/Tag.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ const UseCaseTemplate: ComponentStory<typeof Tag> = (args) => {
return (
<div>
<TagEditor />
<button>Review Your Question</button>
</div>
);
};
Expand Down
20 changes: 12 additions & 8 deletions proof-of-concepts/features/stories/tags/TagEditor/TagEditor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
margin: spacing.$Size-1;
font-size: fonts.$Size-1;
border: none;
width: 90%;
width: 70%;
}

.tag-search-input:focus-within {
Expand All @@ -65,6 +65,13 @@
.tag-results-container {
display: flex;
box-shadow: 0 4px 6px 0 hsla(0, 0%, 0%, 0.2);
border-radius: 4px;
}

.tag-no-results {
display: flex;
color: colors.$Gray-7;
padding: spacing.$Size-0 spacing.$Size-2 spacing.$Size-0 spacing.$Size-2;
}

.tag-results {
Expand Down Expand Up @@ -95,9 +102,6 @@
background-color: colors.$Gray-1;
}

.tag-result-info {
}

.tag-result-header {
display: inline-grid;
width: 100%;
Expand All @@ -113,12 +117,12 @@
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: 12px;
margin-left: spacing.$Size-1;
font-size: fonts.$Size-1;
}

.tag-result-details {
justify-self: center;
justify-self: flex-end;
}
.tag-result-details path {
fill: colors.$Gray-5;
Expand All @@ -132,7 +136,7 @@
margin: 0;
}

@media screen and (max-width: 450px) {
@media screen and (max-width: 500px) {
.tag-header-title {
font-size: fonts.$Size-1;
}
Expand All @@ -157,7 +161,7 @@
}
}

@media screen and (max-width: 535px) {
@media screen and (max-width: 600px) {
.tag-result-header {
grid-template-columns: 8fr 1fr;
}
Expand Down
92 changes: 79 additions & 13 deletions proof-of-concepts/features/stories/tags/TagEditor/TagEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
fetchTags
} from './utils';
import { useCombobox } from '../../combobox/hooks/useCombobox';
import { useAbortController } from '../../useAbortController/useAbortController';
import useDebouncedCallback from '../../useDebounce/src/hooks/useDebouncedCallback';
import useAsync from '../../useDebounce/src/hooks/useAsync';
import { SearchLoader } from '../../loader/SearchLoader';
Expand Down Expand Up @@ -41,10 +42,54 @@ import './TagEditor.scss';

export const TagEditor = () => {
const [input, setInput] = React.useState('');
const prevInput = React.useRef('');
const [selectedTags, setSelectedTags] = React.useState<BottomlineTags>({});
const [tagSuggestions, setTagSuggestions] = React.useState<
BottomlineTag[] | undefined
>();
>([
{
id: 1,
name: 'material-analysis',
count: 5,
excerpt:
"What is Lorem Ipsum? Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum."
},
{
id: 2,
name: 'class-analysis',
count: 33,
excerpt:
"What is Lorem Ipsum? Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum."
},
{
id: 3,
name: 'materialism',
count: 12,
excerpt:
"What is Lorem Ipsum? Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum."
},
{
id: 4,
name: 'dialectical-materialism',
count: 9,
excerpt:
"What is Lorem Ipsum? Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum."
},
{
id: 5,
name: 'historical-materialism',
count: 345435345,
excerpt:
"What is Lorem Ipsum? Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum."
},
{
id: 6,
name: 'materialist-theory',
count: 2,
excerpt:
"What is Lorem Ipsum? Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum."
}
]);

// we define state and change handler callbacks instead of a ref because we don't need to handle
// we need the "appearance" of focus handling for the container when the input element is focused
Expand All @@ -57,7 +102,7 @@ export const TagEditor = () => {
(dispatch) => {
dispatch();
},
2000,
1000,
{ trailing: true }
);

Expand All @@ -66,10 +111,15 @@ export const TagEditor = () => {
status: UseAsyncStatus.IDLE
} as UseAsyncState
});
if (error) {
console.log('[APP] error:', error);
}

const { getSignal, forceAbort } = useAbortController();

React.useEffect(() => {
if (!input || input === '') return;
run(fetchTags(input));
if (!input || (prevInput.current === '' && input === '')) return;
run(fetchTags(input, getSignal));
Copy link
Owner Author

Choose a reason for hiding this comment

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

We must explicitly pass the abortSignal function in order to abort a fetch call. getSignal() is simply a getter

}, [input, run]);

if (status === UseAsyncStatus.PENDING) derivedLoaderState = true;
Expand All @@ -95,9 +145,18 @@ export const TagEditor = () => {
return recommendations;
}
case BL.ComboboxActions.INPUT_BLUR: {
forceAbort();
recommendations.inputValue = state.inputValue;
return recommendations;
}
case BL.ComboboxActions.INPUT_VALUE_CHANGE: {
if (action.inputValue === '' && changes.inputValue !== '') {
recommendations.isOpen = false;
setTagSuggestions(undefined);
}
recommendations.inputValue = action.inputValue;
return recommendations;
}
default: {
return changes;
}
Expand All @@ -116,17 +175,19 @@ export const TagEditor = () => {
onInputValueChange: (changes: Partial<BL.ComboboxState<string>>) => {
// piggy-back on the state change
// set our own input value change
prevInput.current = input;
setInput(changes as string);
},
stateReducer,
items: tagSuggestions,
initialIsOpen: tagSuggestions ? true : false
});

// place our own ref on the input
// we use a useeffect to detect when the input ref is focused
// when the input ref is focused, then we focus the div
const noResultsFound = isOpen && tagSuggestions && tagSuggestions.length == 0;
const resultsFound = isOpen && tagSuggestions && tagSuggestions.length >= 1;

console.log('resultsFound:', resultsFound);
console.log('noResultsFound:', noResultsFound);
return (
<section className="tag-editor-section">
<div className="tag-editor">
Expand Down Expand Up @@ -172,14 +233,19 @@ export const TagEditor = () => {
className="tag-search-input"
ref={null}
/>
{derivedLoaderState ? (
<span className="tag-search-loader">
<SearchLoader />
</span>
) : null}
{/*{derivedLoaderState ? (*/}
<span className="tag-search-loader">
<SearchLoader />
</span>
{/*) : null}*/}
</div>
<div className="tag-results-container" {...getPopupProps()}>
{isOpen && tagSuggestions ? (
{noResultsFound ? (
<span className="tag-no-results">
<span>No results found</span>
</span>
) : null}
{resultsFound ? (
<ul className="tag-results">
{tagSuggestions.map((tag, index: number) => (
<li
Expand Down
15 changes: 11 additions & 4 deletions proof-of-concepts/features/stories/tags/TagEditor/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// debounce hook
// useInteractOutside - to close the popup?

import { delayRandomly, delayControlled, throwRandomly } from '../../utils';
const props = {
size: 'small',
type: 'no-outline',
Expand All @@ -10,17 +10,24 @@ const props = {

export const noop = () => {};

export function fetchTags(tag: string) {
export function fetchTags(tag: string, getSignal: () => AbortSignal) {
console.log('[FETCH TAGS]');
const url = `http://localhost:3000/tags?name_like=${tag}`;
return fetch(url, {
signal: getSignal(),
method: 'GET',
headers: {
'content-type': 'application/json'
}
})
.then((res) => res.json())
.catch((error) => Promise.reject(error));
.then(async (res) => {
await delayControlled();
throwRandomly();
return res.json();
})
.catch((error) => {
return Promise.reject(error);
});
}

export function getTagAttributes(target: HTMLElement) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from 'react';
export function useAbortController() {
const abortControllerRef = React.useRef<AbortController>();
Copy link
Owner Author

Choose a reason for hiding this comment

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

Create an abort controller that we control when it changes/updates between renders


// our abort controller is declared once on initial render
const getAbortController = React.useCallback(() => {
if (!abortControllerRef.current) {
abortControllerRef.current = new AbortController();
}
return abortControllerRef.current;
}, []);

// callback ran when we need to create a new abort controller
const createNewAbortController = React.useCallback(() => {
abortControllerRef.current = new AbortController();
}, []);
Comment on lines +13 to +16
Copy link
Owner Author

Choose a reason for hiding this comment

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

We might cancel a request. When that happens, and a new request comes around, we need to create a new 'instance' of an abort controller


const forceAbort = React.useCallback(() => {
if (getAbortController()) {
// abort the previous request
getAbortController().abort();
}
}, []);
Comment on lines +18 to +23
Copy link
Owner Author

Choose a reason for hiding this comment

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

A convenience function that we expose as a part of our API for the user to explicitly cancel an inflight request


// when the component unmounts/re-renders, abort any existing requests
// to prevent memory leaks
React.useEffect(() => {
return () => getAbortController().abort();
}, [getAbortController]);

// when we call getSignal, we cancel any outstanding requests
// and create a new instance of an abort controller
// then return the signal for the requesting code
const getSignal = React.useCallback(() => {
if (getAbortController()) {
// abort the previous request
getAbortController().abort();
createNewAbortController();
}

return getAbortController().signal;
}, [getAbortController, createNewAbortController]);

return { getSignal, forceAbort };
}
Loading