Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DropdownItem does not re-render to reflect updated language changes #3003

Open
YinanLew opened this issue May 15, 2024 · 6 comments
Open

DropdownItem does not re-render to reflect updated language changes #3003

YinanLew opened this issue May 15, 2024 · 6 comments

Comments

@YinanLew
Copy link

YinanLew commented May 15, 2024

NextUI Version

@nextui-org/theme: 2.1.17

Describe the bug

Description
When switching languages, the text within DropdownItem components does not update or re-render to reflect the new language selections. This issue occurs consistently across various implementations where dynamic language switching is needed.

Version used:
"@nextui-org/button": "2.0.26",
"@nextui-org/code": "2.0.24",
"@nextui-org/input": "2.1.16",
"@nextui-org/kbd": "2.0.25",
"@nextui-org/link": "2.0.26",
"@nextui-org/navbar": "2.0.27",
"@nextui-org/react": "^2.2.9",
"@nextui-org/snippet": "2.0.30",
"@nextui-org/switch": "2.0.25",
"@nextui-org/system": "2.0.15",
"@nextui-org/theme": "2.1.17",
"@react-aria/ssr": "^3.8.0",
"@react-aria/visually-hidden": "^3.8.6",
"@types/node": "20.5.7",
"@types/react": "18.2.21",
"@types/react-dom": "18.2.7",
"autoprefixer": "10.4.16",
"clsx": "^2.0.0",
"eslint": "8.48.0",
"eslint-config-next": "14.0.2",
"framer-motion": "^10.18.0",
"intl-messageformat": "^10.5.0",
"next": "14.0.2",
"next-auth": "^4.24.5",
"next-themes": "^0.2.1",
"postcss": "8.4.31",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-icons": "^5.1.0",
"react-share": "^5.1.0",
"tailwind-variants": "^0.1.18",
"tailwindcss": "3.3.5",
"typescript": "5.0.4"

Your Example Website or App

No response

Steps to Reproduce the Bug or Issue

Create a Dropdown with multiple DropdownItem components, where the text is determined by a language state or context.
Change the language (e.g., from English to Spanish).
Observe that the DropdownItem texts do not update to the new language.

Expected behavior

The DropdownItem components should update and re-render with the new language texts when the language context or state changes.

Screenshots or Videos

"use client";
import React, { useEffect, useState } from "react";
import { useLanguage } from "@/utils/languageContext";
import { AuthRequiredError, DataFetchFailedError } from "@/lib/exceptions";

export default function UsersTableTemp({
  applications,
  onRemoveApplication,
  onIssueCertificate,
  onRejectCertificate,
}: UsersTableTempProps) {
 
  function getDropdownItems(application: FlattenedApplication) {

    const items = [];


    if (session && session.user.role === "admin") {
      items.push(
        <DropdownItem key="edit" textValue="edit">
          <Link
            className="w-full flex flex-row justify-center text-sm text-foreground"
            href={`/applications/${application.eventId}/${application.eventUniqueId}/edit`}
          >
          {translations.strings.edit}
          </Link>
        </DropdownItem>,
        <DropdownItem
          key="issueCertificate"
          className="text-center text-sm text-foreground"
          textValue="issue"
          onClick={() =>
            onIssueCertificate(application.eventId, application.userId, token)
          }
        >
           {translations.strings.approved}
        </DropdownItem>,
        <DropdownItem
          key="rejectCertificate"
          className="text-center text-sm text-foreground"
          textValue="reject"
          onClick={() =>
            onRejectCertificate(application.eventId, application.userId, token)
          }
        >
         {translations.strings.rejected}
        </DropdownItem>,
        <DropdownItem
          key="delete"
          color="danger"
          className="text-center text-sm text-foreground"
          textValue="delete"
          onClick={() => {
            setCurrentEventId(application.eventId);
            setCurrentUniqueId(application.eventUniqueId);
            onOpen();
          }}
        >
          Delete
        </DropdownItem>
      );
    }

    return items;
  }

 const renderCell = React.useCallback(
    (application: FlattenedApplication, columnKey: React.Key) => {
      const cellValue = application[columnKey as keyof FlattenedApplication];

      switch (columnKey) {
        case "eventTitle":
          return <p>{application.eventTitle}</p>;
        case "firstName":
          return <p>{application.firstName}</p>;
        case "lastName":
          return <div className="flex flex-col">{application.lastName}</div>;
        case "address":
          return <div className="flex flex-col">{application.address}</div>;
        case "phoneNumber":
          return <div className="flex flex-col">{application.phoneNumber}</div>;
        case "email":
          return <div className="flex flex-col">{application.email}</div>;
        case "createdAt":
          return (
            <div className="flex flex-col">
              {formatDate(application.createdAt)}
            </div>
          );
        case "certificateStatus":
          return (
            <Chip
              className="capitalize"
              color={statusColorMap[application.certificateStatus]}
              size="sm"
              variant="flat"
            >
              {cellValue}
            </Chip>
          );
        case "status":
          return (
            <Chip
              className="capitalize"
              color={statusColorMap[application.status]}
              size="sm"
              variant="flat"
            >
              {cellValue}
            </Chip>
          );
        case "actions":
          return (
            <div className="relative flex justify-center items-center gap-2">
              <Dropdown>
                <DropdownTrigger>
                  <Button
                    aria-label="Actions"
                    isIconOnly
                    size="sm"
                    variant="light"
                  >
                    <VerticalDotsIcon className="text-default-300" />
                  </Button>
                </DropdownTrigger>
                <DropdownMenu aria-label="Action Items" items={applications}>
                  {getDropdownItems(application)}
                </DropdownMenu>
              </Dropdown>
            </div>
          );
        default:
          return cellValue;
      }
    },
    [translations, session, applications]
  );

Operating System Version

macOS

Browser

Chrome

Copy link

linear bot commented May 15, 2024

@wingkwong
Copy link
Member

Please share a minimal reproducible project on stackblitz. It's hard to investigate just based on the above code. Not sure what lib you use for the lang logic, in general I think you should use a translation hook directly instead.

@YinanLew
Copy link
Author

I'm using context and hooks for handling language changes. However, the text in the action dropdown items in a table does not re-render immediately when the language is changed. It only updates when navigating to a different page.

LanguageProvider:

`"use client";
import React, { createContext, useState, useContext, useEffect } from "react";
import { Translations } from "@/types";
import enTranslations from "@/lang/en.json";
import otTranslations from "@/lang/ot.json";

type LanguageContextType = {
language: string;
setLanguage: React.Dispatch<React.SetStateAction>;
translations: Translations;
loadTranslations: (lang: string) => void;
};

const LanguageContext = createContext<LanguageContextType | undefined>(
undefined
);

export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [language, setLanguage] = useState("en");
const [translations, setTranslations] =
useState(enTranslations);

const loadTranslations = (lang: string) => {
switch (lang) {
case "en":
setTranslations(enTranslations);
break;
case "ot":
setTranslations(otTranslations);
break;
default:
setTranslations(enTranslations);
break;
}
};

useEffect(() => {
loadTranslations(language);
}, [language]);

return (
<LanguageContext.Provider
value={{ language, setLanguage, translations, loadTranslations }}
>
{children}
</LanguageContext.Provider>
);
};

export const useLanguage = (): LanguageContextType => {
const context = useContext(LanguageContext);
if (!context) {
throw new Error("useLanguage must be used within a LanguageProvider");
}
return context;
};
`

LanguageSwitcher:

`import React from "react";
import { useLanguage } from "../utils/languageContext";
import {
Dropdown,
DropdownTrigger,
DropdownMenu,
DropdownItem,
Button,
} from "@nextui-org/react";
import { FaLanguage } from "react-icons/fa";

const LanguageSwitcher: React.FC = () => {
const { setLanguage } = useLanguage();
const handleChangeLanguage = (newLanguage: string) => {
setLanguage(newLanguage);
};

return (


<Button
className="bg-transparent hover:bg-transparent border-none"
size="sm"
endContent={}
>


<DropdownItem key="en" onClick={() => handleChangeLanguage("en")}>
English

<DropdownItem key="ot" onClick={() => handleChangeLanguage("ot")}>
other languages



);
};`

@YinanLew
Copy link
Author

`"use client";
import React, { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import {
FlattenedApplication,
TableColumnTy,
UsersTableTempProps,
} from "@/types";
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
Input,
Button,
DropdownTrigger,
Dropdown,
DropdownMenu,
DropdownItem,
Chip,
Pagination,
Selection,
ChipProps,
SortDescriptor,
Select,
SelectItem,
Link,
} from "@nextui-org/react";
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
} from "@nextui-org/react";
import { PlusIcon } from "./plusIcon";
import { VerticalDotsIcon } from "./verticalDotsIcon";
import { ChevronDownIcon } from "./chevronDownIcon";
import { SearchIcon } from "./searchIcon";
import { capitalize } from "./utils";
import { formatDate } from "@/utils/formatDate";
import { useLanguage } from "@/utils/languageContext";
import { AuthRequiredError, DataFetchFailedError } from "@/lib/exceptions";

export default function UsersTableTemp({
applications,
onRemoveApplication,
onIssueCertificate,
onRejectCertificate,
}: UsersTableTempProps) {
const { data: session, status } = useSession();
const { isOpen, onOpen, onClose } = useDisclosure();
const token = session?.user.token;

const { translations } = useLanguage();
const [columns, setColumns] = useState<TableColumnTy[]>([
{
name: ${translations.strings.event},
uid: "eventTitle",
sortable: true,
},
{
name: ${translations.strings.firstName},
uid: "firstName",
sortable: true,
},
// { name: "Description", uid: "description", sortable: true },
{
name: ${translations.strings.lastName},
uid: "lastName",
sortable: true,
},
{
name: ${translations.strings.location},
uid: "address",
sortable: true,
},
{
name: ${translations.strings.phoneNumber},
uid: "phoneNumber",
sortable: true,
},
{ name: ${translations.strings.email}, uid: "email", sortable: true },
{
name: ${translations.strings.appCreatedAt},
uid: "createdAt",
sortable: true,
},
{
name: ${translations.strings.spokenLanguage},
uid: "spokenLanguage",
sortable: true,
},
{
name: ${translations.strings.writtenLanguage},
uid: "writtenLanguage",
sortable: true,
},
{ name: ${translations.strings.status}, uid: "status", sortable: true },

{
  name: `${translations.strings.certificateStatus}`,
  uid: "certificateStatus",
  sortable: true,
},
{
  name: `${translations.strings.actions}`,
  uid: "actions",
  sortable: false,
},

]);

useEffect(() => {
setColumns([
{
name: ${translations.strings.event},
uid: "eventTitle",
sortable: true,
},
{
name: ${translations.strings.firstName},
uid: "firstName",
sortable: true,
},
// { name: "Description", uid: "description", sortable: true },
{
name: ${translations.strings.lastName},
uid: "lastName",
sortable: true,
},
{
name: ${translations.strings.location},
uid: "address",
sortable: true,
},
{
name: ${translations.strings.phoneNumber},
uid: "phoneNumber",
sortable: true,
},
{ name: ${translations.strings.email}, uid: "email", sortable: true },
{
name: ${translations.strings.appCreatedAt},
uid: "createdAt",
sortable: true,
},
{
name: ${translations.strings.spokenLanguage},
uid: "spokenLanguage",
sortable: true,
},
{
name: ${translations.strings.writtenLanguage},
uid: "writtenLanguage",
sortable: true,
},
{ name: ${translations.strings.status}, uid: "status", sortable: true },

  {
    name: `${translations.strings.certificateStatus}`,
    uid: "certificateStatus",
    sortable: true,
  },
  {
    name: `${translations.strings.actions}`,
    uid: "actions",
    sortable: false,
  },
]);

}, [translations]);

const statusOptions = [
{ name: ${translations.strings.verified}, uid: "verified" },
{ name: ${translations.strings.pending}, uid: "pending" },
{ name: ${translations.strings.rejected}, uid: "rejected" },
// { name: "Not Submitted", uid: "notSubmitted" },
// { name: "Submitted", uid: "submitted" },
// { name: "Approved", uid: "approved" },
// { name: "Rejected", uid: "rejected" },
// Add other statuses as needed
];

const statusColorMap: Record<string, ChipProps["color"]> = {
verified: "success",
pending: "warning",
rejected: "danger",
notSubmitted: "default",
submitted: "warning",
approved: "success",
};

const INITIAL_VISIBLE_COLUMNS = [
"eventTitle",
"firstName",
"lastName",
"address",
"phoneNumber",
"email",
"createdAt",
"spokenLanguage",
"writtenLanguage",
"status",
"certificateStatus",
"actions",
];

const [filterValue, setFilterValue] = useState("");
const [selectedKeys, setSelectedKeys] = useState(new Set([]));
const [visibleColumns, setVisibleColumns] = useState(
new Set(INITIAL_VISIBLE_COLUMNS)
);
const [statusFilter, setStatusFilter] = useState("all");
const [rowsPerPage, setRowsPerPage] = useState(5);
const [sortDescriptor, setSortDescriptor] = useState({
column: "releaseDate",
direction: "ascending",
});
const [page, setPage] = useState(1);
const [currentEventId, setCurrentEventId] = useState("");
const [currentUniqueId, setCurrentUniqueId] = useState("");

useEffect(() => {
const idsAreUnique =
new Set(applications.map((app) => app.eventUniqueId)).size ===
applications.length;
console.assert(idsAreUnique, "IDs are not unique", applications);
}, [applications]);

const hasSearchFilter = Boolean(filterValue);

const headerColumns = React.useMemo(() => {
if (visibleColumns === "all") return columns;

return columns.filter((column) =>
  Array.from(visibleColumns).includes(column.uid)
);

}, [columns, visibleColumns, translations]);

const filteredItems = React.useMemo(() => {
let filteredUsers = [...applications];

if (hasSearchFilter) {
  filteredUsers = filteredUsers.filter((user) =>
    user.firstName.toLowerCase().includes(filterValue.toLowerCase())
  );
}
if (
  statusFilter !== "all" &&
  Array.from(statusFilter).length !== statusOptions.length
) {
  filteredUsers = filteredUsers.filter((user) =>
    Array.from(statusFilter).includes(user.status)
  );
}

return filteredUsers;

}, [applications, filterValue, statusFilter]);

const pages = Math.ceil(filteredItems.length / rowsPerPage);

const items = React.useMemo(() => {
const start = (page - 1) * rowsPerPage;
const end = start + rowsPerPage;

return filteredItems.slice(start, end);

}, [page, filteredItems, rowsPerPage]);

const sortedItems = React.useMemo(() => {
return [...items].sort((a, b) => {
if (sortDescriptor.column === "createdAt") {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
const cmp = dateA < dateB ? -1 : dateA > dateB ? 1 : 0;
return sortDescriptor.direction === "descending" ? -cmp : cmp;
} else {
const first = a[sortDescriptor.column as keyof FlattenedApplication];
const second = b[sortDescriptor.column as keyof FlattenedApplication];
const cmp = first < second ? -1 : first > second ? 1 : 0;
return sortDescriptor.direction === "descending" ? -cmp : cmp;
}
});
}, [sortDescriptor, items]);

const handleDeleteEvent = async (eventId: string, eventObjectId: string) => {
if (!session) {
throw new AuthRequiredError();
}
console.log(eventId, eventObjectId);

try {
  const response = await fetch(
    `http://localhost:8500/application/delete/${eventId}/${eventObjectId}`,
    {
      method: "DELETE",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${session.user.token}`,
      },
    }
  );

  if (!response.ok) {
    throw new Error("Failed to delete the application event");
  }

  onRemoveApplication(eventObjectId);
  onClose();
} catch (error) {
  console.error("Error deleting application event:", error);
}

};

function getDropdownItems(application: FlattenedApplication) {
// Always include these items
const items = [];

// Add additional items for admin users
if (session && session.user.role === "admin") {
  items.push(
    <DropdownItem key="edit" textValue="edit">
      <Link
        className="w-full flex flex-row justify-center text-sm text-foreground"
        href={`/applications/${application.eventId}/${application.eventUniqueId}/edit`}
      >
        Edit
      </Link>
    </DropdownItem>,
    <DropdownItem
      key="issueCertificate"
      className="text-center text-sm text-foreground"
      textValue="issue"
      onClick={() =>
        onIssueCertificate(application.eventId, application.userId, token)
      }
    >
      Issue Certificate
    </DropdownItem>,
    <DropdownItem
      key="rejectCertificate"
      className="text-center text-sm text-foreground"
      textValue="reject"
      onClick={() =>
        onRejectCertificate(application.eventId, application.userId, token)
      }
    >
      Reject Certificate
    </DropdownItem>,
    <DropdownItem
      key="delete"
      color="danger"
      className="text-center text-sm text-foreground"
      textValue="delete"
      onClick={() => {
        setCurrentEventId(application.eventId);
        setCurrentUniqueId(application.eventUniqueId);
        onOpen();
      }}
    >
      Delete
    </DropdownItem>
  );
}

return items;

}

const renderCell = React.useCallback(
(application: FlattenedApplication, columnKey: React.Key) => {
const cellValue = application[columnKey as keyof FlattenedApplication];

  switch (columnKey) {
    case "eventTitle":
      return <p>{application.eventTitle}</p>;
    case "firstName":
      return <p>{application.firstName}</p>;
    case "lastName":
      return <div className="flex flex-col">{application.lastName}</div>;
    case "address":
      return <div className="flex flex-col">{application.address}</div>;
    case "phoneNumber":
      return <div className="flex flex-col">{application.phoneNumber}</div>;
    case "email":
      return <div className="flex flex-col">{application.email}</div>;
    case "createdAt":
      return (
        <div className="flex flex-col">
          {formatDate(application.createdAt)}
        </div>
      );
    case "certificateStatus":
      return (
        <Chip
          className="capitalize"
          color={statusColorMap[application.certificateStatus]}
          size="sm"
          variant="flat"
        >
          {cellValue}
        </Chip>
      );
    case "status":
      return (
        <Chip
          className="capitalize"
          color={statusColorMap[application.status]}
          size="sm"
          variant="flat"
        >
          {cellValue}
        </Chip>
      );
    case "actions":
      return (
        <div className="relative flex justify-center items-center gap-2">
          <Dropdown>
            <DropdownTrigger>
              <Button
                aria-label="Actions"
                isIconOnly
                size="sm"
                variant="light"
              >
                <VerticalDotsIcon className="text-default-300" />
              </Button>
            </DropdownTrigger>
            <DropdownMenu aria-label="Action Items" items={applications}>
              {getDropdownItems(application)}
            </DropdownMenu>
          </Dropdown>
        </div>
      );
    default:
      return cellValue;
  }
},
[session, applications]

);

const onNextPage = React.useCallback(() => {
if (page < pages) {
setPage(page + 1);
}
}, [page, pages]);

const onPreviousPage = React.useCallback(() => {
if (page > 1) {
setPage(page - 1);
}
}, [page]);

const onRowsPerPageChange = React.useCallback(
(e: React.ChangeEvent) => {
setRowsPerPage(Number(e.target.value));
setPage(1);
},
[]
);

const onSearchChange = React.useCallback((value?: string) => {
if (value) {
setFilterValue(value);
setPage(1);
} else {
setFilterValue("");
}
}, []);

const onClear = React.useCallback(() => {
setFilterValue("");
setPage(1);
}, []);

const topContent = React.useMemo(() => {
const dropdownColumns = columns;
// session && session.user.role === "admin"
// ? columns
// : columns.filter((column) => column.name !== "Actions");
return (



<Input
isClearable
className="w-full sm:max-w-[44%]"
placeholder={translations.strings.search}
startContent={}
value={filterValue}
onClear={() => onClear()}
onValueChange={onSearchChange}
/>



<Button
endContent={}
variant="flat"
>
{translations.strings.status}



{statusOptions.map((status) => (

{capitalize(status.name)}

))}




<Button
endContent={}
variant="flat"
>
{translations.strings.columns}



{dropdownColumns.map((column) => (

{capitalize(column.name)}

))}


{/* {session && session.user.role === "admin" && (
<Button
as={"a"}
href="/add-application"
color="primary"
endContent={}
>
Add New

)} */}




{translations.strings.total} {applications.length}{" "}
{translations.strings.event}

5 10 15


);
}, [
filterValue,
statusFilter,
visibleColumns,
onSearchChange,
onRowsPerPageChange,
applications.length,
hasSearchFilter,
session,
columns,
]);

const bottomContent = React.useMemo(() => {
return (



{/* {selectedKeys === "all"
? "All items selected"
: ${selectedKeys.size} of ${filteredItems.length} selected} */}



<Button
isDisabled={pages === 1}
size="sm"
variant="flat"
onPress={onPreviousPage}
>
{translations.strings.previous}

<Button
isDisabled={pages === 1}
size="sm"
variant="flat"
onPress={onNextPage}
>
{translations.strings.next}



);
}, [selectedKeys, items.length, page, pages, hasSearchFilter, columns]);

if (status === "loading") {
return

Loading...
; // or any other loading indicator
}

return (
<>
<Table
aria-label="Example table with custom cells, pagination and sorting"
isHeaderSticky
bottomContent={bottomContent}
bottomContentPlacement="outside"
classNames={{
wrapper: "max-h-[382px] sm:max-h-full w-full",
}}
selectedKeys={selectedKeys}
// selectionMode="multiple"
sortDescriptor={sortDescriptor}
topContent={topContent}
topContentPlacement="outside"
onSelectionChange={setSelectedKeys}
onSortChange={setSortDescriptor}
>

{(column) => (
<TableColumn
className="text-center"
key={column.uid}
align={"center"}
allowsSorting={column.sortable}
>
{column.name}

)}

<TableBody emptyContent={"No applications found"} items={sortedItems}>
{(item) => (

{(columnKey) => (
{renderCell(item, columnKey)}
)}

)}




{translations.strings.delConfirm}
{translations.strings.delConfirmQueApp}

<Button
color="danger"
onClick={() => handleDeleteEvent(currentEventId, currentUniqueId)}
>
{translations.strings.delete}

{translations.strings.cancel}



</>
);
}
`

I pass a function, {getDropdownItems(application)}, to obtain each dropdown item. Subsequently, I assigned getDropdownItems to a state variable and then used useEffect to invoke it, ensuring that the language within getDropdownItems changes along with the language switcher.

Here's a closer look at the implementation:

State Initialization: The state for the dropdown items is initialized based on the return value of getDropdownItems.

useEffect Hook: This hook is triggered on language change and calls getDropdownItems to update the dropdown items state with the new language settings.

Despite this setup, the items inside the under actions do not re-render when the language is switched. I'm puzzled about why the dropdown items are not responding to the language change, considering the state update mechanism and component re-render should be triggered by the useEffect hook or the dependency translations in "renderCell useCallback function".

@YinanLew
Copy link
Author

Please share a minimal reproducible project on stackblitz. It's hard to investigate just based on the above code. Not sure what lib you use for the lang logic, in general I think you should use a translation hook directly instead.

Link:
https://stackblitz.com/edit/nextjs-8iwrsw?file=app%2FusersTable.tsx

@wingkwong
Copy link
Member

The stackblitz environment is not running. Please double check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants