Using a dynamic number of headless UI listboxes (with multiple selections) in a page; OOAD approach - LanguageSelections and AuthorList components
Last updated on 11th Aug. 2024
For the Gita web app. post version 1.1 that I am working on, I need to have a settings page with a nested list of selections of depth 2, with each depth level having dynamic number of entries. For the outer list, I already have a dynamic number of checkboxes (HTML input type="checkbox") for languages . Now I want to provide listboxes of translators and commentators for each language with number of translators and commentators being dynamic, and multiple selections enabled.
I checked out possibility of using Headless UI Listbox. Some notes about it are given below:
Data attributes, https://tailwindcss.com/docs/hover-focus-and-other-states#data-attributes :
"Use the data-* modifier to conditionally apply styles based on data attributes."
Headless UI Listbox and associated components use these data-* modifiers. From https://headlessui.com/react/listbox#component-api : For ListboxOption, examples of the data attributes are: data-selected and data-focus.
Showing/hiding the listbox, https://headlessui.com/v1/react/listbox#showing-hiding-the-listbox : The example code seems to have a couple of issues:
a) Minor issue but I tripped up on it: All the new lines added to the earlier example are not highlighted.
b) The example code uses: {open && (
But I think that means the following code after && will execute only if Listbox is open whereas the text for the example says that if 'static' prop (in ListBoxOptions) is used the following code will always be shown. That did not work for me. I removed the 'open && ' part and then it worked (showed the open listbox always). The open prop stuff does not seem to be required at all - just 'static' is enough. See below code which is working and does not report any errors:
export function Example() {
const [selectedPeople, setSelectedPeople] = useState([people[0], people[1]]);
return (
<Listbox value={selectedPeople} onChange={setSelectedPeople} multiple>
<ListboxButton>Select Translators</ListboxButton>
<ListboxOptions static>
{people.map((person) => (
<ListboxOption
key={person.id}
value={person}
className="data-[selected]:bg-blue-400"
>
{person.name}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
);
}
=================
Using above ListBox for a particular set of translators or commentators is straightforward. One state variable which holds an array (for mulitple selections) has to be passed as props to the ListBox component.
Using hardcoded number of ListBoxes (say 3) each using its own state variable was also quite straightforward (for case of 3 languages).
The issue was in handling dynamic number of languages and so dynamic number of ListBoxes. Dynamically creating state variables is something I don't think I have done so far. I considered, tried out and successfully implemented using a 2D array single state variable shared across initially 3 (hardcoded number of) ListBoxes.
Key aspects of that approach are:
- Entire 2D array state variable is passed as props (variable and its set fn.) to ListBox wrapper component (Example).
- An additional peopleLanguageIndex is passes as prop which is the index to the array that should be used by component instance to show data and modify data.
- OnChange of ListBox is set to a handler function (onListBoxChange) which takes the multiple selections data passed by ListBox and updates the appropriate array of entire 2D array state variable (by using a temp variable that copies other arrays thus creating a fresh 2D array and then uses the set state function to change the entire 2D array state variable).
This approach may have some inefficiency but there was no noticeable lag in the page on running the app. So perhaps this approach is an acceptable approach for Gita web app.
I wrote a series of test programs for these trials. I also added saving the selections to a cookie and reading the cookie on page load. Perhaps these tests may be useful to some students and other self-learners and so I have put it up on GitHub. 'Test10: Fixed issues in increase or decrease in number of languages in peopleAllLanguages data constant' is the latest trial and has the working implementation of what I mentioned above - Test 10 page source code.
=============
Ideally we need an object-oriented design like having a component LanguageSelections which has a checkbox for a particular language and which uses two instances of something like an AuthorList component which shows a ListBox (headless UI) having authors and where multiple authors can be selected in the ListBox. Two instances of AuthorList are needed for Translators and Commentarors.
Now the Settings page can use one LanguageSelections component per language. Ideally the code should handle number of languages being dynamic.
I wrote some test code for this work. As it may be useful to some students and self-learners, I have put it up as a GitHub public repo.
Using one LanguageSelections and associated AuthorList components is straightforward. However, for using multiple LanguageSelections components, the same issue of need to define useState variables in an array if generic code handling dynamic number of languages comes into play.
I spent a little time in browsing for easy solutions for that but did not get a suitable solution. It seems that class components provide a solution but I have not coded class components (except for tiny test ones while I was exploring this) and all the code I have seen in projects that I have referred to in the gita web app work, use functional components as far as I know. So I am reluctant to use class components though it seems to provide a solution to my need. Given below are a few links and notes about class components and state.
https://nextjs.org/docs/messages/class-component-in-server-component has a simple example of using class component (as against functional components) in a Nextjs project.
I tried the above and it worked (with minor change of import). My working code:
"use client";
import React from "react";
export default class Page extends React.Component {
render() {
return <p>Hello world</p>;
}
}
----
https://stackoverflow.com/questions/60961065/unable-to-use-usestate-in-class-component-react-js says you can't use useState in class components. Class components state is coded differently. A comment provides an example of state variable in class component as well as functional component. See the comment with text, "Hooks are a new addition in React 16.8. They let you use state and other React features without writing a class."
How to set state with a dynamic key name in ReactJS ?, https://www.geeksforgeeks.org/how-to-set-state-with-a-dynamic-key-name-in-reactjs/ . Based on this, I wrote cctest2/page.jsx which seems to create state variables dynamically.
============
render prop (in React): https://react.dev/reference/react/cloneElement#passing-data-with-a-render-prop has simple example of render prop.
My understanding is that a render prop is a function passed as a prop with the function being invoked by the component to which it is passed as a (render) prop, to render something. The function receives appropriate parameters from the component.
...
Using as an uncontrolled component, https://headlessui.com/v1/react/listbox#using-as-an-uncontrolled-component :
"This can simplify your code when using the listbox with HTML forms or with form APIs that collect their state using FormData instead of tracking it using React state."
Settings page of Gita app uses FormData via a server action. So above point may apply to it. I decided to invest some time in exploring uncontrolled ListBox component option. I wrote a couple of test pages in the test-hui-lisbox repo : testclient3a and testclient3b, which use an uncontrolled headless UI ListBox component (no use state variables used for ListBox component). It worked with entries being highlighted on being selected with mouse-click. Headless UI documentation page mentioned just above, states that in such cases, "Headless UI will track its state internally for you". I think that's the key reason it worked. Submit handler (or React server action) accessing the selected values from form hidden input elements whose names are prefixed by a 'name' prop, seems to provide a mechanism for having dynamic number of such ListBoxes. I plan to write some more test pages/components to check it out.
JavaScript Accessing Form Values from FormData, https://codingnomads.com/javascript-access-form-values-formdata
Iterables, https://javascript.info/iterable
========
It seems that the formData.get for checkbox has the checkbox associated key only if it is checked.
And in such a case, its value is "on" as I have not specified a value.
"If the value attribute was omitted, the default value for the checkbox is on, so the submitted data in that case would be subscribe=on." [subscribe is the name of the checkbox]
------------------
Based on: Readonly files in VSCode, https://www.stefanjudis.com/today-i-learned/readonly-files-in-vscode/ , Mar 2024 :
I used following settings in a project folder's .vscode/settings.json file (I created .vscode folder):
{
"files.readonlyInclude": {
"app/components/oldver/**": true,
"app/testlangselucf1/oldver/**": true
}
}
----
Now all files in project folder's app/components/oldver and app/testlangselucf1/oldver are shown in VSCode with a lock symbol on the filename tab and the file cannot be edited.
=========
How to display line breaks in React for the "\n" newline character., https://dev.to/yuya0114/how-to-display-line-breaks-in-react-for-the-n-newline-character-3b0h
Based on above, I coded following which works:
{showData.split("\n").map((line, index) => {
return <p key={index}>{line}</p>;
})}
----
Using w-full in the div showing data in Page component was resulting in horizontal scrollbar appearing even when there was no horizontal content overflow. Removing w-full resulted in horizontal scrollbar not appearing even when window was made small enough to force vertical scrollbar to appear. w-full is width: 100%
---------
In the test LanguageSelections project/repo I mentioned earlier, I wrote Uncontrolled component used in Form (UcF) versions of AuthorList and LanguageSelctions, without any state variables being passed as props to the components. I named them as AuthorListUcF and LanguageSelectionsUcF respectively. The current version of these components seems to be stable and can be viewed in components directory at (for commit: Further minor UI impr, c8b99ba): https://github.com/ravisiyer/test-hui-language-selections/tree/c8b99baa391a6d080f06ff11e1d2cf31dc93f797/app/components . The oldver directory in components directory has older versions of these components which are typically simpler than the current versions. Page components to test these components are in app/testauthorlistucf2 and app/testlangselucf1 directories with app/page.tsx home page providing links to them: app directory in above repo at above mentioned commit.
These current components (including page components) show a dynamic number (usually 3) of LanguageSelectionsUcF components each having two AuthorListUcF children components. The page component that has these LanguageSelectionsUcF components accesses the user selections data of these components through formData on a submit button being clicked.
But I could not get Select All and Clear All to work in AuthorListUcF component. I checked in debugger that component's defaultValue prop does change as expected but that default value change is not reflected in ListBox rendered output nor in formData that is submitted afterwards. AuthorListUcFv2.jsx has the code for my trial (which did not work). I could not get info. on the net on this issue in limited search I did. I decided to stop exploring a solution to it, as of now.
One possibility may be to use render props and render the AuthorList ourselves but that I think is coding complexity that I prefer to avoid at this stage.
Barring the above Select All and Clear All issue, I have been able to write fairly generic code handling dynamic number of languages for these selections needed in Settings page, using uncontrolled components that pass the selections through formData to a submit handler (similar approach should work with React server actions).
Now I plan to implement LanguageSelections and AuthorList components in Gita web app. I want to have Select All and Clear All functionality for the AuthorList component. So I have to use state variables for the AuthorList Headless UI ListBox components. I will hardcode a solution for max of 3 languages (which is what the GraphQL data source has currently). I will also include some message to user if the GraphQL data source has more than 3 languages.
In future, if needed, I will invest time in investigating solutions to support dynamic number of languages rather than hardcode a max. supported languages. As of now, the possibiities for such solutions that I know of, are: a) Using React class component for Settings page which seems to provide for creation of dynamic number of state variables, and b) Using render feature of Headless UI ListBox or associated components to implement 'Select All' and 'Clear All' in uncontrolled ListBox components.
=======================
Is having multiple returns inside a component a bad practice?, https://www.reddit.com/r/reactjs/comments/pth0x7/is_having_multiple_returns_inside_a_component_a/
As per above article, it is OK (not bad practice) to have multiple returns in a component.
https://react.dev/reference/react/useState: "useState is a Hook, so you can only call it at the top level of your component or your own Hooks. You can’t call it inside loops or conditions. If you need that, extract a new component and move the state into it."
I understand 'top level' in above statement to mean as not within loops or conditions but not at top of functional component code. Having useState statements after an if condition or function call seems to be OK.
...
Well, Next/React on PC is not complaining when I have useState statements after an if condition which returns from the component with an error message composed page, but Vercel deployment is complaining with this error message: "105:59 Error: React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render. react-hooks/rules-of-hooks".
Commenting out the if statement fixes the above Vercel build error and the deployed app seemed to work (at least settings page works as expected).
==============
React keys need to be unique only within a list. Multiple lists in a page don't need to have keys unique across both. Ref: From: https://react.dev/learn/rendering-lists#rules-of-keys :
Rules of keys
Keys must be unique among siblings. However, it’s okay to use the same keys for JSX nodes in different arrays.
Keys must not change or that defeats their purpose! Don’t generate them while rendering.
----
Addl. Ref: React Keys - unique in that List or unique in the whole page?, https://stackoverflow.com/questions/74760093/react-keys-unique-in-that-list-or-unique-in-the-whole-page - see mongelbrod response - "The keys only have to be unique within the same parent element." He follows it up with an example.
HUI ListBox - I am not able to disable ListBox even if I use it within a Field. I even tried hardcoding just disabled as given in HUI examples but that did not work. Disabling ListBoxOption works.
In AuthorList Select/Clear All checkbox implementation, I was able to use uncontrolled checkbox (no state variable for it and using defaultChecked prop to initialize it). However, I ran into the same defaultChecked prop not reflecting changed value that I faced with, IFIRC, Listbox HUI component. When user unchecks one entry in a list which earlier had all selected, the All checkbox tickmark should be off (unchecked). For that I tried changing defaultChecked prop. based on some logic but that failed. ... Solution would be to revert to using a state variable with All checkbox as in that case, HUI Checkbox may update the checkbox (like HUI Listbox updates the listbox).
...
Done: Change AuthorList Listbox button to p element.
Done: Test AuthorList creation with selectedAuthors.length less than allAuthors.length
Done using 2nd approach: formDataModified has to be set when LanguageSelections user choices change. useEffect() on associated state variables? [Too many state variables - 9] Or pass setFormDataModified as a prop to LS and through LS to AL? [Turned out to be easier and so implemented it; code seems to work.]
Done: Convert LS and AL from .jsx to .tsx
Done: Test invalid languages data case.
Done: Use name in LS and AL so that hidden fields provide data to form action.
Done: AL: When a language is checked, if no commentators or translators have been selected then all commentators and translators should be selected.
...
Type 'IterableIterator<number>' can only be iterated through when using the '--downlevelIteration' flag or with a '--target' of 'es2015' or higher, https://stackoverflow.com/questions/74246843/type-iterableiteratornumber-can-only-be-iterated-through-when-using-the-d
I faced the problem mentioned above for:
for (let key of formData.keys()) {
---
Based on above article, I used the suggested solution of tsconfig.json change as follows:
"compilerOptions": {
"target": "es2015",
...
----
That solved the issue though VSCode editor took some time (less than a minute though) to stop showing the red wavy line after I made the tsconfig.json change.
===================
To debug a server action, I tried out: Debugging with VS Code, https://nextjs.org/docs/pages/building-your-application/configuring/debugging#debugging-with-vs-code . I had some issues initially but then I closed the browser and VSCode and restarted both. This time around, in the little debugging I have tried (breakpoint in server action, inspecting data) - so far it has worked like a charm.
==============
TypeScript forces an object to have all its properties defined at object creation time. Below article provides some discussion as well as suggestions to get around the problem: How to initialize an object in TypeScript, https://stackoverflow.com/questions/52616172/how-to-initialize-an-object-in-typescript
I am trying this out (not mentioned in part of above article I read):
let LanguageSelectionsCookieElement : LanguageSelectionsCookieElementT | null = null;
----
It seems to work.
===========================
Done: Settings: If user specifies no language then he should be shown error message.
Done: Also default button should be provided to revert settings to default.
break does not work in map statement - some is alternative to map: How to Break Statement in an Array Map Method in JavaScript?, https://www.geeksforgeeks.org/how-to-break-statement-in-an-array-map-method-in-javascript/
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/some has better examples of some.
I used:
let atLeastOneLanguageChecked = allLanguageSelectionsData.some(
(languageSelectionData: any) => languageSelectionData.languageChecked
);
----
It seems to work.
===============
Done: SubmitButton callback should be able to return value indicating whether to proceed with action or cancel.
================
From https://react.dev/learn/passing-props-to-a-component : 'Don’t try to “change props”. When you need to respond to the user input (like changing the selected color), you will need to “set state”, ...', 'You can’t change props. When you need interactivity, you’ll need to set state.'
==================
Implemented LanguageSelections and AuthorList components in Gita web app on "settings" branch (dev branch and different from production branch). Modified settings page suitably (now it is split into Settings page and Settings component). Added support for LanguageSelections cookie being written in actions.ts and being read in Settings page. Commit: "Shortened some long variable names and function names using abbr.", 266050b, has the code at this stage. Vercel build ran without errors or warnings. The app. can be run by me on Vercel .
Next I plan to work on changing Verse page contents as per user's language settings. Once that's done, I may make a minor release version.
Comments
Post a Comment