Conversation
|
The latest updates on your projects. Learn more about Vercel for Git ↗︎
|
WalkthroughThe changes involve significant updates to the project, including dependency upgrades in Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant LocationInput
participant MapboxAPI
User->>LocationInput: Enter search term
LocationInput->>MapboxAPI: Request location suggestions
MapboxAPI-->>LocationInput: Return suggestions
LocationInput-->>User: Display suggestions
Poem
Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media? TipsChatThere are 3 ways to chat with CodeRabbit:
Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments. CodeRabbit Commands (Invoked using PR comments)
Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Actionable comments posted: 10
Outside diff range, codebase verification and nitpick comments (2)
src/app/_components/ui/badge.tsx (1)
1-2: Consider justifying or removing eslint disable comments.The eslint disable comments at the top of the file (
tailwindcss/classnames-orderandtailwindcss/no-custom-classname) should be justified with a comment explaining why they are necessary, or removed if the linting issues can be resolved.src/app/(routes)/vendor/listings/service-editor/create-listing-form.tsx (1)
Line range hint
37-426: Enhance form handling and layout inCreateListingForm.The integration of the
LocationInputcomponent in theCreateListingFormintroduces new functionality that requires careful handling. Consider the following improvements:
- Form Validation: Ensure that the
service_locationfield is properly validated both client-side and server-side to prevent invalid data submission.- Layout Consistency: Review the new layout adjustments to ensure they are consistent with the rest of the form and provide a good user experience.
- Data Handling: Check that the new location data is correctly handled in the form's submission process, especially in terms of security and data integrity.
Here's an example of how you might add validation for the
service_locationfield:const form = useForm<z.infer<typeof ServiceListingSchema>>({ mode: "onChange", resolver: zodResolver(ServiceListingSchema), defaultValues: { service_name: "", service_description: "", service_category: "", service_price: "", + service_location: "", }, });
Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
Files selected for processing (17)
- package.json (1 hunks)
- src/app/(routes)/vendor/actions.ts (2 hunks)
- src/app/(routes)/vendor/listings/service-editor/(components)/location.tsx (1 hunks)
- src/app/(routes)/vendor/listings/service-editor/(components)/location2.tsx (1 hunks)
- src/app/(routes)/vendor/listings/service-editor/_hooks/useLocation.ts (1 hunks)
- src/app/(routes)/vendor/listings/service-editor/_hooks/useUploadImages.ts (1 hunks)
- src/app/(routes)/vendor/listings/service-editor/create-listing-form.tsx (6 hunks)
- src/app/(routes)/vendor/listings/service-editor/listing-file-upload.tsx (1 hunks)
- src/app/_components/calendar.tsx (1 hunks)
- src/app/_components/frontpage2.tsx (1 hunks)
- src/app/_components/ui/badge.tsx (1 hunks)
- src/app/_components/ui/multiple-selector.tsx (1 hunks)
- src/app/_lib/mapbox/index.ts (1 hunks)
- src/app/_lib/schema.ts (2 hunks)
- src/app/_types/index.ts (1 hunks)
- src/app/page.tsx (1 hunks)
- tsconfig.json (1 hunks)
Files skipped from review due to trivial changes (2)
- src/app/(routes)/vendor/listings/service-editor/_hooks/useUploadImages.ts
- src/app/(routes)/vendor/listings/service-editor/listing-file-upload.tsx
Additional context used
Biome
src/app/_lib/schema.ts
[error] 58-58: Unexpected constant condition.
(lint/correctness/noConstantCondition)
src/app/_types/index.ts
[error] 26-26: Don't use '{}' as a type.
Prefer explicitly define the object shape. '{}' means "any non-nullable value".
(lint/complexity/noBannedTypes)
Additional comments not posted (6)
src/app/page.tsx (2)
1-1: Verify the newFrontPagecomponent.The import path for the
FrontPagecomponent has been updated. Please ensure that the new component at../app/_components/frontpage2is fully functional and compatible with the existing application.
2-3: Assess the removal ofFooterandNavbar.The
FooterandNavbarcomponents have been commented out, which indicates they are no longer part of theHomepage layout. Please confirm that this change aligns with the overall design and functionality requirements of the application.Also applies to: 9-11
tsconfig.json (1)
7-7: Approved addition of Mapbox SDK type definitions.The inclusion of
"@types/mapbox__mapbox-sdk"in thetsconfig.jsonfile is a good practice as it enhances type safety and developer experience by providing better autocompletion and error checking for Mapbox functionalities.src/app/_components/ui/badge.tsx (1)
8-38: Approved implementation of the Badge component.The
Badgecomponent is well-implemented with clear handling of variants and props. The use ofclass-variance-authorityfor managing class names based on variants is a good practice, enhancing the component's flexibility and maintainability.package.json (1)
12-71: Approved updates and additions to dependencies.The updates to dependencies and the addition of new ones like
@mapbox/mapbox-sdkand@mapbox/search-js-coreare aligned with the project's new features involving mapping functionalities. It's important to ensure that these updates are tested for compatibility and functionality, especially the beta version of@mapbox/search-js-core.Run the following script to verify the compatibility and functionality of the new and updated dependencies:
src/app/_types/index.ts (1)
29-33: Review:MapBoxResponseInterfaceThis interface is appropriately defined to encapsulate the response from a MapBox API call. It effectively utilizes the
MaxBoxSuggestionsinterface, ensuring type safety and consistency across the application.
| import { useSearchBoxCore } from "@mapbox/search-js-react"; | ||
|
|
||
| export const useLocation = async () => { | ||
| const searchBoxCore = useSearchBoxCore({ | ||
| accessToken: process.env.ACCESS_TOKEN, | ||
| }); | ||
| const response = await searchBoxCore.suggest("1600 pennsylvania ave nw", { | ||
| sessionToken: "test-123", | ||
| }); | ||
| console.log(`response from mapbox`, response); | ||
| // { suggestions: [...], attribution: '...', url: '...' }; | ||
|
|
||
| return { | ||
| searchBoxCore, | ||
| response, | ||
| }; | ||
| }; |
There was a problem hiding this comment.
Improve security and response handling in useLocation hook.
The useLocation hook uses an API key from environment variables and makes a call to the Mapbox API. Consider the following improvements:
- Ensure that the API key is securely handled and not exposed in client-side code.
- Validate and sanitize the API response to prevent potential security vulnerabilities.
| export async function requestMapboxToken( | ||
| username = process.env.MAPBOX_USERNAME, | ||
| accessToken = process.env.MAPBOX_ACCESS_TOKEN, | ||
| ) { | ||
| const url = `https://api.mapbox.com/tokens/v2/${username}?access_token=${accessToken}`; | ||
| const expirationDate = new Date(); | ||
| expirationDate.setMinutes(expirationDate.getMinutes() + 59); // Set expiration to 59 minutes from now | ||
|
|
||
| // | ||
| const requestBody = { | ||
| expires: expirationDate.toISOString(), | ||
| scopes: ["styles:read", "fonts:read"], | ||
| }; | ||
|
|
||
| const response = await fetch(url, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify(requestBody), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const errorDetails = await response.text(); | ||
| throw new Error( | ||
| `Request failed with status ${response.status}: ${errorDetails}`, | ||
| ); | ||
| } | ||
|
|
||
| const data = await response.json(); | ||
| return data; | ||
| } |
There was a problem hiding this comment.
Enhance security and error handling in requestMapboxToken.
The requestMapboxToken function constructs a URL with user credentials and makes a POST request to the Mapbox API. Consider the following improvements:
- Ensure that sensitive data such as usernames and access tokens are securely handled and not exposed.
- Implement comprehensive error handling to manage different types of API response errors effectively.
| service_location: z.string().transform((val, ctx) => { | ||
| console.log("received location values in the schema: ", val); | ||
| // eslint-disable-next-line no-constant-condition | ||
| if (true) { | ||
| return ctx.addIssue({ | ||
| code: z.ZodIssueCode.custom, | ||
| message: "Location is required. Please select a location.", | ||
| }); | ||
| } | ||
| return val; | ||
| }), |
There was a problem hiding this comment.
Remove constant condition and reconsider logging in production.
The transformation function for service_location always triggers a validation issue due to the constant condition if (true). This should be replaced with a meaningful condition or removed if not needed.
Additionally, consider removing or guarding the logging statement to avoid performance issues and potential information leakage in production environments.
Apply this diff to address the issues:
- console.log("received location values in the schema: ", val);
- if (true) {
+ if (!val) { // Assuming the condition should check for non-empty values
return ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Location is required. Please select a location.",
});
}Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| service_location: z.string().transform((val, ctx) => { | |
| console.log("received location values in the schema: ", val); | |
| // eslint-disable-next-line no-constant-condition | |
| if (true) { | |
| return ctx.addIssue({ | |
| code: z.ZodIssueCode.custom, | |
| message: "Location is required. Please select a location.", | |
| }); | |
| } | |
| return val; | |
| }), | |
| service_location: z.string().transform((val, ctx) => { | |
| if (!val) { // Assuming the condition should check for non-empty values | |
| return ctx.addIssue({ | |
| code: z.ZodIssueCode.custom, | |
| message: "Location is required. Please select a location.", | |
| }); | |
| } | |
| return val; | |
| }), |
Tools
Biome
[error] 58-58: Unexpected constant condition.
(lint/correctness/noConstantCondition)
| export default function FrontPage() { | ||
| return ( | ||
| <div className="bg-white"> | ||
| <div className="relative isolate px-6 pt-14 lg:px-8"> | ||
| <div | ||
| aria-hidden="true" | ||
| className="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80" | ||
| > | ||
| <div | ||
| style={{ | ||
| clipPath: | ||
| "polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)", | ||
| }} | ||
| className="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-[#F8DA1B] to-[#9A008A] opacity-30 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]" | ||
| /> | ||
| </div> | ||
| <div className="mx-auto max-w-2xl py-32 sm:py-48 lg:py-56"> | ||
| <div className="hidden sm:mb-8 sm:flex sm:justify-center"> | ||
| <div className="relative rounded-full px-3 py-1 text-sm leading-6 text-gray-600 ring-1 ring-gray-900/10 hover:ring-gray-900/20"> | ||
| Visit our new website {` `} | ||
| <a | ||
| aria-label="Visit the Joyful Mode" | ||
| target="_blank" | ||
| href="https://www.thejoyfulmode.com" | ||
| className="font-semibold text-fuchsia-600" | ||
| > | ||
| <span aria-hidden="true" className="absolute inset-0" /> | ||
| Learn more <span aria-hidden="true">→</span> | ||
| </a> | ||
| </div> | ||
| </div> | ||
| <div className="text-center"> | ||
| <h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl"> | ||
| Introducing <br /> | ||
| The Joyful Mode | ||
| </h1> | ||
| <p className="mt-6 text-lg leading-8 text-gray-600"> | ||
| From Originotes to a new era of creativity and positivity, we're | ||
| excited to bring you The Joyful Mode – your ultimate partner in | ||
| Web Design, Development, SEO, and Marketing Services tailored for | ||
| Law Firms. | ||
| </p> | ||
| <div className="mt-10 flex items-center justify-center gap-x-6"> | ||
| <a | ||
| aria-label="Visit the Joyful Mode" | ||
| target="_blank" | ||
| href="https://www.thejoyfulmode.com" | ||
| className="rounded-md bg-[#F8DA1B] px-3.5 py-2.5 text-sm font-semibold text-black shadow-sm hover:bg-[#fae665] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fuchsia-600" | ||
| > | ||
| Visit us at the Joyful Mode | ||
| </a> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <div | ||
| aria-hidden="true" | ||
| className="absolute inset-x-0 top-[calc(100%-13rem)] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[calc(100%-30rem)]" | ||
| > | ||
| <div | ||
| style={{ | ||
| clipPath: | ||
| "polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)", | ||
| }} | ||
| className="relative left-[calc(50%+3rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 bg-gradient-to-tr from-[#F8DA1B] to-[#9A008A] opacity-30 sm:left-[calc(50%+36rem)] sm:w-[72.1875rem]" | ||
| /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Enhance security and consider performance implications.
The FrontPage component uses complex CSS properties that might affect performance on lower-end devices. Consider testing on various devices to ensure a smooth user experience.
Additionally, links that open in new tabs should include rel="noopener noreferrer" to mitigate potential security risks.
Apply this diff to enhance security for outbound links:
- <a
- aria-label="Visit the Joyful Mode"
- target="_blank"
- href="https://www.thejoyfulmode.com"
- className="font-semibold text-fuchsia-600"
- >
+ <a
+ aria-label="Visit the Joyful Mode"
+ target="_blank"
+ rel="noopener noreferrer"
+ href="https://www.thejoyfulmode.com"
+ className="font-semibold text-fuchsia-600"
+ >Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export default function FrontPage() { | |
| return ( | |
| <div className="bg-white"> | |
| <div className="relative isolate px-6 pt-14 lg:px-8"> | |
| <div | |
| aria-hidden="true" | |
| className="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80" | |
| > | |
| <div | |
| style={{ | |
| clipPath: | |
| "polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)", | |
| }} | |
| className="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-[#F8DA1B] to-[#9A008A] opacity-30 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]" | |
| /> | |
| </div> | |
| <div className="mx-auto max-w-2xl py-32 sm:py-48 lg:py-56"> | |
| <div className="hidden sm:mb-8 sm:flex sm:justify-center"> | |
| <div className="relative rounded-full px-3 py-1 text-sm leading-6 text-gray-600 ring-1 ring-gray-900/10 hover:ring-gray-900/20"> | |
| Visit our new website {` `} | |
| <a | |
| aria-label="Visit the Joyful Mode" | |
| target="_blank" | |
| href="https://www.thejoyfulmode.com" | |
| className="font-semibold text-fuchsia-600" | |
| > | |
| <span aria-hidden="true" className="absolute inset-0" /> | |
| Learn more <span aria-hidden="true">→</span> | |
| </a> | |
| </div> | |
| </div> | |
| <div className="text-center"> | |
| <h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl"> | |
| Introducing <br /> | |
| The Joyful Mode | |
| </h1> | |
| <p className="mt-6 text-lg leading-8 text-gray-600"> | |
| From Originotes to a new era of creativity and positivity, we're | |
| excited to bring you The Joyful Mode – your ultimate partner in | |
| Web Design, Development, SEO, and Marketing Services tailored for | |
| Law Firms. | |
| </p> | |
| <div className="mt-10 flex items-center justify-center gap-x-6"> | |
| <a | |
| aria-label="Visit the Joyful Mode" | |
| target="_blank" | |
| href="https://www.thejoyfulmode.com" | |
| className="rounded-md bg-[#F8DA1B] px-3.5 py-2.5 text-sm font-semibold text-black shadow-sm hover:bg-[#fae665] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fuchsia-600" | |
| > | |
| Visit us at the Joyful Mode | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| <div | |
| aria-hidden="true" | |
| className="absolute inset-x-0 top-[calc(100%-13rem)] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[calc(100%-30rem)]" | |
| > | |
| <div | |
| style={{ | |
| clipPath: | |
| "polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)", | |
| }} | |
| className="relative left-[calc(50%+3rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 bg-gradient-to-tr from-[#F8DA1B] to-[#9A008A] opacity-30 sm:left-[calc(50%+36rem)] sm:w-[72.1875rem]" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default function FrontPage() { | |
| return ( | |
| <div className="bg-white"> | |
| <div className="relative isolate px-6 pt-14 lg:px-8"> | |
| <div | |
| aria-hidden="true" | |
| className="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80" | |
| > | |
| <div | |
| style={{ | |
| clipPath: | |
| "polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)", | |
| }} | |
| className="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-[#F8DA1B] to-[#9A008A] opacity-30 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]" | |
| /> | |
| </div> | |
| <div className="mx-auto max-w-2xl py-32 sm:py-48 lg:py-56"> | |
| <div className="hidden sm:mb-8 sm:flex sm:justify-center"> | |
| <div className="relative rounded-full px-3 py-1 text-sm leading-6 text-gray-600 ring-1 ring-gray-900/10 hover:ring-gray-900/20"> | |
| Visit our new website {` `} | |
| <a | |
| aria-label="Visit the Joyful Mode" | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| href="https://www.thejoyfulmode.com" | |
| className="font-semibold text-fuchsia-600" | |
| > | |
| <span aria-hidden="true" className="absolute inset-0" /> | |
| Learn more <span aria-hidden="true">→</span> | |
| </a> | |
| </div> | |
| </div> | |
| <div className="text-center"> | |
| <h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl"> | |
| Introducing <br /> | |
| The Joyful Mode | |
| </h1> | |
| <p className="mt-6 text-lg leading-8 text-gray-600"> | |
| From Originotes to a new era of creativity and positivity, we're | |
| excited to bring you The Joyful Mode – your ultimate partner in | |
| Web Design, Development, SEO, and Marketing Services tailored for | |
| Law Firms. | |
| </p> | |
| <div className="mt-10 flex items-center justify-center gap-x-6"> | |
| <a | |
| aria-label="Visit the Joyful Mode" | |
| target="_blank" | |
| href="https://www.thejoyfulmode.com" | |
| className="rounded-md bg-[#F8DA1B] px-3.5 py-2.5 text-sm font-semibold text-black shadow-sm hover:bg-[#fae665] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fuchsia-600" | |
| > | |
| Visit us at the Joyful Mode | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| <div | |
| aria-hidden="true" | |
| className="absolute inset-x-0 top-[calc(100%-13rem)] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[calc(100%-30rem)]" | |
| > | |
| <div | |
| style={{ | |
| clipPath: | |
| "polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)", | |
| }} | |
| className="relative left-[calc(50%+3rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 bg-gradient-to-tr from-[#F8DA1B] to-[#9A008A] opacity-30 sm:left-[calc(50%+36rem)] sm:w-[72.1875rem]" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } |
| export const Location2 = () => { | ||
| const [selectedPeople, setSelectedPeople] = useState([people[0], people[1]]); | ||
| const [query, setQuery] = useState(""); | ||
|
|
||
| const filteredPeople = | ||
| query === "" | ||
| ? people | ||
| : people.filter((person) => { | ||
| return person.name.toLowerCase().includes(query.toLowerCase()); | ||
| }); | ||
|
|
||
| return ( | ||
| <Combobox | ||
| multiple | ||
| as="div" | ||
| value={selectedPeople} | ||
| onChange={setSelectedPeople} | ||
| onClose={() => setQuery("")} | ||
| > | ||
| <Label className="block text-sm font-medium leading-6 text-gray-900"> | ||
| Location | ||
| </Label> | ||
| <div className="relative mt-2"> | ||
| <ComboboxInput | ||
| className="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-fuchsia-600 sm:text-sm sm:leading-6" | ||
| onChange={(event) => setQuery(event.target.value)} | ||
| onBlur={() => setQuery("")} | ||
| // displayValue={() => { | ||
| // return (<span>{selectedPeople.map((person) => person.name)}</span>); | ||
| // }} | ||
| /> | ||
| <ComboboxButton className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"> | ||
| <ChevronUpDownIcon | ||
| className="size-5 text-gray-400" | ||
| aria-hidden="true" | ||
| /> | ||
| </ComboboxButton> | ||
|
|
||
| {filteredPeople.length > 0 && ( | ||
| <ComboboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm"> | ||
| {filteredPeople.map((person) => ( | ||
| <ComboboxOption | ||
| key={person.name} | ||
| value={person} | ||
| className={({ focus }) => | ||
| classNames( | ||
| "relative cursor-default select-none py-2 pl-3 pr-9", | ||
| focus ? "bg-fuchsia-600 text-white" : "text-gray-900", | ||
| ) | ||
| } | ||
| > | ||
| {({ focus, selected }) => ( | ||
| <> | ||
| <div className="flex"> | ||
| <span | ||
| className={classNames( | ||
| "truncate", | ||
| selected && "font-semibold", | ||
| )} | ||
| > | ||
| {person.name} | ||
| </span> | ||
| <span | ||
| className={classNames( | ||
| "ml-2 truncate text-gray-500", | ||
| focus ? "text-fuchsia-200" : "text-gray-500", | ||
| )} | ||
| > | ||
| {person.name} | ||
| </span> | ||
| </div> | ||
|
|
||
| {selected && ( | ||
| <span | ||
| className={classNames( | ||
| "absolute inset-y-0 right-0 flex items-center pr-4", | ||
| focus ? "text-white" : "text-fuchsia-600", | ||
| )} | ||
| > | ||
| <CheckIcon className="size-5" aria-hidden="true" /> | ||
| </span> | ||
| )} | ||
| </> | ||
| )} | ||
| </ComboboxOption> | ||
| ))} | ||
| </ComboboxOptions> | ||
| )} | ||
| </div> | ||
| <div className="mt-4 flex flex-wrap items-center justify-start space-x-3"> | ||
| {selectedPeople.map((person) => ( | ||
| <span key={person.id} className="pb-3"> | ||
| <Badge variant="outline"> | ||
| {person.name} <XMarkIcon className="ml-1 size-4 font-semibold text-neutral-900" /> | ||
| </Badge> | ||
| {/* <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="absolute right-0 top-1 block size-4 -translate-y-1/2 translate-x-1/2 text-neutral-500"> | ||
| <path fillRule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z" clipRule="evenodd" /> | ||
| </svg> */} | ||
| </span> | ||
| ))} | ||
| </div> | ||
| </Combobox> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
Optimize class name generation and enhance accessibility.
The Location2 component uses the classNames function extensively to dynamically generate class names. While this is a common pattern, consider simplifying its usage to improve readability and performance.
Additionally, ensure that all interactive elements are accessible, particularly for keyboard and screen reader users.
Consider simplifying the classNames function usage by reducing the number of conditions and possibly caching common results.
| export interface MaxBoxSuggestions { | ||
| name: string; | ||
| mapbox_id: string; | ||
| feature_type: string; | ||
| place_formatted: string; | ||
| context: { | ||
| country: { | ||
| id: string; | ||
| name: string; | ||
| country_code: string; | ||
| country_code_alpha_3: string; | ||
| }; | ||
| region: { | ||
| id: string; | ||
| name: string; | ||
| region_code: string; | ||
| region_code_full: string; | ||
| }; | ||
| district: { | ||
| id: string; | ||
| name: string; | ||
| }; | ||
| }; | ||
| language: "en"; | ||
| maki: "marker"; | ||
| metadata: {}; | ||
| } |
There was a problem hiding this comment.
Review: MaxBoxSuggestions Interface
The interface is well-structured and aligns with the requirements for handling map-related data. However, consider the following improvements:
- Flexibility for
languageandmaki: Currently set to fixed values, these might benefit from being more flexible to accommodate different scenarios or future requirements. - Metadata Type Definition: The
metadataproperty is currently an empty object ({}). Consider defining a more explicit type to avoid potential issues and align with best practices.
To address the static analysis hint about using '{}' as a type, consider defining a more explicit interface or type for metadata, even if it's initially empty:
interface Metadata {
// Define potential properties here
}
export interface MaxBoxSuggestions {
...
metadata: Metadata;
}Tools
Biome
[error] 26-26: Don't use '{}' as a type.
Prefer explicitly define the object shape. '{}' means "any non-nullable value".
(lint/complexity/noBannedTypes)
| export function LocationInput({ | ||
| form, | ||
| }: { | ||
| form: UseFormReturn<z.infer<typeof ServiceListingSchema>>; | ||
| }) { | ||
| const [searchTerm, setSearchTerm] = useState(""); | ||
| const [isSearching, setIsSearching] = useState(false); | ||
| const [open, setOpen] = useState(false); | ||
| const value = ""; | ||
| const debouncedSearchTerm = useDebounce(searchTerm, 300); | ||
| const [locations, setLocations] = useState<SearchBoxSuggestionResponse | null>(null); | ||
| const [selectedLocations, setSelectedLocations] = useState< | ||
| SearchBoxSuggestion[] | [] | ||
| >([]); | ||
|
|
||
| useEffect(() => { | ||
| if (!open) { | ||
| setSearchTerm(""); | ||
| setLocations(null); | ||
| } | ||
| }, [open]); | ||
|
|
||
|
|
||
| useEffect(() => { | ||
| if (selectedLocations.length) { | ||
| form.setValue("service_location", JSON.stringify(selectedLocations)); | ||
| } | ||
| }, [selectedLocations, form]); | ||
|
|
||
| useEffect(() => { | ||
| const searchLocation = async (str: string) => { | ||
| try { | ||
| setIsSearching(true); | ||
| const response = await search(str); | ||
| if (response) { | ||
| setLocations(response); | ||
| setIsSearching(false); | ||
| } | ||
| } catch (error) { | ||
| setIsSearching(false); | ||
| } | ||
| }; | ||
| if (searchTerm.length) { | ||
| searchLocation(debouncedSearchTerm); | ||
| } else { | ||
| setLocations(null); | ||
| } | ||
| }, [searchTerm, debouncedSearchTerm]); | ||
|
|
||
| return ( | ||
| <div> | ||
| <input | ||
| id="service_location" | ||
| type="hidden" | ||
| {...form.register("service_location", { | ||
| required: true, | ||
| value: JSON.stringify(selectedLocations), | ||
| })} | ||
| /> | ||
| <div className="col-span-full flex flex-col"> | ||
| <Label className="dark:text-background mb-3">Location</Label> | ||
| <Popover open={open} onOpenChange={setOpen}> | ||
| <PopoverTrigger asChild> | ||
| <Button | ||
| variant="outline" | ||
| role="combobox" | ||
| aria-expanded={open} | ||
| className="text-muted-foreground w-[300px] justify-between md:w-[600px] dark:bg-transparent dark:text-white" | ||
| > | ||
| {value | ||
| ? locations?.suggestions.find((location) => location.name === value)?.name | ||
| : "Search by city or zipcode..."} | ||
| <CaretSortIcon className="ml-2 size-4 shrink-0 opacity-50" /> | ||
| </Button> | ||
| </PopoverTrigger> | ||
| <PopoverContent className="w-[300px] p-0 md:w-[600px]"> | ||
| <Command shouldFilter={false}> | ||
| <CommandInput | ||
| onValueChange={(value) => setSearchTerm(value.trim())} | ||
| placeholder="e.g. Miami, San Francisco, Greenville, 33015..." | ||
| className="h-9" | ||
| /> | ||
| <CommandEmpty> | ||
| {isSearching && searchTerm && `Searching...`} | ||
| {!locations?.suggestions.length && | ||
| !searchTerm && | ||
| !isSearching && | ||
| `Start typing to search for a location...`} | ||
| {!locations?.suggestions.length && | ||
| !isSearching && | ||
| debouncedSearchTerm && | ||
| searchTerm && | ||
| `No results found for "${searchTerm}"`} | ||
| </CommandEmpty> | ||
| <CommandGroup> | ||
| <CommandList> | ||
| {locations?.suggestions.map((location) => ( | ||
| <CommandItem | ||
| value={location.name} | ||
| key={location.mapbox_id} | ||
| onSelect={() => { | ||
| setSelectedLocations([...selectedLocations, location]); | ||
| setOpen(false); | ||
| }} | ||
| > | ||
| {`${location.name}, ${location.place_formatted}`} | ||
| <CheckIcon | ||
| className={cn( | ||
| "ml-auto size-4", | ||
| value === location.name ? "opacity-100" : "opacity-0", | ||
| )} | ||
| /> | ||
| </CommandItem> | ||
| ))} | ||
| </CommandList> | ||
| </CommandGroup> | ||
| </Command> | ||
| </PopoverContent> | ||
| </Popover> | ||
| <p className="text-muted-foreground dark:text-background mt-3 text-[0.8rem]"> | ||
| Your service can have multiple cities within the same county or | ||
| different counties in your state. | ||
| </p> | ||
| {/* server or client errors will go here */} | ||
| <div className="mt-4 flex flex-wrap items-center justify-start space-x-3"> | ||
| {selectedLocations.map((location) => ( | ||
| <span key={location.mapbox_id} className="pb-3"> | ||
| <Badge variant="outline" className="dark:text-white"> | ||
| {`${location.name}, ${location?.context?.region?.name}`}{" "} | ||
| <XMarkIcon className="ml-1 size-4 font-semibold text-neutral-900 dark:text-white" /> | ||
| </Badge> | ||
| </span> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); |
There was a problem hiding this comment.
Review: LocationInput Component
The LocationInput component is well-constructed with good use of React hooks and debouncing for performance optimization. However, consider the following improvements:
- Error Handling: Currently, the error handling in the search function does not provide any user feedback. Consider displaying an error message or indicator to inform the user when a search fails.
Improve error handling by adding a state for storing error messages and displaying them in the UI:
const [error, setError] = useState<string | null>(null);
try {
const response = await search(str);
setLocations(response);
setIsSearching(false);
} catch (error) {
setIsSearching(false);
setError('Failed to fetch locations');
// Optionally, reset locations
setLocations(null);
}And in the UI:
{error && <div className="text-red-500">{error}</div>}| export const CalendarUI = () => { | ||
| return ( | ||
| <div className="md:grid md:grid-cols-2 md:divide-x md:divide-gray-200"> | ||
| <div className="md:pr-14"> | ||
| <div className="flex items-center"> | ||
| <h2 className="flex-auto text-sm font-semibold text-gray-900">January 2022</h2> | ||
| <button | ||
| type="button" | ||
| className="-my-1.5 flex flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500" | ||
| > | ||
| <span className="sr-only">Previous month</span> | ||
| <ChevronLeftIcon className="size-5" aria-hidden="true" /> | ||
| </button> | ||
| <button | ||
| type="button" | ||
| className="-my-1.5 -mr-1.5 ml-2 flex flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500" | ||
| > | ||
| <span className="sr-only">Next month</span> | ||
| <ChevronRightIcon className="size-5" aria-hidden="true" /> | ||
| </button> | ||
| </div> | ||
| <div className="mt-10 grid grid-cols-7 text-center text-xs leading-6 text-gray-500"> | ||
| <div>M</div> | ||
| <div>T</div> | ||
| <div>W</div> | ||
| <div>T</div> | ||
| <div>F</div> | ||
| <div>S</div> | ||
| <div>S</div> | ||
| </div> | ||
| <div className="mt-2 grid grid-cols-7 text-sm"> | ||
| {days.map((day, dayIdx) => ( | ||
| <div key={day.date} className={classNames(dayIdx > 6 && 'border-t border-gray-200', 'py-2')}> | ||
| <button | ||
| type="button" | ||
| className={classNames( | ||
| day.isSelected && 'text-white', | ||
| !day.isSelected && day.isToday && 'text-indigo-600', | ||
| !day.isSelected && !day.isToday && day.isCurrentMonth && 'text-gray-900', | ||
| !day.isSelected && !day.isToday && !day.isCurrentMonth && 'text-gray-400', | ||
| day.isSelected && day.isToday && 'bg-indigo-600', | ||
| day.isSelected && !day.isToday && 'bg-gray-900', | ||
| !day.isSelected && 'hover:bg-gray-200', | ||
| (day.isSelected || day.isToday) && 'font-semibold', | ||
| 'mx-auto flex h-8 w-8 items-center justify-center rounded-full' | ||
| )} | ||
| > | ||
| <time dateTime={day.date}>{day.date.split('-').pop().replace(/^0/, '')}</time> | ||
| </button> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| <section className="mt-12 md:mt-0 md:pl-14"> | ||
| <h2 className="text-base font-semibold leading-6 text-gray-900"> | ||
| Schedule for <time dateTime="2022-01-21">January 21, 2022</time> | ||
| </h2> | ||
| <ol className="mt-4 space-y-1 text-sm leading-6 text-gray-500"> | ||
| {meetings.map((meeting) => ( | ||
| <li | ||
| key={meeting.id} | ||
| className="group flex items-center space-x-4 rounded-xl px-4 py-2 focus-within:bg-gray-100 hover:bg-gray-100" | ||
| > | ||
| <img src={meeting.imageUrl} alt="" className="size-10 flex-none rounded-full" /> | ||
| <div className="flex-auto"> | ||
| <p className="text-gray-900">{meeting.name}</p> | ||
| <p className="mt-0.5"> | ||
| <time dateTime={meeting.startDatetime}>{meeting.start}</time> -{' '} | ||
| <time dateTime={meeting.endDatetime}>{meeting.end}</time> | ||
| </p> | ||
| </div> | ||
| <Menu as="div" className="relative opacity-0 focus-within:opacity-100 group-hover:opacity-100"> | ||
| <div> | ||
| <MenuButton className="-m-2 flex items-center rounded-full p-1.5 text-gray-500 hover:text-gray-600"> | ||
| <span className="sr-only">Open options</span> | ||
| <EllipsisVerticalIcon className="size-6" aria-hidden="true" /> | ||
| </MenuButton> | ||
| </div> | ||
|
|
||
| <Transition | ||
| enter="transition ease-out duration-100" | ||
| enterFrom="transform opacity-0 scale-95" | ||
| enterTo="transform opacity-100 scale-100" | ||
| leave="transition ease-in duration-75" | ||
| leaveFrom="transform opacity-100 scale-100" | ||
| leaveTo="transform opacity-0 scale-95" | ||
| > | ||
| <MenuItems className="absolute right-0 z-10 mt-2 w-36 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> | ||
| <div className="py-1"> | ||
| <MenuItem> | ||
| {({ focus }) => ( | ||
| <a | ||
| href="#" | ||
| className={classNames( | ||
| focus ? 'bg-gray-100 text-gray-900' : 'text-gray-700', | ||
| 'block px-4 py-2 text-sm' | ||
| )} | ||
| > | ||
| Edit | ||
| </a> | ||
| )} | ||
| </MenuItem> | ||
| <MenuItem> | ||
| {({ focus }) => ( | ||
| <a | ||
| href="#" | ||
| className={classNames( | ||
| focus ? 'bg-gray-100 text-gray-900' : 'text-gray-700', | ||
| 'block px-4 py-2 text-sm' | ||
| )} | ||
| > | ||
| Cancel | ||
| </a> | ||
| )} | ||
| </MenuItem> | ||
| </div> | ||
| </MenuItems> | ||
| </Transition> | ||
| </Menu> | ||
| </li> | ||
| ))} | ||
| </ol> | ||
| </section> | ||
| </div> | ||
| ) | ||
| } |
There was a problem hiding this comment.
Review: CalendarUI Component
The CalendarUI component is effectively structured for displaying a calendar and associated meetings. It makes good use of utility functions for class names, enhancing readability. Consider the following improvements:
- Navigation Functionality: The buttons for changing months currently do not have any implemented functionality. Consider adding handlers to enable month navigation.
Implement functionality for the navigation buttons:
const handlePrevMonth = () => {
// Logic to go to the previous month
};
const handleNextMonth = () => {
// Logic to go to the next month
};
<button type="button" onClick={handlePrevMonth} ...>
<button type="button" onClick={handleNextMonth} ...>| export const searchLocation = async ( | ||
| searchTerm: string, | ||
| ): Promise<SearchBoxSuggestionResponse | null> => { | ||
| const search = new SearchBoxCore({ | ||
| accessToken: process.env.MAPBOX_ACCESS_TOKEN!, | ||
| }); | ||
|
|
||
| const sessionToken = new SessionToken(); | ||
| const result = await search.suggest(searchTerm, { | ||
| sessionToken, | ||
| country: "us", | ||
| types: "postcode,city", | ||
| }); | ||
| if (result.suggestions.length === 0) return null; | ||
|
|
||
| return result; | ||
| }; |
There was a problem hiding this comment.
Improve error handling in searchLocation function.
The function searchLocation interacts with the Mapbox API but does not handle potential errors that might arise from the API call. Consider wrapping the API call in a try-catch block to handle these scenarios gracefully. Additionally, the direct use of environment variables (process.env.MAPBOX_ACCESS_TOKEN) within the function could be refactored to enhance security and maintainability by abstracting these details into a configuration module.
Propose the following changes to improve error handling and configuration management:
- const search = new SearchBoxCore({
- accessToken: process.env.MAPBOX_ACCESS_TOKEN!,
- });
+ const accessToken = getConfig('MAPBOX_ACCESS_TOKEN'); // Abstracting access token retrieval
+ const search = new SearchBoxCore({ accessToken });
try {
const sessionToken = new SessionToken();
const result = await search.suggest(searchTerm, {
sessionToken,
country: "us",
types: "postcode,city",
});
if (result.suggestions.length === 0) return null;
return result;
} catch (error) {
console.error('Failed to fetch suggestions:', error);
return null; // Ensure function returns null in case of errors
}Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export const searchLocation = async ( | |
| searchTerm: string, | |
| ): Promise<SearchBoxSuggestionResponse | null> => { | |
| const search = new SearchBoxCore({ | |
| accessToken: process.env.MAPBOX_ACCESS_TOKEN!, | |
| }); | |
| const sessionToken = new SessionToken(); | |
| const result = await search.suggest(searchTerm, { | |
| sessionToken, | |
| country: "us", | |
| types: "postcode,city", | |
| }); | |
| if (result.suggestions.length === 0) return null; | |
| return result; | |
| }; | |
| export const searchLocation = async ( | |
| searchTerm: string, | |
| ): Promise<SearchBoxSuggestionResponse | null> => { | |
| const accessToken = getConfig('MAPBOX_ACCESS_TOKEN'); // Abstracting access token retrieval | |
| const search = new SearchBoxCore({ accessToken }); | |
| try { | |
| const sessionToken = new SessionToken(); | |
| const result = await search.suggest(searchTerm, { | |
| sessionToken, | |
| country: "us", | |
| types: "postcode,city", | |
| }); | |
| if (result.suggestions.length === 0) return null; | |
| return result; | |
| } catch (error) { | |
| console.error('Failed to fetch suggestions:', error); | |
| return null; // Ensure function returns null in case of errors | |
| } | |
| }; |
| const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorProps>( | ||
| ( | ||
| { | ||
| value, | ||
| onChange, | ||
| placeholder, | ||
| defaultOptions: arrayDefaultOptions = [], | ||
| options: arrayOptions, | ||
| delay, | ||
| onSearch, | ||
| loadingIndicator, | ||
| emptyIndicator, | ||
| maxSelected = Number.MAX_SAFE_INTEGER, | ||
| onMaxSelected, | ||
| hidePlaceholderWhenSelected, | ||
| disabled, | ||
| groupBy, | ||
| className, | ||
| badgeClassName, | ||
| selectFirstItem = true, | ||
| creatable = false, | ||
| triggerSearchOnFocus = false, | ||
| commandProps, | ||
| inputProps, | ||
| }: MultipleSelectorProps, | ||
| ref: React.Ref<MultipleSelectorRef>, | ||
| ) => { | ||
| const inputRef = React.useRef<HTMLInputElement>(null); | ||
| const [open, setOpen] = React.useState(false); | ||
| const [isLoading, setIsLoading] = React.useState(false); | ||
|
|
||
| const [selected, setSelected] = React.useState<Option[]>(value || []); | ||
| const [options, setOptions] = React.useState<GroupOption>( | ||
| transToGroupOption(arrayDefaultOptions, groupBy), | ||
| ); | ||
| const [inputValue, setInputValue] = React.useState(''); | ||
| const debouncedSearchTerm = useDebounce(inputValue, delay || 500); | ||
|
|
||
| React.useImperativeHandle( | ||
| ref, | ||
| () => ({ | ||
| selectedValue: [...selected], | ||
| input: inputRef.current as HTMLInputElement, | ||
| focus: () => inputRef.current?.focus(), | ||
| }), | ||
| [selected], | ||
| ); | ||
|
|
||
| const handleUnselect = React.useCallback( | ||
| (option: Option) => { | ||
| const newOptions = selected.filter((s) => s.value !== option.value); | ||
| setSelected(newOptions); | ||
| onChange?.(newOptions); | ||
| }, | ||
| [onChange, selected], | ||
| ); | ||
|
|
||
| const handleKeyDown = React.useCallback( | ||
| (e: React.KeyboardEvent<HTMLDivElement>) => { | ||
| const input = inputRef.current; | ||
| if (input) { | ||
| if (e.key === 'Delete' || e.key === 'Backspace') { | ||
| if (input.value === '' && selected.length > 0) { | ||
| const lastSelectOption = selected[selected.length - 1]; | ||
| // If last item is fixed, we should not remove it. | ||
| if (!lastSelectOption?.fixed) { | ||
| handleUnselect(selected[selected?.length - 1] as Option); | ||
| } | ||
| } | ||
| } | ||
| // This is not a default behavior of the <input /> field | ||
| if (e.key === 'Escape') { | ||
| input.blur(); | ||
| } | ||
| } | ||
| }, | ||
| [handleUnselect, selected], | ||
| ); | ||
|
|
||
| useEffect(() => { | ||
| if (value) { | ||
| setSelected(value); | ||
| } | ||
| }, [value]); | ||
|
|
||
| useEffect(() => { | ||
| /** If `onSearch` is provided, do not trigger options updated. */ | ||
| if (!arrayOptions || onSearch) { | ||
| return; | ||
| } | ||
| const newOption = transToGroupOption(arrayOptions || [], groupBy); | ||
| if (JSON.stringify(newOption) !== JSON.stringify(options)) { | ||
| setOptions(newOption); | ||
| } | ||
| }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]); | ||
|
|
||
| useEffect(() => { | ||
| const doSearch = async () => { | ||
| setIsLoading(true); | ||
| const res = await onSearch?.(debouncedSearchTerm); | ||
| setOptions(transToGroupOption(res || [], groupBy)); | ||
| setIsLoading(false); | ||
| }; | ||
|
|
||
| const exec = async () => { | ||
| if (!onSearch || !open) return; | ||
|
|
||
| if (triggerSearchOnFocus) { | ||
| await doSearch(); | ||
| } | ||
|
|
||
| if (debouncedSearchTerm) { | ||
| await doSearch(); | ||
| } | ||
| }; | ||
|
|
||
| // eslint-disable-next-line no-void | ||
| void exec(); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); | ||
|
|
||
|
|
||
| const EmptyItem = React.useCallback(() => { | ||
| if (!emptyIndicator) return undefined; | ||
|
|
||
| // For async search that showing emptyIndicator | ||
| if (onSearch && !creatable && Object.keys(options).length === 0) { | ||
| return ( | ||
| <CommandItem value="-" disabled> | ||
| {emptyIndicator} | ||
| </CommandItem> | ||
| ); | ||
| } | ||
|
|
||
| return <CommandEmpty>{emptyIndicator}</CommandEmpty>; | ||
| }, [creatable, emptyIndicator, onSearch, options]); | ||
|
|
||
| const selectables = React.useMemo<GroupOption>( | ||
| () => removePickedOption(options, selected), | ||
| [options, selected], | ||
| ); | ||
|
|
||
| /** Avoid Creatable Selector freezing or lagging when paste a long string. */ | ||
| const commandFilter = React.useCallback(() => { | ||
| if (commandProps?.filter) { | ||
| return commandProps.filter; | ||
| } | ||
|
|
||
| if (creatable) { | ||
| return (value: string, search: string) => { | ||
| return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; | ||
| }; | ||
| } | ||
| // Using default filter in `cmdk`. We don't have to provide it. | ||
| return undefined; | ||
| }, [creatable, commandProps?.filter]); | ||
|
|
||
| return ( | ||
| <Command | ||
| {...commandProps} | ||
| onKeyDown={(e) => { | ||
| handleKeyDown(e); | ||
| commandProps?.onKeyDown?.(e); | ||
| }} | ||
| className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)} | ||
| shouldFilter={ | ||
| commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch | ||
| } // When onSearch is provided, we don't want to filter the options. You can still override it. | ||
| filter={commandFilter()} | ||
| > | ||
| <div | ||
| className={cn( | ||
| 'min-h-10 rounded-md border border-input text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2', | ||
| { | ||
| 'px-3 py-2': selected.length !== 0, | ||
| 'cursor-text': !disabled && selected.length !== 0, | ||
| }, | ||
| className, | ||
| )} | ||
| onClick={() => { | ||
| if (disabled) return; | ||
| inputRef.current?.focus(); | ||
| }} | ||
| > | ||
| <div className="flex flex-wrap gap-1"> | ||
| {selected.map((option) => { | ||
| return ( | ||
| <Badge | ||
| key={option.value} | ||
| className={cn( | ||
| 'data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground', | ||
| 'data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground', | ||
| badgeClassName, | ||
| )} | ||
| data-fixed={option.fixed} | ||
| data-disabled={disabled || undefined} | ||
| > | ||
| {option.label} | ||
| <button | ||
| className={cn( | ||
| 'ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2', | ||
| (disabled || option.fixed) && 'hidden', | ||
| )} | ||
| onKeyDown={(e) => { | ||
| if (e.key === 'Enter') { | ||
| handleUnselect(option); | ||
| } | ||
| }} | ||
| onMouseDown={(e) => { | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| }} | ||
| onClick={() => handleUnselect(option)} | ||
| > | ||
| <XMarkIcon className="text-muted-foreground hover:text-foreground size-3" /> | ||
| </button> | ||
| </Badge> | ||
| ); | ||
| })} | ||
| {/* Avoid having the "Search" Icon */} | ||
| <CommandPrimitive.Input | ||
| {...inputProps} | ||
| ref={inputRef} | ||
| value={inputValue} | ||
| disabled={disabled} | ||
| onValueChange={(value) => { | ||
| setInputValue(value); | ||
| inputProps?.onValueChange?.(value); | ||
| }} | ||
| onBlur={(event) => { | ||
| setOpen(false); | ||
| inputProps?.onBlur?.(event); | ||
| }} | ||
| onFocus={(event) => { | ||
| setOpen(true); | ||
| triggerSearchOnFocus && onSearch?.(debouncedSearchTerm); | ||
| inputProps?.onFocus?.(event); | ||
| }} | ||
| placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder} | ||
| className={cn( | ||
| 'flex-1 bg-transparent outline-none placeholder:text-muted-foreground', | ||
| { | ||
| 'w-full': hidePlaceholderWhenSelected, | ||
| 'px-3 py-2': selected.length === 0, | ||
| 'ml-1': selected.length !== 0, | ||
| }, | ||
| inputProps?.className, | ||
| )} | ||
| /> | ||
| </div> | ||
| </div> | ||
| <div className="relative"> | ||
| {open && ( | ||
| <CommandList className="bg-popover text-popover-foreground animate-in absolute top-1 z-10 w-full rounded-md border shadow-md outline-none"> | ||
| {isLoading ? ( | ||
| <>{loadingIndicator}</> | ||
| ) : ( | ||
| <> | ||
| {EmptyItem()} | ||
|
|
||
| {!selectFirstItem && <CommandItem value="-" className="hidden" />} | ||
| {Object.entries(selectables).map(([key, dropdowns]) => ( | ||
| <CommandGroup key={key} heading={key} className="h-full overflow-auto"> | ||
| <> | ||
| {dropdowns.map((option) => { | ||
| return ( | ||
| <CommandItem | ||
| key={option.value} | ||
| value={option.value} | ||
| disabled={option.disable} | ||
| onMouseDown={(e) => { | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| }} | ||
| onSelect={() => { | ||
| if (selected.length >= maxSelected) { | ||
| onMaxSelected?.(selected.length); | ||
| return; | ||
| } | ||
| setInputValue(''); | ||
| const newOptions = [...selected, option]; | ||
| setSelected(newOptions); | ||
| onChange?.(newOptions); | ||
| }} | ||
| className={cn( | ||
| 'cursor-pointer', | ||
| option.disable && 'cursor-default text-muted-foreground', | ||
| )} | ||
| > | ||
| {option.label} | ||
| </CommandItem> | ||
| ); | ||
| })} | ||
| </> | ||
| </CommandGroup> | ||
| ))} | ||
| </> | ||
| )} | ||
| </CommandList> | ||
| )} | ||
| </div> | ||
| </Command> | ||
| ); | ||
| }, | ||
| ); | ||
|
|
||
| MultipleSelector.displayName = 'MultipleSelector'; | ||
| export default MultipleSelector; |
There was a problem hiding this comment.
Optimize state management and performance in MultipleSelector.
The MultipleSelector component is complex and handles multiple states and effects. Consider the following improvements to enhance performance and maintainability:
- Reduce re-renders: Use
React.memooruseMemoto memoize components and functions that do not need to re-render unless specific props or state change. - Optimize context usage: If this component is used frequently or with large data sets, consider using a more efficient state management solution like Redux or Context API with a reducer pattern to manage state more efficiently.
- Error handling in async operations: Ensure that all async operations have proper error handling to prevent UI inconsistencies or crashes.
Here's an example of how you might refactor the async search handling to include error handling:
const doSearch = async () => {
try {
setIsLoading(true);
const res = await onSearch?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
} catch (error) {
console.error('Error performing search:', error);
// Handle errors appropriately here
} finally {
setIsLoading(false);
}
};Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorProps>( | |
| ( | |
| { | |
| value, | |
| onChange, | |
| placeholder, | |
| defaultOptions: arrayDefaultOptions = [], | |
| options: arrayOptions, | |
| delay, | |
| onSearch, | |
| loadingIndicator, | |
| emptyIndicator, | |
| maxSelected = Number.MAX_SAFE_INTEGER, | |
| onMaxSelected, | |
| hidePlaceholderWhenSelected, | |
| disabled, | |
| groupBy, | |
| className, | |
| badgeClassName, | |
| selectFirstItem = true, | |
| creatable = false, | |
| triggerSearchOnFocus = false, | |
| commandProps, | |
| inputProps, | |
| }: MultipleSelectorProps, | |
| ref: React.Ref<MultipleSelectorRef>, | |
| ) => { | |
| const inputRef = React.useRef<HTMLInputElement>(null); | |
| const [open, setOpen] = React.useState(false); | |
| const [isLoading, setIsLoading] = React.useState(false); | |
| const [selected, setSelected] = React.useState<Option[]>(value || []); | |
| const [options, setOptions] = React.useState<GroupOption>( | |
| transToGroupOption(arrayDefaultOptions, groupBy), | |
| ); | |
| const [inputValue, setInputValue] = React.useState(''); | |
| const debouncedSearchTerm = useDebounce(inputValue, delay || 500); | |
| React.useImperativeHandle( | |
| ref, | |
| () => ({ | |
| selectedValue: [...selected], | |
| input: inputRef.current as HTMLInputElement, | |
| focus: () => inputRef.current?.focus(), | |
| }), | |
| [selected], | |
| ); | |
| const handleUnselect = React.useCallback( | |
| (option: Option) => { | |
| const newOptions = selected.filter((s) => s.value !== option.value); | |
| setSelected(newOptions); | |
| onChange?.(newOptions); | |
| }, | |
| [onChange, selected], | |
| ); | |
| const handleKeyDown = React.useCallback( | |
| (e: React.KeyboardEvent<HTMLDivElement>) => { | |
| const input = inputRef.current; | |
| if (input) { | |
| if (e.key === 'Delete' || e.key === 'Backspace') { | |
| if (input.value === '' && selected.length > 0) { | |
| const lastSelectOption = selected[selected.length - 1]; | |
| // If last item is fixed, we should not remove it. | |
| if (!lastSelectOption?.fixed) { | |
| handleUnselect(selected[selected?.length - 1] as Option); | |
| } | |
| } | |
| } | |
| // This is not a default behavior of the <input /> field | |
| if (e.key === 'Escape') { | |
| input.blur(); | |
| } | |
| } | |
| }, | |
| [handleUnselect, selected], | |
| ); | |
| useEffect(() => { | |
| if (value) { | |
| setSelected(value); | |
| } | |
| }, [value]); | |
| useEffect(() => { | |
| /** If `onSearch` is provided, do not trigger options updated. */ | |
| if (!arrayOptions || onSearch) { | |
| return; | |
| } | |
| const newOption = transToGroupOption(arrayOptions || [], groupBy); | |
| if (JSON.stringify(newOption) !== JSON.stringify(options)) { | |
| setOptions(newOption); | |
| } | |
| }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]); | |
| useEffect(() => { | |
| const doSearch = async () => { | |
| setIsLoading(true); | |
| const res = await onSearch?.(debouncedSearchTerm); | |
| setOptions(transToGroupOption(res || [], groupBy)); | |
| setIsLoading(false); | |
| }; | |
| const exec = async () => { | |
| if (!onSearch || !open) return; | |
| if (triggerSearchOnFocus) { | |
| await doSearch(); | |
| } | |
| if (debouncedSearchTerm) { | |
| await doSearch(); | |
| } | |
| }; | |
| // eslint-disable-next-line no-void | |
| void exec(); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); | |
| const EmptyItem = React.useCallback(() => { | |
| if (!emptyIndicator) return undefined; | |
| // For async search that showing emptyIndicator | |
| if (onSearch && !creatable && Object.keys(options).length === 0) { | |
| return ( | |
| <CommandItem value="-" disabled> | |
| {emptyIndicator} | |
| </CommandItem> | |
| ); | |
| } | |
| return <CommandEmpty>{emptyIndicator}</CommandEmpty>; | |
| }, [creatable, emptyIndicator, onSearch, options]); | |
| const selectables = React.useMemo<GroupOption>( | |
| () => removePickedOption(options, selected), | |
| [options, selected], | |
| ); | |
| /** Avoid Creatable Selector freezing or lagging when paste a long string. */ | |
| const commandFilter = React.useCallback(() => { | |
| if (commandProps?.filter) { | |
| return commandProps.filter; | |
| } | |
| if (creatable) { | |
| return (value: string, search: string) => { | |
| return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; | |
| }; | |
| } | |
| // Using default filter in `cmdk`. We don't have to provide it. | |
| return undefined; | |
| }, [creatable, commandProps?.filter]); | |
| return ( | |
| <Command | |
| {...commandProps} | |
| onKeyDown={(e) => { | |
| handleKeyDown(e); | |
| commandProps?.onKeyDown?.(e); | |
| }} | |
| className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)} | |
| shouldFilter={ | |
| commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch | |
| } // When onSearch is provided, we don't want to filter the options. You can still override it. | |
| filter={commandFilter()} | |
| > | |
| <div | |
| className={cn( | |
| 'min-h-10 rounded-md border border-input text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2', | |
| { | |
| 'px-3 py-2': selected.length !== 0, | |
| 'cursor-text': !disabled && selected.length !== 0, | |
| }, | |
| className, | |
| )} | |
| onClick={() => { | |
| if (disabled) return; | |
| inputRef.current?.focus(); | |
| }} | |
| > | |
| <div className="flex flex-wrap gap-1"> | |
| {selected.map((option) => { | |
| return ( | |
| <Badge | |
| key={option.value} | |
| className={cn( | |
| 'data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground', | |
| 'data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground', | |
| badgeClassName, | |
| )} | |
| data-fixed={option.fixed} | |
| data-disabled={disabled || undefined} | |
| > | |
| {option.label} | |
| <button | |
| className={cn( | |
| 'ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2', | |
| (disabled || option.fixed) && 'hidden', | |
| )} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter') { | |
| handleUnselect(option); | |
| } | |
| }} | |
| onMouseDown={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| }} | |
| onClick={() => handleUnselect(option)} | |
| > | |
| <XMarkIcon className="text-muted-foreground hover:text-foreground size-3" /> | |
| </button> | |
| </Badge> | |
| ); | |
| })} | |
| {/* Avoid having the "Search" Icon */} | |
| <CommandPrimitive.Input | |
| {...inputProps} | |
| ref={inputRef} | |
| value={inputValue} | |
| disabled={disabled} | |
| onValueChange={(value) => { | |
| setInputValue(value); | |
| inputProps?.onValueChange?.(value); | |
| }} | |
| onBlur={(event) => { | |
| setOpen(false); | |
| inputProps?.onBlur?.(event); | |
| }} | |
| onFocus={(event) => { | |
| setOpen(true); | |
| triggerSearchOnFocus && onSearch?.(debouncedSearchTerm); | |
| inputProps?.onFocus?.(event); | |
| }} | |
| placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder} | |
| className={cn( | |
| 'flex-1 bg-transparent outline-none placeholder:text-muted-foreground', | |
| { | |
| 'w-full': hidePlaceholderWhenSelected, | |
| 'px-3 py-2': selected.length === 0, | |
| 'ml-1': selected.length !== 0, | |
| }, | |
| inputProps?.className, | |
| )} | |
| /> | |
| </div> | |
| </div> | |
| <div className="relative"> | |
| {open && ( | |
| <CommandList className="bg-popover text-popover-foreground animate-in absolute top-1 z-10 w-full rounded-md border shadow-md outline-none"> | |
| {isLoading ? ( | |
| <>{loadingIndicator}</> | |
| ) : ( | |
| <> | |
| {EmptyItem()} | |
| {!selectFirstItem && <CommandItem value="-" className="hidden" />} | |
| {Object.entries(selectables).map(([key, dropdowns]) => ( | |
| <CommandGroup key={key} heading={key} className="h-full overflow-auto"> | |
| <> | |
| {dropdowns.map((option) => { | |
| return ( | |
| <CommandItem | |
| key={option.value} | |
| value={option.value} | |
| disabled={option.disable} | |
| onMouseDown={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| }} | |
| onSelect={() => { | |
| if (selected.length >= maxSelected) { | |
| onMaxSelected?.(selected.length); | |
| return; | |
| } | |
| setInputValue(''); | |
| const newOptions = [...selected, option]; | |
| setSelected(newOptions); | |
| onChange?.(newOptions); | |
| }} | |
| className={cn( | |
| 'cursor-pointer', | |
| option.disable && 'cursor-default text-muted-foreground', | |
| )} | |
| > | |
| {option.label} | |
| </CommandItem> | |
| ); | |
| })} | |
| </> | |
| </CommandGroup> | |
| ))} | |
| </> | |
| )} | |
| </CommandList> | |
| )} | |
| </div> | |
| </Command> | |
| ); | |
| }, | |
| ); | |
| MultipleSelector.displayName = 'MultipleSelector'; | |
| export default MultipleSelector; | |
| const doSearch = async () => { | |
| try { | |
| setIsLoading(true); | |
| const res = await onSearch?.(debouncedSearchTerm); | |
| setOptions(transToGroupOption(res || [], groupBy)); | |
| } catch (error) { | |
| console.error('Error performing search:', error); | |
| // Handle errors appropriately here | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; |
Summary by CodeRabbit
New Features
LocationInputcomponent for selecting geographical locations in service listings.MultipleSelectorcomponent for selecting multiple options from a dropdown.Badgecomponent for UI consistency.CalendarUIcomponent for displaying a monthly calendar with meeting schedules.FrontPagecomponent for the landing page.Bug Fixes
Chores