diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..0404c10 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - name: Install dependencies + run: npm install -g pnpm && pnpm install + - name: Install Playwright Browsers + run: pnpm exec playwright install --with-deps + - name: Run Playwright tests + run: pnpm exec playwright test + - uses: actions/upload-artifact@v3 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index d1595af..9cda64e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ yarn-error.log* # vercel .vercel +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/apps/web/app/(root)/admin/group/[id]/page.tsx b/apps/web/app/(root)/admin/group/[id]/page.tsx index 8e1c35b..de5955f 100644 --- a/apps/web/app/(root)/admin/group/[id]/page.tsx +++ b/apps/web/app/(root)/admin/group/[id]/page.tsx @@ -1,6 +1,6 @@ import GroupBody from "@/components/admin/groupBody/organisms/group-body"; -export default function GroupPage(): JSX.Element { +export default function GroupPage(): JSX.Element { return (
diff --git a/apps/web/app/(root)/admin/layout.tsx b/apps/web/app/(root)/admin/layout.tsx index b274989..682ff25 100644 --- a/apps/web/app/(root)/admin/layout.tsx +++ b/apps/web/app/(root)/admin/layout.tsx @@ -25,8 +25,8 @@ export default async function AdminRootLayout({children}: {children: React.React redirect("/api/auth/login"); } - const allUsersGroupId = 1 - const initialGroups: Group[] = sortGroups((await getAllGroups()).data || [], allUsersGroupId) + const allWizelinersGroupId = 1 + const initialGroups: Group[] = sortGroups((await getAllGroups()).data || [], allWizelinersGroupId) return ( diff --git a/apps/web/app/api/ai/openai/route.ts b/apps/web/app/api/ai/openai/route.ts index bdb2843..df11fc5 100644 --- a/apps/web/app/api/ai/openai/route.ts +++ b/apps/web/app/api/ai/openai/route.ts @@ -103,7 +103,8 @@ export async function POST( return new StreamingTextResponse(stream, { status: 200 }); } catch (error: any) { // If an error occurs, log it to the console and send a message to the user - // console.error(error); + console.log(error.message) + console.error(error); return new NextResponse(`Error: ${error.message}`, { status: 500 }); } } diff --git a/apps/web/components/admin/editGroup/molcules/edit-group-menu-modal.tsx b/apps/web/components/admin/editGroup/molcules/edit-group-menu-modal.tsx index 49b603d..80afd65 100644 --- a/apps/web/components/admin/editGroup/molcules/edit-group-menu-modal.tsx +++ b/apps/web/components/admin/editGroup/molcules/edit-group-menu-modal.tsx @@ -1,14 +1,16 @@ import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from "@nextui-org/react"; -import { useEffect, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import type { Group } from "@prisma/client"; import { toast } from "sonner"; import { AiFillDelete } from "react-icons/ai" -import { groupsAreEqual, isValidGroup } from "@/helpers/group-helpers"; +import { GroupsActionType, groupsAreEqual, isValidGroup } from "@/helpers/group-helpers"; import ConfirmDeleteModal from "@/components/shared/molecules/confirm-delete-modal"; +import { GroupsContext, type GroupsContextShape } from "@/context/groups-context"; import NewGroupMenu from "./edit-group-menu"; interface EditGroupMenuModalProps { isNew: boolean; + allowElimination: boolean; initialGroup: Group; isOpen: boolean; onGroupSave: (savedGroup: Group) => void; @@ -16,12 +18,13 @@ interface EditGroupMenuModalProps { onModalClose: () => void; } -export default function EditGroupMenuModal({isNew, initialGroup, isOpen, onGroupSave, onGroupDeletion, onModalClose}: EditGroupMenuModalProps): JSX.Element { +export default function EditGroupMenuModal({isNew, allowElimination, initialGroup, isOpen, onGroupSave, onGroupDeletion, onModalClose}: EditGroupMenuModalProps): JSX.Element { const [group, setGroup] = useState(initialGroup) const [isLoading, setIsLoading] = useState(false) const [confirmDeleteModalIsOpen, setConfirmDeleteModalIsOpen] = useState(false) const modalHorizontalPadding = 5 const saveIsDisabled = !isValidGroup(group) || groupsAreEqual(initialGroup, group) + const groupsContext = useContext(GroupsContext) useEffect(() => { if (!isOpen){ @@ -41,7 +44,7 @@ export default function EditGroupMenuModal({isNew, initialGroup, isOpen, onGroup } const handleDeleteButtonPress: (e: any) => void = (_) => { - if (onGroupDeletion && !isNew){ + if (onGroupDeletion && !isNew && allowElimination){ onModalClose() setConfirmDeleteModalIsOpen(true) } @@ -115,6 +118,9 @@ export default function EditGroupMenuModal({isNew, initialGroup, isOpen, onGroup if (!response.ok){ throw new Error("Network response was not ok") } + groupsContext?.groupsDispatch({ + type: GroupsActionType.Delete, + }) return response.json() }) .then((deletedGroup) => { @@ -142,7 +148,7 @@ export default function EditGroupMenuModal({isNew, initialGroup, isOpen, onGroup

{isNew ? "New group" : "Edit group"}

- {!isNew ? + {!isNew && allowElimination ? diff --git a/apps/web/components/admin/groupBody/molecules/group-header.tsx b/apps/web/components/admin/groupBody/molecules/group-header.tsx index a3e6d34..74fa105 100644 --- a/apps/web/components/admin/groupBody/molecules/group-header.tsx +++ b/apps/web/components/admin/groupBody/molecules/group-header.tsx @@ -18,7 +18,7 @@ interface GroupHeaderProps { export function GroupHeader({ groupData, onGroupsSettingsPress, - setUpdatedUsers + setUpdatedUsers, }: GroupHeaderProps): JSX.Element { const [creditsModalIsOpen, setCreditsModalIsOpen] = useState(false) const [descriptionModalIsOpen, setDescriptionModalIsOpen] = useState(false) @@ -44,12 +44,13 @@ export function GroupHeader({ } return ( -
+
{/* Adjust the order of items for XS screens */} -
+
{/* Placeholder for credits information below the group name */} diff --git a/apps/web/components/admin/groupBody/molecules/group-table.tsx b/apps/web/components/admin/groupBody/molecules/group-table.tsx index e7fbf97..85880c9 100644 --- a/apps/web/components/admin/groupBody/molecules/group-table.tsx +++ b/apps/web/components/admin/groupBody/molecules/group-table.tsx @@ -36,6 +36,7 @@ interface GroupTableProps { setUpdatedUsers: any; idGroup: number; users: User[]; + allowMembersEditing: boolean; } //columns @@ -70,6 +71,7 @@ export default function GroupTable({ setUpdatedUsers, users, idGroup, + allowMembersEditing }: GroupTableProps): JSX.Element { const { isOpen, onOpen, onOpenChange } = useDisclosure(); @@ -158,7 +160,8 @@ export default function GroupTable({ if (hasSearchFilter) { filteredUsers = filteredUsers.filter((user) => - user.name.toLowerCase().includes(filterValue.toLowerCase()) + user.name.toLowerCase().includes(filterValue.toLowerCase()) || + user.email.toLowerCase().includes(filterValue.toLowerCase()) ); } if ( @@ -321,6 +324,29 @@ export default function GroupTable({ }, []); const topContent = useMemo(() => { + const membersEditingButton: JSX.Element = selectedKeys instanceof Set && selectedKeys.size > 0 ? ( + + ) : ( + + ) + return (
@@ -331,7 +357,7 @@ export default function GroupTable({ onClear(); }} onValueChange={onSearchChange} - placeholder="Search by name..." + placeholder="Search by name or email..." size="sm" startContent={} value={filterValue} @@ -387,28 +413,11 @@ export default function GroupTable({ ))} - {selectedKeys instanceof Set && selectedKeys.size > 0 ? ( - - ) : ( - - )} + + {/*If the editing of the group's members is possible, show corresponding button*/} + {allowMembersEditing ? + membersEditingButton + : null}
@@ -494,13 +503,13 @@ export default function GroupTable({ }, }} classNames={{ - wrapper: "max-h-[382px] shadow-none p-4", + wrapper: "shadow-none p-4 overflow-y-scroll h-[calc(100vh-20rem)]", }} isHeaderSticky onSelectionChange={setSelectedKeys as any} onSortChange={setSortDescriptor as any} selectedKeys={selectedKeys} - selectionMode="multiple" + selectionMode={allowMembersEditing ? "multiple" : "single"} sortDescriptor={sortDescriptor} topContent={topContent} topContentPlacement="outside" @@ -516,7 +525,7 @@ export default function GroupTable({ )} - + {(item) => ( {(columnKey) => ( diff --git a/apps/web/components/admin/groupBody/organisms/group-body.tsx b/apps/web/components/admin/groupBody/organisms/group-body.tsx index 8d357ec..82a54de 100644 --- a/apps/web/components/admin/groupBody/organisms/group-body.tsx +++ b/apps/web/components/admin/groupBody/organisms/group-body.tsx @@ -1,7 +1,7 @@ "use client"; // Import necessary hooks and utilities from React and Next.js import { useContext, useEffect, useState } from "react"; -import { useParams } from "next/navigation"; +import { useParams , useRouter } from "next/navigation"; import type { Group } from "@prisma/client"; import { Spinner } from "@nextui-org/react"; import { toast } from "sonner"; @@ -20,13 +20,14 @@ export default function GroupBody(): JSX.Element { const sidebarGroupsContext = useContext( GroupsContext ); - const [editGroupModalIsOpen, setEditGroupModalIsOpen] = - useState(false); + const [editGroupModalIsOpen, setEditGroupModalIsOpen] = useState(false); + const router = useRouter(); const params = useParams(); const idGroup = Number(params.id); + const allowGroupEditing: boolean = idGroup !== 1 // 1: the id of the 'All Wizeliners' group. // State for storing group data - const [groupData, setGroupData] = useState(null); + const [groupData, setGroupData] = useState(placeHolderGroupData()); // State for tracking loading status const [loading, setLoading] = useState(true); @@ -80,9 +81,10 @@ export default function GroupBody(): JSX.Element { setGroupData(data); console.log(data); } catch (err: any) { + router.push("/admin/group/1") // Redirect to 'All Wizeliners' if group's data can't be fetched. // setError(err.message); console.log(err); - toast.error("Failed getting group data"); + toast.error(`Failed in fetching the data of group ${idGroup}.`); } finally { setLoading(false); } @@ -106,7 +108,7 @@ export default function GroupBody(): JSX.Element { // if (error) return
Error: {error}
; return ( -
+
{/* Group Header */} {/* Group Table */} void; } export default function GroupCard({ + isAllUsersGroup, group, isSelected, onPress, @@ -34,13 +37,14 @@ export default function GroupCard({ - + +
)} diff --git a/apps/web/components/shared/molecules/general.tsx b/apps/web/components/shared/molecules/general.tsx index 466aa74..dd32f7d 100644 --- a/apps/web/components/shared/molecules/general.tsx +++ b/apps/web/components/shared/molecules/general.tsx @@ -1,26 +1,69 @@ "use client"; -import { useContext } from "react"; +import { useContext, useState } from "react"; import { Button, Card, CardBody } from "@nextui-org/react"; import { MdDeleteOutline } from "react-icons/md"; import { BiCoinStack } from "react-icons/bi" +import { toast } from "sonner"; import ThemeButton from "@/components/theme-button"; import { PrismaUserContext, type PrismaUserContextShape } from "@/context/prisma-user-context"; import { roundUsersCredits } from "@/helpers/user-helpers"; +import { ConversationsContext, type ConversationsContextShape } from "@/context/conversations-context"; +import { ConversationsActionType } from "@/helpers/sidebar-conversation-helpers"; +import ConfirmDeleteModal from "./confirm-delete-modal"; -function ClearAll(): JSX.Element { - return ( -
+ + +
); } diff --git a/apps/web/components/shared/molecules/user-card.tsx b/apps/web/components/shared/molecules/user-card.tsx index d973f3f..3435497 100644 --- a/apps/web/components/shared/molecules/user-card.tsx +++ b/apps/web/components/shared/molecules/user-card.tsx @@ -2,6 +2,7 @@ import { Card, User } from "@nextui-org/react"; import { SlOptions } from "react-icons/sl"; import { BiCoinStack } from "react-icons/bi" +import { shortenString } from "@/helpers/string-helpers"; // Defining the props expected by the UserCard component interface UserCardProps { @@ -71,7 +72,7 @@ export default function UserCard({ } - name={name} + name={shortenString(name, 15)} /> diff --git a/apps/web/components/user/conversationBody/molecules/conversation-body.tsx b/apps/web/components/user/conversationBody/molecules/conversation-body.tsx index a9dbcf5..be3920b 100644 --- a/apps/web/components/user/conversationBody/molecules/conversation-body.tsx +++ b/apps/web/components/user/conversationBody/molecules/conversation-body.tsx @@ -101,7 +101,6 @@ async function handleSaveMessage( ): Promise { try { await saveMessage(idConversation, idUser, model, sender, input, size, onUserCreditsReduction); - toast.success("Model message saved"); } catch { console.log("Error ocurred while saving message."); toast.error("Error ocurred while saving message of model."); @@ -376,12 +375,14 @@ export default function ConversationBody(): JSX.Element {
) : ( )}
diff --git a/apps/web/components/user/conversationBody/molecules/message-list.tsx b/apps/web/components/user/conversationBody/molecules/message-list.tsx index 64000e8..899788f 100644 --- a/apps/web/components/user/conversationBody/molecules/message-list.tsx +++ b/apps/web/components/user/conversationBody/molecules/message-list.tsx @@ -40,7 +40,7 @@ export default function MessageList({ }, [autoScroll, messages]); return ( -
+
{/* Messages display */} {messages.map((message, index) => (
diff --git a/apps/web/components/user/conversationBody/molecules/prompt-text-input.tsx b/apps/web/components/user/conversationBody/molecules/prompt-text-input.tsx index 453fc19..4c2e0f8 100644 --- a/apps/web/components/user/conversationBody/molecules/prompt-text-input.tsx +++ b/apps/web/components/user/conversationBody/molecules/prompt-text-input.tsx @@ -1,3 +1,5 @@ +/* eslint-disable no-nested-ternary*/ + /** * A component that renders a text input field and a send button for the user to send messages. * @param input - The current value of the text input field. @@ -14,7 +16,7 @@ import type { User } from "@prisma/client"; import { Sender } from "@prisma/client"; import { toast } from "sonner"; import CreditsBadge from "@/components/user/conversationBody/atoms/credits-badge"; -import { calculateTokens } from "@/lib/helper/gpt/credits-and-tokens"; +import { calculateTokens, creditsToTokens } from "@/lib/helper/gpt/credits-and-tokens"; import { saveMessage } from "@/lib/helper/data-handles"; import type { PrismaUserContextShape } from "@/context/prisma-user-context"; import { PrismaUserContext } from "@/context/prisma-user-context"; @@ -37,7 +39,6 @@ async function handleSaveMessage( // Save sent user message if the user's information is available. if (idUser !== undefined){ await saveMessage(idConversation, idUser, model, sender, input, size || "", onUserCreditsReduction); - toast.success("User message saved"); } } catch { console.log("Error ocurred while saving message."); @@ -45,6 +46,24 @@ async function handleSaveMessage( } } +// check to see if the prompt button should be disabled based on if an image can be generated +function checkDalle(credits: number, size: string): boolean { + const totalImages = creditsToTokens(credits, "dalle", size) + // return false if an image can be generated + if (totalImages === 0) + return true + return false +} + +function checkGPT(credits: number, model: string, input: string): boolean { + const totalTokens = creditsToTokens(credits, model) + const inputTokens = calculateTokens(input) + // return false if a prompt can be generated + if (totalTokens === 0 || totalTokens <= inputTokens) + return true + return false +} + export default function PromptTextInput({ idConversation, model, @@ -52,6 +71,8 @@ export default function PromptTextInput({ handleInputChange, handleSubmit, isLoading, + creditsRemaining, + size, }: { idConversation: number; model: string; @@ -59,6 +80,8 @@ export default function PromptTextInput({ handleInputChange: any; handleSubmit: any; isLoading: boolean; + creditsRemaining: number; + size: string; }): JSX.Element { const prismaUserContext = useContext(PrismaUserContext) @@ -68,19 +91,42 @@ export default function PromptTextInput({ prismaUserContext?.setPrismaUser(updatedUser) } + // const handleSubmitCheckGPT = (input: string, credits?: number) : any => { + // const totalTokens = creditsToTokens(credits!, model) + // const inputTokens = calculateTokens(input) + // if (totalTokens > inputTokens && totalTokens > 4096 + inputTokens || totalTokens > inputTokens && totalTokens < 4096) { + // handleSubmit + // } else if (totalTokens < inputTokens || totalTokens === 0) { + // toast.error("Insufficient credits to send a prompt") + // } + // } + return (
{/* Tokens Placeholder */} -
- -
+ { + model === "dalle" && checkDalle(creditsRemaining, size) + ? +
+

Insufficient credits

+
+ : + model === "dalle" + ? + <> + : +
+ +
+ }
{/* Textarea field */}