diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..d6c9537 --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..0d6babe --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,30 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default { + // other rules... + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: ['./tsconfig.json', './tsconfig.node.json'], + tsconfigRootDir: __dirname, + }, +} +``` + +- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` +- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..8e3ef92 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/ui/components/shadcn", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8dd5048 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + FE Task + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5bd8ef9 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,47 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-slot": "^1.0.2", + "@tanstack/react-query": "^5.17.12", + "@tanstack/react-table": "^8.11.6", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "localforage": "^1.10.0", + "match-sorter": "^6.3.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-intersection-observer": "^9.5.3", + "react-router-dom": "^6.21.2", + "sort-by": "^1.2.0", + "tailwind-merge": "^2.2.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@types/node": "^20.11.4", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "eslint": "^8.55.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "typescript": "^5.2.2", + "vite": "^5.0.8" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..eb05b2e --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,23 @@ +import { Route, Routes } from "react-router-dom"; +import Home from "./pages/Home"; +import Header from "./ui/components/custom/Header/Header"; +import AttributesWInfiniteScroll from "./pages/AttributesWInfiniteScroll"; +import AttributeDetail from "./pages/AttributeDetail"; +import AttributesWPagination from "./pages/AttributesWPagination"; + +function App() { + return ( + <> +
+ + + } /> + } /> + } /> + } /> + + + ); +} + +export default App; diff --git a/frontend/src/ReactQueryProvider.tsx b/frontend/src/ReactQueryProvider.tsx new file mode 100644 index 0000000..0a57162 --- /dev/null +++ b/frontend/src/ReactQueryProvider.tsx @@ -0,0 +1,16 @@ +'use client' + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactNode } from "react"; + +export const queryClient = new QueryClient(); + +export const ReactQueryProvider = ({ + children, +}: { + children: ReactNode; +}) => { + return ( + {children} + ); +}; diff --git a/frontend/src/assets/meiro_logo.jpeg b/frontend/src/assets/meiro_logo.jpeg new file mode 100644 index 0000000..130b7b3 Binary files /dev/null and b/frontend/src/assets/meiro_logo.jpeg differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/helpers/dateFormatter.ts b/frontend/src/helpers/dateFormatter.ts new file mode 100644 index 0000000..ccc8a80 --- /dev/null +++ b/frontend/src/helpers/dateFormatter.ts @@ -0,0 +1,19 @@ +import { Label } from "@/services/attributesService"; + +export function formatDateString(inputDate: string): string { + const date = new Date(inputDate); + + if (isNaN(date.getTime())) { + return 'Invalid Date'; + } + + const day = ('0' + date.getDate()).slice(-2); + const month = ('0' + (date.getMonth() + 1)).slice(-2); + const year = date.getFullYear(); + const hours = ('0' + date.getHours()).slice(-2); + const minutes = ('0' + date.getMinutes()).slice(-2); + + const formattedDate = `${day}/${month}/${year} ${hours}:${minutes}`; + + return formattedDate; + } \ No newline at end of file diff --git a/frontend/src/helpers/matchLabelNames.ts b/frontend/src/helpers/matchLabelNames.ts new file mode 100644 index 0000000..f01914b --- /dev/null +++ b/frontend/src/helpers/matchLabelNames.ts @@ -0,0 +1,14 @@ +import { Label } from "@/services/attributesService"; + +export function matchLabelNames(labels: Label[], arr: Pick[]): string[] { + const labelNames: string[] = [] + arr.forEach((value) => { + const matchingLabel = labels.find((label) => label.id === value.toString()); + if (matchingLabel) { + labelNames.push(matchingLabel.name) + } else { + console.log(`Label with ID ${value} not found`); + } + }); + return labelNames + } \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..0b46ea1 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,76 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 10% 3.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..d084cca --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..6cfb255 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.tsx' +import './index.css' +import { BrowserRouter } from 'react-router-dom' +import { ReactQueryProvider } from './ReactQueryProvider.tsx' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/frontend/src/pages/AttributeDetail.tsx b/frontend/src/pages/AttributeDetail.tsx new file mode 100644 index 0000000..e0d1753 --- /dev/null +++ b/frontend/src/pages/AttributeDetail.tsx @@ -0,0 +1,110 @@ +import { formatDateString } from "@/helpers/dateFormatter"; +import { + Attribute, + deleteAttribute, + getAttribute, +} from "@/services/attributesService"; +import { + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/ui/components/shadcn/ui/alert-dialog"; +import { Button } from "@/ui/components/shadcn/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/ui/components/shadcn/ui/card"; +import { AlertDialog } from "@radix-ui/react-alert-dialog"; +import { TrashIcon } from "@radix-ui/react-icons"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate, useParams } from "react-router-dom"; +import { labels } from "../../../backend/src/labels/data"; +import { matchLabelNames } from "@/helpers/matchLabelNames"; + +export default function AttributeDetail() { + const { id } = useParams(); + const navigate = useNavigate(); + + const { + data: attribute, + isLoading, + isFetching, + } = useQuery<{ data: Attribute }>({ + queryKey: [`attributes`], + queryFn: () => getAttribute(id || ""), + }); + + if (isLoading || !attribute || isFetching) { + return ( +
+ Loading... +
+ ); + } + + const correspondingLabelNames: string[] = matchLabelNames( + labels, + attribute.data.labelIds + ); + + return ( + +
+ + + {attribute?.data.name} + + {formatDateString(attribute.data.createdAt)} + + + + {correspondingLabelNames.map((name) => { + return [{name}] ; + })} + + + + + + + + +
+ + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete attribute + data. + + + + Cancel + { + await deleteAttribute(attribute.data.id); + navigate("/attributes"); + }} + > + Continue + + + +
+ ); +} diff --git a/frontend/src/pages/AttributesWInfiniteScroll.tsx b/frontend/src/pages/AttributesWInfiniteScroll.tsx new file mode 100644 index 0000000..e84ce59 --- /dev/null +++ b/frontend/src/pages/AttributesWInfiniteScroll.tsx @@ -0,0 +1,81 @@ +import { + DataResponse, + FilterQueryParamsInfiniteScroll, + getAllAttributesInfiniteScroll, +} from "@/services/attributesService"; +import { DataTable } from "@/ui/components/custom/DataTable/DataTable"; +import { columns } from "@/ui/components/custom/DataTable/columns"; +import FilterPanel from "@/ui/components/custom/FilterPanel/FilterPanel"; +import { Card, CardContent } from "@/ui/components/shadcn/ui/card"; +import { GitHubLogoIcon } from "@radix-ui/react-icons"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { useInView } from "react-intersection-observer"; +import { Link } from "react-router-dom"; + +export default function AttributesWInfiniteScroll() { + const [filterObject, setFilterObject] = useState({ + limit: 10, + searchText: "", + sortBy: "name", + sortDir: "asc", + }); + + const { ref, inView } = useInView(); + + const { data: attributes, fetchNextPage } = useInfiniteQuery({ + queryKey: [`attributes?${JSON.stringify(filterObject)}`], + queryFn: ({ pageParam }) => { + const newOffset = Number(pageParam) ?? 0; + return getAllAttributesInfiniteScroll(filterObject, newOffset); + }, + initialPageParam: 0, + getNextPageParam: (lastPage) => + lastPage.meta.hasNextPage + ? lastPage.meta.offset + lastPage.meta.limit + : undefined, + }); + + useEffect(() => { + if (inView) { + fetchNextPage(); + } + }, [inView, fetchNextPage]); + + const allAttributes = useMemo(() => { + if (!attributes) { + return []; + } + return attributes?.pages + .map((att) => att.data) + .reduce((acc, curr) => [...acc, ...curr], []); + }, [attributes]); + + return ( +
+
+ +
+ + +
+
+ + +
+
+ + + +
+
+
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/AttributesWPagination.tsx b/frontend/src/pages/AttributesWPagination.tsx new file mode 100644 index 0000000..821e4e7 --- /dev/null +++ b/frontend/src/pages/AttributesWPagination.tsx @@ -0,0 +1,69 @@ +import { + DataResponse, + FilterQueryParamsPagination, + getAllAttributesPagination, +} from "@/services/attributesService"; +import { DataTable } from "@/ui/components/custom/DataTable/DataTable"; +import { columns } from "@/ui/components/custom/DataTable/columns"; +import FilterPanel from "@/ui/components/custom/FilterPanel/FilterPanel"; +import Pagination from "@/ui/components/custom/Pagination/Pagination"; +import { Card, CardContent } from "@/ui/components/shadcn/ui/card"; +import { GitHubLogoIcon } from "@radix-ui/react-icons"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { Link } from "react-router-dom"; + +export default function AttributesWPagination() { + const [filterObject, setFilterObject] = useState({ + offset: 0, + limit: 10, + searchText: "", + sortBy: "name", + sortDir: "asc", + }); + + const { data: attributes, isLoading } = useQuery({ + queryKey: [`attributes?${JSON.stringify(filterObject)}`], + queryFn: () => getAllAttributesPagination(filterObject), + }); + + return ( +
+
+ + +
+ +
+ {isLoading || !attributes ? ( +

Loading...

+ ) : ( + + )} +
+ +
+
+ + +
+
+ + + +
+
+
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx new file mode 100644 index 0000000..e9faac9 --- /dev/null +++ b/frontend/src/pages/Home.tsx @@ -0,0 +1,5 @@ +import React from "react"; + +export default function Home() { + return
HomePage
; +} diff --git a/frontend/src/services/attributesService.ts b/frontend/src/services/attributesService.ts new file mode 100644 index 0000000..f11e408 --- /dev/null +++ b/frontend/src/services/attributesService.ts @@ -0,0 +1,80 @@ +const BASE_URL = "http://127.0.0.1:3000"; + +export type DataResponse = { + data: Array; + meta: { + offset: number; + limit: number; + searchText: string; + sortBy: "name" | "createdAt"; + sortDir: "asc" | "desc"; + hasNextPage: boolean; + }; +}; + +export type Attribute = { + id: string; + name: string; + createdAt: string; // ISO8601 + labelIds: Pick[]; + deleted: boolean; +}; + +export type Label = { + id: string; + name: string; +}; + +export type FilterQueryParamsInfiniteScroll = { + limit: number; + searchText: string; + sortBy: "name" | "createdAt"; + sortDir: "asc" | "desc"; +}; + +export type FilterQueryParamsPagination = FilterQueryParamsInfiniteScroll & { + offset: number; +}; + +export const getAllAttributesInfiniteScroll = ( + filterObject: FilterQueryParamsInfiniteScroll, + offset: number +) => { + const queryParamsString = Object.entries({ ...filterObject, offset }) + .map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`) + .join("&"); + + return fetch(`${BASE_URL}/attributes?${queryParamsString}`, { method: "GET" }) + .then((res) => res.json()) + .catch((error) => { + console.error("Error fetching all attributes:", error); + }); +}; + +export const getAllAttributesPagination = ( + filterObject: FilterQueryParamsPagination +) => { + const queryParamsString = Object.entries(filterObject) + .map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`) + .join("&"); + + return fetch(`${BASE_URL}/attributes?${queryParamsString}`, { method: "GET" }) + .then((res) => res.json()) + .catch((error) => { + console.error("Error fetching all attributes:", error); + }); +}; + +export const getAttribute = (id: string) => + fetch(`${BASE_URL}/attributes/${id}`, { method: "GET" }) + .then((res) => res.json()) + .catch((error) => { + console.error(`Error fetching attribute with id: ${id}`, error); + }); + +export const deleteAttribute = (id: string) => + fetch(`${BASE_URL}/attributes/${id}`, { method: "DELETE" }) + .then((res) => res.json()) + .catch((error) => { + console.error(`Error deleting attribute with id: ${id}`, error); + }); diff --git a/frontend/src/ui/components/custom/DataTable/DataTable.tsx b/frontend/src/ui/components/custom/DataTable/DataTable.tsx new file mode 100644 index 0000000..9c670a1 --- /dev/null +++ b/frontend/src/ui/components/custom/DataTable/DataTable.tsx @@ -0,0 +1,76 @@ +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, +} from "../../shadcn/ui/table"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ ); +} diff --git a/frontend/src/ui/components/custom/DataTable/columns.tsx b/frontend/src/ui/components/custom/DataTable/columns.tsx new file mode 100644 index 0000000..a50876f --- /dev/null +++ b/frontend/src/ui/components/custom/DataTable/columns.tsx @@ -0,0 +1,104 @@ +import { Attribute, deleteAttribute } from "@/services/attributesService"; +import { ColumnDef } from "@tanstack/react-table"; +import { Button } from "../../shadcn/ui/button"; +import { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogCancel, + AlertDialogAction, + AlertDialogHeader, + AlertDialogFooter, +} from "../../shadcn/ui/alert-dialog"; +import { TrashIcon } from "@radix-ui/react-icons"; +import { useQueryClient } from "@tanstack/react-query"; +import { formatDateString } from "@/helpers/dateFormatter"; +import { Link } from "react-router-dom"; +import { matchLabelNames } from "@/helpers/matchLabelNames"; +import { labels } from "../../../../../../backend/src/labels/data"; + +export const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => { + return ( + + {row.getValue("name")} + + ); + }, + }, + { + accessorKey: "labelIds", + header: "Labels", + cell: ({ row }) => { + const correspondingLabelNames: string[] = matchLabelNames( + labels, + row.original.labelIds + ); + + return ( + <> + {correspondingLabelNames.map((name) => { + return {name}/ ; + })} + + ); + }, + }, + { + accessorKey: "createdAt", + header: "Created at", + cell: ({ row }) => { + return <>{formatDateString(row.getValue("createdAt"))}; + }, + }, + { + id: "actions", + accessorKey: "actions", + header: "Actions", + cell: ({ row }) => { + const attribute = row.original; + // eslint-disable-next-line react-hooks/rules-of-hooks + const queryClient = useQueryClient(); + return ( + <> + + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete + attribute data. + + + + Cancel + { + await deleteAttribute(attribute.id); + queryClient.invalidateQueries({ queryKey: ["attributes"] }); + }} + > + Continue + + + + + + ); + }, + }, +]; diff --git a/frontend/src/ui/components/custom/FilterPanel/FilterPanel.tsx b/frontend/src/ui/components/custom/FilterPanel/FilterPanel.tsx new file mode 100644 index 0000000..765fa63 --- /dev/null +++ b/frontend/src/ui/components/custom/FilterPanel/FilterPanel.tsx @@ -0,0 +1,84 @@ +import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; +import { Input } from "../../shadcn/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../shadcn/ui/select"; +import { FilterQueryParams } from "@/services/attributesService"; + +interface FilterPanelProps { + filterObject: FilterQueryParams; + setFilterObject: React.Dispatch>; +} + +export default function FilterPanel({ + filterObject, + setFilterObject, +}: FilterPanelProps) { + const handleInputChange = (event: React.ChangeEvent) => { + event.preventDefault(); + const newSearchValue = event.target.value; + setFilterObject((prevFilter) => ({ + ...prevFilter, + searchText: newSearchValue, + })); + }; + + const handleSortBySelectChange = (newSelectedValue: "name" | "createdAt") => { + setFilterObject((prevFilter) => ({ + ...prevFilter, + sortBy: newSelectedValue, + })); + }; + + const handleOrderSelectChange = (newSelectedValue: "asc" | "desc") => { + setFilterObject((prevFilter) => ({ + ...prevFilter, + sortDir: newSelectedValue, + })); + }; + + return ( + <> +
+ + +
+
+ + +
+ + ); +} diff --git a/frontend/src/ui/components/custom/Header/Header.tsx b/frontend/src/ui/components/custom/Header/Header.tsx new file mode 100644 index 0000000..2c653cb --- /dev/null +++ b/frontend/src/ui/components/custom/Header/Header.tsx @@ -0,0 +1,35 @@ +import { Link } from "react-router-dom"; +import Logo from "./../../../../assets/meiro_logo.jpeg"; + +function Header() { + return ( +
+ + logo + FE Task + + +
+ ); +} + +export default Header; diff --git a/frontend/src/ui/components/custom/Pagination/Pagination.tsx b/frontend/src/ui/components/custom/Pagination/Pagination.tsx new file mode 100644 index 0000000..aaf63ea --- /dev/null +++ b/frontend/src/ui/components/custom/Pagination/Pagination.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { Button } from "../../shadcn/ui/button"; +import { FilterQueryParams } from "@/services/attributesService"; + +interface PaginationProps { + filterObject: FilterQueryParams; + setFilterObject: React.Dispatch> + hasNextPage: boolean | undefined; + } + +export default function Pagination({ + filterObject, + setFilterObject, + hasNextPage, + }: PaginationProps) { + + const goToNextPage = () => { + setFilterObject((prevFilter) => ({ + ...prevFilter, + offset: prevFilter?.offset + filterObject.limit, + })); + }; + + const goToPreviousPage = () => { + setFilterObject((prevFilter) => ({ + ...prevFilter, + offset: prevFilter?.offset - filterObject.limit, + })); + }; + + return
+ + +
; +} diff --git a/frontend/src/ui/components/shadcn/ui/alert-dialog.tsx b/frontend/src/ui/components/shadcn/ui/alert-dialog.tsx new file mode 100644 index 0000000..29b025a --- /dev/null +++ b/frontend/src/ui/components/shadcn/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/ui/components/shadcn/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/frontend/src/ui/components/shadcn/ui/button.tsx b/frontend/src/ui/components/shadcn/ui/button.tsx new file mode 100644 index 0000000..0270f64 --- /dev/null +++ b/frontend/src/ui/components/shadcn/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/frontend/src/ui/components/shadcn/ui/card.tsx b/frontend/src/ui/components/shadcn/ui/card.tsx new file mode 100644 index 0000000..77e9fb7 --- /dev/null +++ b/frontend/src/ui/components/shadcn/ui/card.tsx @@ -0,0 +1,76 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/frontend/src/ui/components/shadcn/ui/input.tsx b/frontend/src/ui/components/shadcn/ui/input.tsx new file mode 100644 index 0000000..a92b8e0 --- /dev/null +++ b/frontend/src/ui/components/shadcn/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/frontend/src/ui/components/shadcn/ui/select.tsx b/frontend/src/ui/components/shadcn/ui/select.tsx new file mode 100644 index 0000000..ac2a8f2 --- /dev/null +++ b/frontend/src/ui/components/shadcn/ui/select.tsx @@ -0,0 +1,164 @@ +"use client" + +import * as React from "react" +import { + CaretSortIcon, + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, +} from "@radix-ui/react-icons" +import * as SelectPrimitive from "@radix-ui/react-select" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/frontend/src/ui/components/shadcn/ui/table.tsx b/frontend/src/ui/components/shadcn/ui/table.tsx new file mode 100644 index 0000000..c0df655 --- /dev/null +++ b/frontend/src/ui/components/shadcn/ui/table.tsx @@ -0,0 +1,120 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..7cb7e37 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,77 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: ["class"], + content: [ + './pages/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + './app/**/*.{ts,tsx}', + './src/**/*.{ts,tsx}', + ], + prefix: "", + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..51ef919 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + }, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..d36c010 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,12 @@ +import path from "path" +import react from "@vitejs/plugin-react" +import { defineConfig } from "vite" + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}) \ No newline at end of file