I'm using EditorJS on a React page to allow people to write in a block-based editor. However, I also want to build sections, where a user can have multiple sections and each section can support an EditorJS component
I'm running into an issue when I add a new section, and want to render an empty EditorJS component for this new section (and keep the data from the old section and EditorJS instance). Instead of an empty instance, it copies over the information from the old instance and assigns it to the new Section. Type definitions are below
types.d.ts
interface User {
...
sections: Section[],
}
interface Section {
id: string,
name: string,
content: ContentBlock,
}
interface ContentBlock {
id: string,
threads: Thread[],
content: OutputData, //this is the EditorJS saved data
}
I'm wondering if EditorJS is keeping some sort of global state that it's applying to every instance of itself in my application. Does anyone have experience with spinning up multiple editorJS instances?
For reference, I have two components: Page.tsx and Section.tsx. Relevant code is below
//Page.tsx
const Page: React.FC = () => {
const [allSections, setAllSections] = useState<Section[]>([]);
const [currSectionID, setCurrSectionID] = useState("");
const addNewSection = (type: string) => {
const newID = uuidv4();
const newSection: Section = {
id: newID,
name: "",
content: emptyContentBlock,
};
setAllSections(arr => [...arr, newSection]);
setCurrSectionID(newID);
};
const updateContentForSection = (contentBlock: ContentBlock, sectionID: string) => {
const newSectionArray = [...allSections];
newSectionArray.forEach((section: Section) => {
if (section.id === sectionID) {
section.content = contentBlock
}
});
setAllSections(newSectionArray);
};
return (
<Section
sectionID={currSectionID}
sections={allSections}
pageActions = {{
addNewSection: addNewSection,
updateContentForSection: updateContentForSection,
}}
/>
)
}
//Section.tsx
const Section: React.FC<SectionInput> = (props) => {
const currSection = props.sections.filter(section => section.id === props.sectionID)[0];
const blocks = currSection? currSection.content.content : [];
const [editorInstance, setEditorInstance] = useState<EditorJS>();
const saveEditorData = async() => {
if (editorInstance) {
const savedData = await editorInstance.save();
console.log(`saving data to section ${props.sectionID}`, savedData);
props.pageActions.updateContentForSection({content: savedData, id: props.sectionID, threads: threads}, props.sectionID);
}
}
}
return (
<div>
<button
className={`absolute top-0 right-12 mt-2 focus:outline-none`}
onClick={() => {
props.pageActions.addNewSection()
}}
>
Add Section
</button>
<EditorJs
key="0"
holder="custom"
data={blocks}
autofocus={true}
instanceRef={(instance: EditorJS) => {
setEditorInstance(instance)
}}
onChange={saveEditorData}
tools={EDITOR_JS_TOOLS}
>
<div
id="custom"
>
</div>
</EditorJs>
</div>
)
So according to this github thread, the answer is actually straightforward. Use a unique ID for each editorJS ID for each editor you want to have in the DOM. The code, then, becomes something like this
<EditorJs
key={`${sectionID}`}
holder={`custom${sectionID}`}
data={blocks}
autofocus={true}
instanceRef={(instance: EditorJS) => {
setEditorInstance(instance)
}}
onChange={saveEditorData}
tools={EDITOR_JS_TOOLS}
>
<div
id={`custom${sectionID}`}
>
</div>
</EditorJs>
Related
Update
This maybe a Formik bug, and I have switched to https://react-hook-form.com, as Formik has not been updated for a while.
https://github.com/jaredpalmer/formik/issues/3716
Context
I'm using React, Formik, and google-map-react to allow store owner edit their store address with google map place autocomplete.
I have three components:
EditStoreInfoPage is the page component, which contains EditStoreInfoForm.
EditStoreInfoForm is the form component, which contains FormikAddressField. I uses Formik here.
FormikAddressField is the one form field that supports google place autocomplete.
Store information will be fetched from backend in EditStoreInfoPage, and passed down to EditStoreInfoForm and FormikAddressField. Whenever a new address is typed in FormikAddressField, it calls a callback function handleStoreLocationUpdate passed down from EditStoreInfoPage.
Issue
Render the page without any issue. I see that formValues are populated corrected with the data fetched from backend.
However, once I finished typing the address, the form get cleared except the store address is still there.
From the console output of above screenshot, I can see that function handleStoreLocationUpdate get called, however, console.log(formValues); in function handleStoreLocationUpdate of EditStoreInfoPage contains empty value for store fields. I was expecting that the formValues here still kept the value fetched from backend, not sure why these values get wiped out as I use React useState.
Any idea what went wrong?
Code
EditStoreInfoPage
This is the React component that first call backend API to get the store information based on storeIdentifier. formValues will be populated with these information, as you can see that setFormValues is being called. formValues is passed down to child component EditStoreInfoForm as props.
type EditStoreInfoPageProps = {
storeIdentifier: string;
};
const EditStoreInfoPage = (props: EditStoreInfoPageProps) => {
let navigate = useNavigate();
const [formValues, setFormValues] = React.useState<StoreAttributes>({
storeName: "",
storeLocation: "",
storeLocationLongitude: 0,
storeLocationLatitude: 0,
});
// Get store info.
React.useEffect(() => {
const user: CognitoUser | null = getCurrentBusinessAccountUser();
if (!user) {
Toast("Store Not Found!", "Failed to get store information!", "danger");
} else {
const storeIdentifier: string = user?.getUsername();
getStoreInfo(storeIdentifier)
.then((response) => {
setFormValues({
storeName: response?.storeName || "",
storeLocation: response?.storeLocation || "",
storeLocationLatitude: response?.storeLocationLatitude!,
storeLocationLongitude: response?.storeLocationLongitude!,
});
})
.catch((error) =>
Toast(
"Store Not Found!",
"Failed to get store information!",
"danger"
)
);
}
}, []);
const handleStoreLocationUpdate = (newStoreLocation: string) => {
const geocoder = new window.google.maps.Geocoder();
console.log("handleStoreLocationUpdate");
console.log(newStoreLocation);
console.log(formValues);
const geocodeRequest = { address: newStoreLocation };
const geocodeCallback = (
results: google.maps.GeocoderResult[] | null,
status: google.maps.GeocoderStatus
) => {
if (status === "OK") {
if (results && results[0]) {
const formValuesClone: StoreAttributes = structuredClone(formValues);
formValuesClone.storeLocation = newStoreLocation;
formValuesClone.storeLocationLatitude =
results[0].geometry.location.lat();
formValuesClone.storeLocationLongitude =
results[0].geometry.location.lng();
setFormValues(formValuesClone);
} else {
Toast("Not valid address!", "Please input a valid address", "danger");
}
} else {
Toast("Not valid address!", "Please input a valid address", "danger");
}
};
geocoder.geocode(geocodeRequest, geocodeCallback);
};
const handleSubmit = (data: StoreAttributes) => {
updateStore(props.storeIdentifier, JSON.stringify(data, null, 2))
.then((response) => {
if (response.status == 200) {
Toast(
"Updated!",
"The store information has been updated. Redirect to store page...",
"success"
);
navigate("/stores/" + props.storeIdentifier);
} else {
Toast(
"Updated failed!",
"Failed to update store information.",
"danger"
);
}
})
.catch((error) => {
Toast("Updated failed!!", error.message, "danger");
});
};
const handleUpdate = (data: StoreAttributes) => {
// make a deep clone here, as formValues here is an object.
console.log("handleUpdate");
const copy = structuredClone(data);
setFormValues(copy);
};
return (
<EditStoreInfoForm
formValues={formValues}
handleStoreLocationUpdate={handleStoreLocationUpdate}
handleUpdate={handleUpdate}
handleSubmit={handleSubmit}
/>
);
};
export default EditStoreInfoPage;
EditStoreInfoForm
EditStoreInfoForm is the form component. I use Formik here. It renders the form with props.formValues. It contains a child component FormikAddressField which will be used to support google place auto complete.
export type EditStoreInfoFormProps = {
formValues: StoreAttributes;
handleStoreLocationUpdate: any;
handleUpdate: any;
handleSubmit: any;
};
const EditStoreInfoForm = (props: EditStoreInfoFormProps) => {
console.log("EditStoreInfoForm");
const onBlur = () => {
console.log(props.formValues);
}
return (
<div className="flex justify-center items-center">
<Formik.Formik
initialValues={props.formValues}
enableReinitialize={true}
validationSchema={validationSchema}
validateOnChange={false}
validateOnBlur={false}
onSubmit={(values) => {
props.handleSubmit(values);
}}
>
{({ }) => (
<Formik.Form className="w-1/3">
<div className="form-group">
<div>
<FormikTextField
label="Store Name"
name="storeName"
placeholder={props.formValues?.storeName}
/>
</div>
<div className="form-group">
<FormikAddressField
label="Store Location"
name="storeLocation"
onAddressUpdate={props.handleStoreLocationUpdate}
placeholder={props.formValues?.storeLocation}
/>
</div>
<div className="w-full h-60">
{/* <GoogleMapLocationPin latitude={10} longitude={10} text="store"/> */}
<StoresGoogleMapLocation
googleMapCenter={{
lat: props.formValues.storeLocationLatitude,
lng: props.formValues.storeLocationLongitude,
}}
storeAddress={props.formValues?.storeLocation}
storeLocationLongitude={
props.formValues?.storeLocationLongitude
}
storeLocationLatitude={props.formValues?.storeLocationLatitude}
/>
</div>
<div className="form-group">
<button type="submit" className="form-button m-2 w-20 h-10">
Update
</button>
</div>
</Formik.Form>
)}
</Formik.Formik>
</div>
);
};
export default EditStoreInfoForm;
FormikAddressField
FormikAddressField is the field for autocomplete. See https://developers.google.com/maps/documentation/javascript/place-autocomplete to know what it is.
const FormikAddressField = ({ label, onAddressUpdate, ...props }: any) => {
const [field, meta] = useField(props);
const loader = new Loader({
apiKey: process.env.REACT_APP_GOOGLE_MAP_API_KEY!,
libraries: ["places", "geometry"],
});
const locationInputId = "locationInputId";
let searchInput: HTMLInputElement;
const autoCompleteInstanceRef = React.useRef<any>(null);
React.useEffect(() => {
loader.load().then(() => {
let searchInput = document.getElementById(
locationInputId
) as HTMLInputElement;
//console.log(searchInput);
autoCompleteInstanceRef.current = new google.maps.places.Autocomplete(
searchInput!,
{
// restrict your search to a specific type of resultmap
//types: ["address"],
// restrict your search to a specific country, or an array of countries
// componentRestrictions: { country: ['gb', 'us'] },
}
);
autoCompleteInstanceRef.current.addListener(
"place_changed",
onPlaceChanged
);
});
// returned function will be called on component unmount
return () => {
google.maps.event.clearInstanceListeners(searchInput!);
};
}, []);
const onPlaceChanged = () => {
const place: google.maps.places.PlaceResult =
autoCompleteInstanceRef.current.getPlace();
if (!place) return;
onAddressUpdate(place.formatted_address);
};
return (
<>
<label htmlFor={props.id || props.name} className="form-label">
{label}
</label>
<Field
id={locationInputId}
className="text-md w-full h-full m-0 p-0"
type="text"
{...field}
{...props}
/>
{meta.touched && meta.error ? (
<div className="error">{meta.error}</div>
) : null}
</>
);
};
export default FormikAddressField;
CodeSandbox
Here is a simplified version: https://nv1m89.csb.app/
The EditStoreInfoPage is above the EditStoreInfoForm. The formikValues in EditStoreInfoPage appears to be a copy, which is not updated every time the actual real-time formik values in EditStoreInfoForm are changed. Your real problem here is that you shouldn't have the clone in the first place.
Just pass the real store values up to the handler:
<FormikAddressField
label="Store Location"
name="storeLocation"
onAddressUpdate={(newAddress) => props.handleStoreLocationUpdate(newAddress, formValues)}
placeholder={props.formValues?.storeLocation}
/>
Now change:
const handleStoreLocationUpdate = (newStoreLocation: string) => {
To:
const handleStoreLocationUpdate = (newStoreLocation: string, formValues: StoreAttributes) => {
And use that argument.
As mentioned there are other issues here. Really you should refactor to get rid of this completely:
const [formValues, setFormValues] = React.useState<StoreAttributes>({
storeName: "",
storeLocation: "",
storeLocationLongitude: 0,
storeLocationLatitude: 0,
});
You'd do it by making the actual form state accessible to that component. Probably by changing to the useFormik pattern and loading that hook in the parent.
I am using react-draft-wysiwyg in my project. It works fine most cases.
Only problem is, when I try to link a text to url. In that case it doesn't update the state.
Let's say, I already have a text "Hello world!". Now if I add more text in this e.g. "Hello world! newText". I want to link this "newText" to url using link option from toolbar and click save. It doesn't update the state with the link and goes back previous text "Hello world!"
Interesting thing is that after adding a link if I add more text to that, it works just fine.
const createStateFromHtml = (html: string) => {
const blocksFromHtml = htmlToDraft(html)
const { contentBlocks, entityMap } = blocksFromHtml
const contentState = ContentState.createFromBlockArray(contentBlocks, entityMap)
return EditorState.createWithContent(contentState)
}
const createHtmlFromState = (editorState: EditorState) => {
const rawContentState = convertToRaw(editorState.getCurrentContent())
return draftToHtml(rawContentState)
}
const htmlSanitizeSettings = {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['allowfullscreen'],
}
const BodyEditor = (props: BodyEditorProps) => {
DOMPurify.setConfig(htmlSanitizeSettings)
const initialState = props.html && props.html !== '' ? createStateFromHtml(props.html) : EditorState.createEmpty()
const [editorState, setEditorState] = React.useState(initialState)
const setEditorHtml = () => {
const html = createHtmlFromState(editorState)
const htmlWithIframeSettings = addPropertiesToIframes(html)
const purifiedHtml = DOMPurify.sanitize(htmlWithIframeSettings)
props.setBody(purifiedHtml)
}
/*
* Adds a div element around iframes and adds class="embedded-video" around so we can use CSS to make iframes reponsive
*/
const addPropertiesToIframes = (html: string) => {
return (
html
// Let's assume embedded iframes are videos
.replace(/<iframe/g, '<div class="iframe-container"><iframe class="embedded-video"')
.replace(/<\/iframe>/g, '</iframe></div>')
.replace(/iframe width=/g, 'iframe allowfullscreen width=')
// Let's remove embedded-video class from embedded spotify iframes
// This should be done for all non-video embeds because otherwise the iframe are made to have 16:9 aspect ratio
.replace(
// eslint-disable-next-line no-useless-escape
/class=\"embedded-video\" src=\"https:\/\/open\.spotify\.com/g,
'src="https://open.spotify.com',
)
)
}
return props.editable ? (
<Editor
editorStyle={editorStyle}
editorState={editorState}
onEditorStateChange={setEditorState}
onBlur={setEditorHtml}
toolbar={{
options: toolbarOptions,
image: {
...imageOptions,
uploadCallback: props.imageUploadCallback,
},
link: {
...linkOptions,
},
embedded: {
...embeddedOptions,
},
}}
placeholder={props.placeholder}
/>
) : (
<div
onClick={() => props.toggleEditable(props.name)}
dangerouslySetInnerHTML={
props.html
? { __html: DOMPurify.sanitize(props.html!) }
: { __html: DOMPurify.sanitize(props.placeholder!) }
}
/>
)
}
export default BodyEditor
Any help would be highly appreciated. I am stuck on this for a very long time.
I used this combine. It is working properly.
import { Editor } from "react-draft-wysiwyg";
import { useEffect } from "react";
import {convertToRaw } from 'draft-js';
import draftToHtml from 'draftjs-to-html';
Maybe, you can try that.
For Example:
You can use to the state initial value below:
EditorState.createWithContent(
ContentState.createFromBlockArray(
convertFromHTML(<your_data>)
)
)
You can use to store the HTML data below:
draftToHtml(convertToRaw(<your_state>.getCurrentContent()))
I'm using Strapi to call dynamic data into my website via an API GET request, and I want to generate paths for my dynamic pages. One level of dynamic pages works fine, but the second is a challenge.
My structure is as follows:
[category].js
[category]/[client].js
Both are dynamic, so I have, for example, a category "fashion" with multiple clients. The same goes for other categories like "products".
The first dynamic page works fine in building paths
[dynamic.js].
import CategoryCard from "../../../components/portfolio/categoryCard";
import { fetcher } from "../../../lib/api";
export const getStaticPaths = async () => {
const categoryPathResponse = await fetcher(
`${process.env.NEXT_PUBLIC_STRAPI_URL}/categories`
);
const data = categoryPathResponse.data;
const paths = data.map((path) => {
return {
params: { category: path.attributes.path.toString().toLowerCase() },
};
});
return {
paths,
fallback: false,
};
};
export async function getStaticProps(context) {
const category = context.params.category;
const categoryPropsResponse = await fetcher(
`${process.env.NEXT_PUBLIC_STRAPI_URL}/categories?filters[path][$eq]=${category}&?populate[0]=clients&populate[1]=clients.thumbnail`
);
return {
props: { category: categoryPropsResponse },
};
}
const CategoryOverviewPage = ({ category }) => {
const data = category.data;
const categoryTitle = data[0].attributes.Category;
return (
<>
{console.log('data for category before card', data)}
<div className="flex px-4 mt-24 lg:mt-12 lg:px-20">
<div>
<h1 className="[writing-mode:vertical-lr] [-webkit-writing-mode: vertical-lr] [-ms-writing-mode: vertical-lr] rotate-180 text-center">
{categoryTitle}
</h1>
</div>
<div className="grid grid-cols-[repeat(auto-fit,_minmax(150px,_250px))] gap-4 lg:gap-8 ml-4 lg:ml-32 max-w-[82vw]">
<CategoryCard data={data} />
</div>
</div>
</>
);
};
export default CategoryOverviewPage;
But the complexity comes with the second part, in which I have to create multiple paths per category. I tried and ended up with the following
[clients].js
export const getStaticPaths = async () => {
const categoryPathResponse = await fetcher(
`${process.env.NEXT_PUBLIC_STRAPI_URL}/categories?populate=*`
);
const data = categoryPathResponse.data;
const paths = data.map((path) => {
const category = path.attributes.path.toString().toLowerCase()
const client = path.attributes.clients.map((client) => client.name).toString().toLowerCase().replace(/\s+/g, "-")
return {
params: {
category: category, client: client
},
};
});
return {
paths,
fallback: false,
};
};
export async function getStaticProps(context) {
const category = context.params.category;
const client = context.params.client;
const data = await fetcher(
`${process.env.NEXT_PUBLIC_STRAPI_URL_BASE}/categories?filters[path][$eq]=${category}&?populate[clients][populate]=*&populate[clients][filters][name][$eq]=${client}`
);
return {
props: { client: data },
};
}
It seems to work for categories with only 1 item, which makes sense because a URL (path) is created like index/category/client.
But when there are multiple clients, it tries to create a path with 1 category and multiple clients attached to the same path, something like this category/client1client2.
This has to be separated, and for each client, there has to be a new path created like category1/client1, category1/client2, category2/client1, category2/client2, etc.
Any ideas?
In addition to mapping over the categories data, you also need to map over the clients array and generate a path entry for each.
Modify the code inside getStaticPaths in /[category]/[client].js as follows.
export const getStaticPaths = async () => {
// Existing code...
const paths = data.map((path) => {
const category = path.attributes.path.toString().toLowerCase()
return path.attributes.clients
.map((client) => {
const clientDetails = client.name.toLowerCase().replace(/\s+/g, "-")
return {
params: {
category: category, client: clientDetails
}
};
})
}).flat() // Flatten array to avoid nested arrays;
return {
paths,
fallback: false,
};
};
I have a small learning project build with React, TS, & Apollo.
As backend I am using https://graphqlzero.almansi.me/
The result I am looking for is:
Fetch Posts by page, 5 per page
Re-use previously fetched data
Here is my container
export const GET_POSTS = gql`
query GetPosts($options: PageQueryOptions!) {
posts(options: $options) {
data {
id
title
body
}
}
}
`
const Posts = (): JSX.Element => {
const [page, setPage] = useState<number>(1)
const { data, fetchMore} = useQuery(GET_POSTS, {
variables: {
options: {
paginate: {
page: 1,
limit: 5,
},
},
},
nextFetchPolicy: "cache-first"
},
)
return (
<div>
<ListOfPosts {...{data,fetchMore,page,setPage }} />
</div>
)
}
and ListOfPosts
const ListOfPosts = ({ data,fetchMore, page, setPage }) => {
const getNextPage = (): void => {
fetchMore({
variables: {
options: {
paginate: {
page: page + 1,
limit: 5,
},
},
},
})
setPage((p: number) => p + 1)
}
const getPrevPage = (): void => {
setPage((p: number) => (p === 0 ? p : p - 1))
}
console.log(data)
return (
<div>
<p>current page{page}</p>
<button type="button" onClick={getPrevPage}>
Get Prev Page
</button>
<button type="button" onClick={getNextPage}>
Get Next Page
</button>
{data &&
data?.posts?.data?.map((post: any) => (
<div key={post.id}>
<h4>{post.title}</h4>
<p>{post.body}</p>
</div>
))}
</div>
)
So if I send a query with page === 1 I get posts from 1 to 5, if page === 2 - posts from 6 to 10 and so on.
The problem is that if for example I send request in next sequence
page === 1 (initially sent by useQuery)
page === 2 ( sending with fetchMore)
page === 3 ( sending with fetchMore)
page === 2 ( sending with fetchMore)
on last request Apollo performs network request, despite data for that that request is already in cache
So my questions actually are:
how to configure Apollo cache to return required data without re-fetching it from a server
How to "invalidate" that data and tell Apollo that I need refresh that particular portion of data?
I think it should be somehow configured in cache typePolicies but haven't found a way to make it work - despite data is in cache( I can track it with browser extension) it is not returned in {data}=useQuery :/
Here is how my cache config looks like.
export const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
merge(existing, incoming) {
return [ ...existing,...incoming ]
}
}
}
}
}
})
So there are two types of pagination. In the first pagination, the "pages" are somehow known to the user and the user navigates through these via the UI. This seems to be what you want to do:
I would propose to keep it simple: Change the state variables, pass them into useQuery and render the results. Apollo will run the query again with the new page value every time the variables change. If Apollo has seen the same variables before (you go back to the previous page), Apollo can return the result from cache.
export const GET_POSTS = gql`
query GetPosts($options: PageQueryOptions!) {
posts(options: $options) {
data {
id
title
body
}
}
}
`
const Posts = (): JSX.Element => {
const [page, setPage] = useState<number>(1)
const { data } = useQuery(GET_POSTS, {
variables: {
options: {
paginate: {
page,
limit: 5,
},
},
},
nextFetchPolicy: "cache-first"
})
return (
<div>
<ListOfPosts {...{data,page,setPage }} />
</div>
)
}
const ListOfPosts = ({ data, page, setPage }) => {
const getNextPage = (): void => setPage((p: number) => p + 1);
const getPrevPage = (): void =>
setPage((p: number) => (p === 0 ? p : p - 1))
return (
<div>
<p>current page{page}</p>
<button type="button" onClick={getPrevPage}>
Get Prev Page
</button>
<button type="button" onClick={getNextPage}>
Get Next Page
</button>
{data &&
data?.posts?.data?.map((post: any) => (
<div key={post.id}>
<h4>{post.title}</h4>
<p>{post.body}</p>
</div>
))}
</div>
)
}
Merging the cache is only relevant when you want to continuously show results after another. Things like "load more" or "infinite scrolling". This means you want to continue adding to your result list. You would add more and more posts to the view and the old pages don't disappear. This is what fetchMore is designed for. If you want to do that have a look at the docs and this part specifically. Your problem is that you are currently mixing both approaches, which probably leads to weird results.
I'm new in ReactJS. I have a task - to do an app like Notes. User can add sublist to his notes, and note have to save to the state in subarray. I need to save sublist in the array inside object. I need to get state like this:
[...notes, { _id: noteId, text: noteText, notes: [{_id: subNoteId, text: subNoteText, notes[]}] }].
How can I to do this?
Sandbox here: https://codesandbox.io/s/relaxed-lamarr-u5hug?file=/src/App.js
Thank you for any help, and sorry for my English
const NoteForm = ({ saveNote, placeholder }) => {
const [value, setValue] = useState("");
const submitHandler = (event) => {
event.preventDefault();
saveNote(value);
setValue("");
};
return (
<form onSubmit={submitHandler}>
<input
type="text"
onChange={(event) => setValue(event.target.value)}
value={value}
placeholder={placeholder}
/>
<button type="submit">Add</button>
</form>
);
};
const NoteList = ({ notes, saveNote }) => {
const renderSubNotes = (noteArr) => {
const list = noteArr.map((note) => {
let subNote;
if (note.notes && note.notes.length > 0) {
subNote = renderSubNotes(note.notes);
}
return (
<div key={note._id}>
<li>{note.text}</li>
<NoteForm placeholder="Enter your sub note" saveNote={saveNote} />
{subNote}
</div>
);
});
return <ul>{list}</ul>;
};
return renderSubNotes(notes);
};
export default function App() {
const [notes, setNotes] = useState([]);
const saveHandler = (text) => {
const trimmedText = text.trim();
const noteId =
Math.floor(Math.random() * 1000) + trimmedText.replace(/\s/g, "");
if (trimmedText.length > 0) {
setNotes([...notes, { _id: noteId, text: trimmedText, notes: [] }]);
}
};
return (
<div>
<h1>Notes</h1>
<NoteList notes={notes} saveNote={saveHandler} />
<NoteForm
saveNote={saveHandler}
placeholder="Enter your note"
/>
</div>
);
}
The code in your saveHandler function is where you're saving your array of notes.
Specifically, this line:
setNotes([...notes, { _id: noteId, text: trimmedText, notes: [] }]);
But at the moment you're saving an empty array. What if you create another stateful variable called currentNote or something like that, relative to whatever note the user is currently working on within the application? While they are working on that note, the stateful currentNote object is updated with the relevant data, e.g. noteID, content, and parentID. Then, when the user has finished editing that particular note, by either pressing save or the plus button to add a new subnote, etc, that should fire a function such as your saveHandler to add the currentNote object to the "notes" array in the stateful "notes" variable. I'm not sure I like that the stateful variable notes contains an array within it called notes as well. I think this may cause confusion.
But in short, your setNotes line could change to something like (bear with me my JS syntax skills suck):
let newNotes= [...notes.notes];
newNotes.push(currentNote);
setNotes([...notes, { _id: noteId, text: trimmedText, notes: newNotes }]);
Wherein your stateful currentNote object is copied into the notes array on every save.