From 30f9342c78cc3174d205e3741281cdb411af259e Mon Sep 17 00:00:00 2001 From: Daniel Harvilik <56021310+danielharvilik@users.noreply.github.com> Date: Sun, 21 Jan 2024 17:34:35 +0100 Subject: [PATCH] finished meiro-fe-task --- frontend/.eslintrc.cjs | 18 ++ frontend/.gitignore | 24 +++ frontend/README.md | 30 ++++ frontend/components.json | 17 ++ frontend/index.html | 12 ++ frontend/package.json | 47 +++++ frontend/postcss.config.js | 6 + frontend/public/vite.svg | 1 + frontend/src/App.tsx | 23 +++ frontend/src/ReactQueryProvider.tsx | 16 ++ frontend/src/assets/meiro_logo.jpeg | Bin 0 -> 12742 bytes frontend/src/assets/react.svg | 1 + frontend/src/helpers/dateFormatter.ts | 19 ++ frontend/src/helpers/matchLabelNames.ts | 14 ++ frontend/src/index.css | 76 ++++++++ frontend/src/lib/utils.ts | 6 + frontend/src/main.tsx | 16 ++ frontend/src/pages/AttributeDetail.tsx | 110 ++++++++++++ .../src/pages/AttributesWInfiniteScroll.tsx | 81 +++++++++ frontend/src/pages/AttributesWPagination.tsx | 69 ++++++++ frontend/src/pages/Home.tsx | 5 + frontend/src/services/attributesService.ts | 80 +++++++++ .../components/custom/DataTable/DataTable.tsx | 76 ++++++++ .../components/custom/DataTable/columns.tsx | 104 +++++++++++ .../custom/FilterPanel/FilterPanel.tsx | 84 +++++++++ .../ui/components/custom/Header/Header.tsx | 35 ++++ .../custom/Pagination/Pagination.tsx | 49 ++++++ .../ui/components/shadcn/ui/alert-dialog.tsx | 141 +++++++++++++++ .../src/ui/components/shadcn/ui/button.tsx | 57 ++++++ frontend/src/ui/components/shadcn/ui/card.tsx | 76 ++++++++ .../src/ui/components/shadcn/ui/input.tsx | 25 +++ .../src/ui/components/shadcn/ui/select.tsx | 164 ++++++++++++++++++ .../src/ui/components/shadcn/ui/table.tsx | 120 +++++++++++++ frontend/src/vite-env.d.ts | 1 + frontend/tailwind.config.js | 77 ++++++++ frontend/tsconfig.json | 31 ++++ frontend/tsconfig.node.json | 10 ++ frontend/vite.config.ts | 12 ++ 38 files changed, 1733 insertions(+) create mode 100644 frontend/.eslintrc.cjs create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/components.json create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/ReactQueryProvider.tsx create mode 100644 frontend/src/assets/meiro_logo.jpeg create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/helpers/dateFormatter.ts create mode 100644 frontend/src/helpers/matchLabelNames.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AttributeDetail.tsx create mode 100644 frontend/src/pages/AttributesWInfiniteScroll.tsx create mode 100644 frontend/src/pages/AttributesWPagination.tsx create mode 100644 frontend/src/pages/Home.tsx create mode 100644 frontend/src/services/attributesService.ts create mode 100644 frontend/src/ui/components/custom/DataTable/DataTable.tsx create mode 100644 frontend/src/ui/components/custom/DataTable/columns.tsx create mode 100644 frontend/src/ui/components/custom/FilterPanel/FilterPanel.tsx create mode 100644 frontend/src/ui/components/custom/Header/Header.tsx create mode 100644 frontend/src/ui/components/custom/Pagination/Pagination.tsx create mode 100644 frontend/src/ui/components/shadcn/ui/alert-dialog.tsx create mode 100644 frontend/src/ui/components/shadcn/ui/button.tsx create mode 100644 frontend/src/ui/components/shadcn/ui/card.tsx create mode 100644 frontend/src/ui/components/shadcn/ui/input.tsx create mode 100644 frontend/src/ui/components/shadcn/ui/select.tsx create mode 100644 frontend/src/ui/components/shadcn/ui/table.tsx create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts 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 0000000000000000000000000000000000000000..130b7b3df4919a937328585c630ba147e4970688 GIT binary patch literal 12742 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 z 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 PC`Q3~n#;d{zia;kJVQ=@ literal 0 HcmV?d00001 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