Task 3: React Frontend

This commit is contained in:
RobinB27
2025-11-15 22:41:26 +01:00
parent ebea7897f2
commit 3a61b72642
30 changed files with 820 additions and 0 deletions

View File

@@ -0,0 +1,138 @@
/*some style for app component*/
:root {
--spacing: 0.25rem;
--border-color: #a0a0a0;
}
/* Style for definition list */
dl {
margin-top: 0;
margin-bottom: 20px;
}
dt,
dd {
line-height: 1.42857143;
}
dt {
font-weight: 700;
}
dd {
margin-left: 0;
}
body h1,
body h2 {
margin-bottom: 0;
}
nav {
border-bottom: 1px solid var(--border-color);
}
article {
width: 400px;
}
body>main {
max-height: calc(100vh - 100px);
overflow-y: auto;
padding: var(--spacing) !important;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
align-items: start;
gap: var(--spacing);
& article {
margin: 0;
padding: var(--spacing);
&>header {
margin: calc(var(--spacing) * -1) calc(var(--spacing) * -1) var(--spacing);
padding: var(--spacing);
}
}
}
.info {
h4 {
margin-bottom: 2px;
margin-top: 10px;
}
p {
margin: 0;
}
}
.table-container {
width: 100%;
}
.table-scroll-wrapper {
width: 100%;
max-height: 70vh;
overflow: scroll;
}
/* Set a fixed scrollable wrapper */
#table-content {
max-height: 400px;
overflow: auto;
/* Set header to stick to the top of the container. */
& thead tr th {
position: sticky;
top: 0;
}
/* If we use border, we must use table-collapse to avoid a slight movement of the header row */
& table {
border-collapse: collapse;
border-top: 0;
}
/* Because we must set sticky on th, we have to apply background styles here rather than on thead */
& th {
cursor: pointer;
border-left: 1px dotted rgba(200, 209, 224, 0.6);
border-bottom: 1px solid #ddd;
background: #eee;
text-align: left;
box-shadow: 0px 0px 0 2px #e8e8e8;
&.active {
background: #ddd;
}
&.sortable::after {
font-family: FontAwesome;
content: "\f0dc";
position: absolute;
right: 8px;
color: #999;
}
&.active::after {
position: absolute;
right: 8px;
color: #999;
}
&.active.sorting.asc::after {
font-family: FontAwesome;
content: "\f0d8";
}
&.active.sorting.desc::after {
font-family: FontAwesome;
content: "\f0d7";
}
}
}

View File

@@ -0,0 +1,47 @@
import "./App.css";
import '@fortawesome/fontawesome-free/css/all.css';
import { readCSV } from './csv';
import { CSV_Data, fileInfo } from './types';
import React, { useState } from "react";
import Layout from "./Layout";
import CSVCard from "./components/CSVCard";
import InfoCard from "./components/InfoCard";
import DataTable from "./components/DataTable";
function App() {
const [data, setData] = useState([] as CSV_Data);
const [info, setInfo] = useState({
filename: "None",
filetype: "None",
filesize: "None",
rowcount: 0
});
// This is triggered in CSVCard
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = e.target.files?.[0];
if (!file) throw new Error("No file received");
const data = await readCSV(e);
const newFileInfo: fileInfo = {
filename: file.name,
filetype: file.type,
filesize: String(file.size) + "B",
rowcount: data.length
}
setInfo(newFileInfo);
setData(data);
}
return (
<Layout>
<CSVCard handleChange={handleFileChange}></CSVCard>
<InfoCard info={info}></InfoCard>
<DataTable data={data}></DataTable>
</Layout>
);
}
export default App;

View File

@@ -0,0 +1,29 @@
body h1,
body h2 {
margin-bottom: 0;
}
nav {
border-bottom: 1px solid var(--border-color);
}
body main {
max-height: calc(100vh - 100px);
overflow-y: auto;
padding: var(--spacing) !important;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: start;
gap: var(--spacing);
& article {
margin: 0;
padding: var(--spacing);
& > header {
margin: calc(var(--spacing) * -1) calc(var(--spacing) * -1) var(--spacing);
padding: var(--spacing);
}
}
}

View File

@@ -0,0 +1,21 @@
import React from "react";
import "./Layout.css";
const Layout = (props: { children: React.ReactNode }) => {
return (
<>
<nav className="container-fluid">
<ul>
<li>
<h1>Open data explorer</h1>
</li>
</ul>
</nav>
<main className="container-fluid">
{props.children}
</main>
</>
);
};
export default Layout;

View File

@@ -0,0 +1,23 @@
import React from "react";
import "../Layout.css";
const CSVCard = (props: {
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}) => {
return (
<article>
<header>
<h2>Select CSV data</h2>
</header>
<form>
<label htmlFor="file-input" className="custom-file-upload">
<i className="fa fa-file-csv"></i> Select CSV file to explore
</label>
<input id="file-input" type="file" aria-describedby="fileHelp" accept="text/csv" onChange={props.handleChange}/>
<small>Please upload a CSV file, where the first row is the header.</small>
</form>
</article>
);
}
export default CSVCard;

View File

@@ -0,0 +1,84 @@
import { Key, SetStateAction, useState } from "react";
import { CSV_Data } from "../types";
const DataTable = (props: {data: CSV_Data}) => {
if (props.data.length == 0) return <></>;
const header = Object.keys(props.data[0]!);
const [sortCol, setSortCol] = useState("None");
const [sortType, setSortType] = useState("asc");
const sortingHandler = (col: String) => {
if (sortCol !== col) {
setSortCol(col as SetStateAction<string>);
setSortType("asc");
} else if (sortType === "asc") {
setSortType("desc");
} else {
setSortCol("None");
setSortType("None");
}
}
if (sortCol !== "None" && sortType === "asc") {
props.data.sort( (a, b) => {
if (a[sortCol]! < b[sortCol]!) return -1;
if (a[sortCol]! > b[sortCol]!) return 1;
return 0;
});
} else if (sortCol !== "None" && sortType === "desc") {
props.data.sort( (a, b) => {
if (a[sortCol]! > b[sortCol]!) return -1;
if (a[sortCol]! < b[sortCol]!) return 1;
return 0;
});
}
return (
<table id="table-content">
<thead>
<tr>
{
header.map( (col) => (
<ColHeader col={col} sortingHandle={sortingHandler} isSelected={col == sortCol} sortType={sortType}></ColHeader>
))
}
</tr>
</thead>
<tbody>
{
props.data.map( (row, i) => (
<tr key={i}>
{
header.map( (col) => (
<Row col={col} content={row[col] as String}></Row>
))
}
</tr>
))
}
</tbody>
</table>
)
}
const ColHeader = (props: {col: String, sortingHandle: (s: String) => void, isSelected: boolean, sortType: String}) => {
return (
<th
className={
props.isSelected
? (props.sortType === "asc" ? "active sorting asc" : "active sorting desc")
: "sortable"
}
onClick={() => {props.sortingHandle!(props.col)}}
key={props.col as Key}>
{props.col}
</th>
);
}
const Row = (props: {col: String, content: String}) => {
return <td key={props.col as Key}>{props.content}</td>;
}
export default DataTable;

View File

@@ -0,0 +1,35 @@
import "../Layout.css";
import { fileInfo } from "../types";
const InfoCard = (props: {
info: fileInfo
}) => {
let noFileMessage = <div></div>
if (props.info.filename === "None")
noFileMessage = <div id="data-info-placeholder">No file selected</div>;
return (
<article>
<header>
<h2>Data infos</h2>
</header>
<div className="info">
<h4>Filename</h4>
<p>{props.info.filename}</p>
<h4>File type</h4>
<p>{props.info.filetype}</p>
<h4>File size</h4>
<p>{props.info.filesize}</p>
<h4>Number of rows</h4>
<p>{props.info.rowcount}</p>
</div>
{noFileMessage}
</article>
);
}
export default InfoCard;

View File

@@ -0,0 +1,30 @@
import { CSV_Data } from './types';
import { csv2json } from 'json-2-csv';
const convertCSVtoJSON = async ( csvText: string ) => {
// Type cast OK, as the typing of the external library is not perfect -> Actually it is.
// NOTE: On transpilation to JS, it will be (more or less) disregarded anyway.
// If you claim it isn't good typing, it's the same as expecting it to guess the typing,
// which is literally impossible in TS. But sure...
return ( await csv2json( csvText ) ) as CSV_Data;
};
/**
* Reads a CSV file and returns the data as JSON.
* @param event The change event of the file input.
*/
export const readCSV = async ( event: React.ChangeEvent<HTMLInputElement> ): Promise<CSV_Data> => {
if ( !( event.target instanceof HTMLInputElement ) ) {
throw new Error( 'Not an HTMLInputElement' );
}
const file = event.target.files?.[0];
if ( file == null ) {
throw new Error( 'No file selected' );
}
const result = await file.text();
return await convertCSVtoJSON( result );
};

View File

@@ -0,0 +1,20 @@
:root {
--spacing: 1rem;
--border-color: #a0a0a0;
}
/* Style for definition list */
dl {
margin-top: 0;
margin-bottom: 20px;
}
dt,
dd {
line-height: 1.42857143;
}
dt {
font-weight: 700;
}
dd {
margin-left: 0;
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "@fortawesome/fontawesome-free/css/all.css";
import App from "./App";
import "@picocss/pico/css/pico.min.css";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

11
task_3_react/src/client/types.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
export type CSVRecord = Record<string, unknown>;
export type CSV_Data = CSVRecord[];
// InfoCard receives this via props
export type fileInfo = {
filename: string;
filetype: string;
filesize: string;
rowcount: number;
}

1
task_3_react/src/client/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />