feat(frontend): initial mock app + tooling + CI (#1)
* feat(frontend): filelist, filedetails, routing * ci: enable lint/test/build, preview stub * ci: linting file filter
This commit is contained in:
parent
acb4005fbe
commit
25ae69f6f4
21 changed files with 7978 additions and 0 deletions
192
.github/workflows/ci.yml
vendored
Normal file
192
.github/workflows/ci.yml
vendored
Normal file
|
@ -0,0 +1,192 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
main
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
NODE_VERSION: lts/hydrogen
|
||||
CI: 1
|
||||
|
||||
jobs:
|
||||
fe-setup:
|
||||
runs-on: ubuntu-latest
|
||||
name: Setup (frontend)
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- uses: actions/cache@v3
|
||||
id: cache-restore
|
||||
with:
|
||||
path: |
|
||||
.yarn
|
||||
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-${{ env.NODE_VERSION }}
|
||||
- name: Install dependencies
|
||||
if: steps.cache-restore.outputs.cache-hit != 'true'
|
||||
run: . script/bootstrap
|
||||
fe-lint:
|
||||
runs-on: ubuntu-latest
|
||||
name: Lint
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
needs: fe-setup
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: Yarn cache
|
||||
uses: actions/cache@v3
|
||||
id: yarn-cache-restore
|
||||
with:
|
||||
path: |
|
||||
.yarn
|
||||
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-${{ env.NODE_VERSION }}
|
||||
- name: Lint
|
||||
run: |
|
||||
. script/bootstrap
|
||||
yarn lint
|
||||
fe-test:
|
||||
runs-on: ubuntu-latest
|
||||
name: Test
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
needs: fe-setup
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: Yarn cache
|
||||
uses: actions/cache@v3
|
||||
id: yarn-cache-restore
|
||||
with:
|
||||
path: |
|
||||
.yarn
|
||||
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-${{ env.NODE_VERSION }}
|
||||
- name: Test
|
||||
run: |
|
||||
. script/bootstrap
|
||||
yarn test
|
||||
|
||||
fe-build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build App
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
needs: fe-setup
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: Yarn cache
|
||||
uses: actions/cache@v3
|
||||
id: yarn-cache-restore
|
||||
with:
|
||||
path: |
|
||||
.yarn
|
||||
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-${{ env.NODE_VERSION }}
|
||||
- name: Parcel cache
|
||||
uses: actions/cache@v3
|
||||
id: parcel-cache-restore
|
||||
with:
|
||||
path: |
|
||||
.parcel-cache
|
||||
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-${{ env.NODE_VERSION }}-parcel
|
||||
- run: |
|
||||
. script/bootstrap
|
||||
yarn build
|
||||
- name: Build Artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: packages/app/dist
|
||||
fe-preview:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy preview
|
||||
if: ${{ false && github.ref != 'refs/heads/main' }}
|
||||
needs: fe-build
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
id: node-setup
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: Yarn cache
|
||||
uses: actions/cache@v3
|
||||
id: yarn-cache-restore
|
||||
with:
|
||||
path: |
|
||||
.yarn
|
||||
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-${{ env.NODE_VERSION }}
|
||||
- run: . script/bootstrap
|
||||
- name: Build Artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: packages/app/dist
|
||||
- name: Deploy
|
||||
id: preview-deploy
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
run: |
|
||||
yarn netlify deploy --dir=packages/app/dist --json | jq .deploy_url > output.log
|
||||
echo "::set-output name=draft-url::$(cat output.log)"
|
||||
- name: Report
|
||||
uses: actions/github-script@v6
|
||||
env:
|
||||
DRAFT_URL: ${{ steps.preview-deploy.outputs.draft-url }}
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `:eyes: Branch deployed at ${process.env.DRAFT_URL}`
|
||||
})
|
||||
fe-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy
|
||||
needs: fe-build
|
||||
if: ${{ false }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
id: node-setup
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: Yarn cache
|
||||
uses: actions/cache@v3
|
||||
id: yarn-cache-restore
|
||||
with:
|
||||
path: |
|
||||
.yarn
|
||||
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-${{ env.NODE_VERSION }}
|
||||
- run: . script/bootstrap
|
||||
- name: Build Artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: packages/app/dist
|
||||
- name: Netlify CLI setup
|
||||
run: npm install -g netlify-cli
|
||||
- name: Deploy
|
||||
id: preview-deploy
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
run: |
|
||||
yarn netlify deploy --dir=packages/app/dist --prod
|
7
.gitignore
vendored
7
.gitignore
vendored
|
@ -7,6 +7,13 @@ yarn-error.log*
|
|||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
*/.pnp.*
|
||||
*/.yarn/*
|
||||
!*/.yarn/patches
|
||||
!*/.yarn/plugins
|
||||
!*/.yarn/sdks
|
||||
!*/.yarn/versions
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
|
|
1
frontend/.nvmrc
Normal file
1
frontend/.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
lts/hydrogen
|
0
frontend/.yarnrc.yml
Normal file
0
frontend/.yarnrc.yml
Normal file
8
frontend/jest.config.js
Normal file
8
frontend/jest.config.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "jsdom",
|
||||
transform: {
|
||||
"^.+\\.(ts|tsx)$": "ts-jest",
|
||||
},
|
||||
}
|
34
frontend/package.json
Normal file
34
frontend/package.json
Normal file
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"packageManager": "yarn@3.6.1",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.14.1",
|
||||
"@mui/material": "^5.14.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "parcel serve src/index.html --no-cache",
|
||||
"build": "parcel build src/index.html",
|
||||
"lint": "rome check tests src *.js --verbose && rome format tests src *.js --verbose",
|
||||
"lint:fix": "rome check tests src ./*.js --apply --verbose && rome format tests src ./*.js --write --verbose",
|
||||
"test": "yarn jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@parcel/core": "^2.9.3",
|
||||
"@parcel/types": "^2.9.3",
|
||||
"@testing-library/dom": "^9.3.1",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/jest": "^29.5.3",
|
||||
"@types/react": "^18.2.18",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"jest": "^29.6.2",
|
||||
"jest-environment-jsdom": "^29.6.2",
|
||||
"parcel": "^2.9.3",
|
||||
"process": "^0.11.10",
|
||||
"rome": "^12.1.3",
|
||||
"ts-jest": "^29.1.1",
|
||||
"typescript": "^5.1.6"
|
||||
}
|
||||
}
|
20
frontend/rome.json
Normal file
20
frontend/rome.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"$schema": "https://docs.rome.tools/schemas/12.1.3/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": false
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"lineWidth": 80
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"semicolons": "asNeeded"
|
||||
}
|
||||
}
|
||||
}
|
7
frontend/script/bootstrap
Normal file
7
frontend/script/bootstrap
Normal file
|
@ -0,0 +1,7 @@
|
|||
#!/bin/bash
|
||||
|
||||
if [[ $CI != 1 ]]; then
|
||||
nvm use
|
||||
fi
|
||||
|
||||
corepack enable && yarn
|
59
frontend/src/App.tsx
Normal file
59
frontend/src/App.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { Box } from "@mui/material"
|
||||
|
||||
import NavigationBar from "./components/NavigationBar"
|
||||
import FileList from "./components/FileList"
|
||||
import FileDetails from "./components/FileDetails"
|
||||
import AsyncTaskContext from "./contexts/AsyncTaskContext"
|
||||
import LocationContext, { useLocationContext } from "./contexts/LocationContext"
|
||||
|
||||
const mockData = [
|
||||
{
|
||||
title: "Test file",
|
||||
filename: "testfile.txt",
|
||||
size: 1023,
|
||||
uid: "123",
|
||||
},
|
||||
{
|
||||
title: "Other file",
|
||||
filename: "testfile2.txt",
|
||||
size: 535346,
|
||||
uid: "456",
|
||||
},
|
||||
]
|
||||
const routeLabels = {
|
||||
ITEM_DETAILS: "item-details",
|
||||
}
|
||||
|
||||
const routes = {
|
||||
[routeLabels.ITEM_DETAILS]: "/item/:itemId",
|
||||
}
|
||||
|
||||
const App = () => {
|
||||
const { location } = useLocationContext()
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
||||
<NavigationBar />
|
||||
<Box component="main" sx={{ display: "flex", paddingTop: "10px" }}>
|
||||
<Box component="div" sx={{ flexGrow: 1 }}>
|
||||
<FileList data={mockData} />
|
||||
</Box>
|
||||
{location.label === routeLabels.ITEM_DETAILS ? (
|
||||
<Box component="div" sx={{ flexGrow: 1 }}>
|
||||
<FileDetails itemId={location.params.itemId} />
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const AppWithContexts = () => (
|
||||
<AsyncTaskContext>
|
||||
<LocationContext routes={routes}>
|
||||
<App />
|
||||
</LocationContext>
|
||||
</AsyncTaskContext>
|
||||
)
|
||||
|
||||
export default AppWithContexts
|
61
frontend/src/components/FileDetails.tsx
Normal file
61
frontend/src/components/FileDetails.tsx
Normal file
|
@ -0,0 +1,61 @@
|
|||
import MuiCard from "@mui/material/Card"
|
||||
import MuiTypography from "@mui/material/Typography"
|
||||
import MuiArticleIcon from "@mui/icons-material/Article"
|
||||
import MuiBox from "@mui/material/Box"
|
||||
import { byteSizeToUnits } from "../utils"
|
||||
import MuiDeleteIcon from "@mui/icons-material/Delete"
|
||||
import MuiDownloadIcon from "@mui/icons-material/Download"
|
||||
import MuiIconButton from "@mui/material/IconButton"
|
||||
|
||||
interface FileDetailsProps {
|
||||
itemId: string
|
||||
}
|
||||
|
||||
// TODO: API data.
|
||||
const mockData = {
|
||||
title: "My File",
|
||||
size: 123123123,
|
||||
}
|
||||
|
||||
function FileDetails({ itemId }: FileDetailsProps) {
|
||||
const handleDownloadClick = () => {
|
||||
console.log("download click")
|
||||
}
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
console.log("delete click")
|
||||
}
|
||||
|
||||
return (
|
||||
<MuiCard sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<MuiBox
|
||||
component="div"
|
||||
sx={{ display: "flex", alignItems: "center", flexDirection: "column" }}
|
||||
>
|
||||
<MuiArticleIcon sx={{ fontSize: 120 }} />
|
||||
<MuiTypography variant="h1" sx={{ fontSize: 30 }}>
|
||||
{mockData.title}
|
||||
</MuiTypography>
|
||||
<MuiTypography>{byteSizeToUnits(mockData.size)}</MuiTypography>
|
||||
</MuiBox>
|
||||
<MuiBox sx={{ display: "flex", justifyContent: "center" }}>
|
||||
<>
|
||||
<MuiIconButton
|
||||
aria-label="download item"
|
||||
onClick={() => handleDownloadClick()}
|
||||
>
|
||||
<MuiDownloadIcon />
|
||||
</MuiIconButton>
|
||||
<MuiIconButton
|
||||
aria-label="delete item"
|
||||
onClick={() => handleDeleteClick()}
|
||||
>
|
||||
<MuiDeleteIcon />
|
||||
</MuiIconButton>
|
||||
</>
|
||||
</MuiBox>
|
||||
</MuiCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileDetails
|
129
frontend/src/components/FileList.tsx
Normal file
129
frontend/src/components/FileList.tsx
Normal file
|
@ -0,0 +1,129 @@
|
|||
import { useCallback } from "react"
|
||||
|
||||
import MuiArticleIcon from "@mui/icons-material/Article"
|
||||
import MuiDeleteIcon from "@mui/icons-material/Delete"
|
||||
import MuiDownloadIcon from "@mui/icons-material/Download"
|
||||
import MuiList from "@mui/material/List"
|
||||
import MuiListItem from "@mui/material/ListItem"
|
||||
import MuiListItemText from "@mui/material/ListItemText"
|
||||
import MuiListItemButton from "@mui/material/ListItemButton"
|
||||
import MuiListItemIcon from "@mui/material/ListItemIcon"
|
||||
import MuiIconButton from "@mui/material/IconButton"
|
||||
import MuiTypography from "@mui/material/Typography"
|
||||
|
||||
import { byteSizeToUnits } from "../utils"
|
||||
import { useLocationContext } from "../contexts/LocationContext"
|
||||
import { useAsyncTaskContext } from "../contexts/AsyncTaskContext"
|
||||
|
||||
interface FileListItemData {
|
||||
/* Displayed title of the item. */
|
||||
title: string
|
||||
/* Filename of the item as it appears on disk. */
|
||||
filename: string
|
||||
/* Size of the file in bytes. */
|
||||
size: number
|
||||
/* Unique identifier */
|
||||
uid: string
|
||||
}
|
||||
|
||||
interface FileListProps {
|
||||
data: Array<FileListItemData>
|
||||
}
|
||||
|
||||
interface FileListItemProps {
|
||||
title: string
|
||||
size: number
|
||||
filename: string
|
||||
onClickHandler: () => void
|
||||
onDeleteHandler: () => void
|
||||
onDownloadHandler: () => void
|
||||
}
|
||||
|
||||
function FileListItem({
|
||||
title,
|
||||
size,
|
||||
filename,
|
||||
onClickHandler,
|
||||
onDeleteHandler,
|
||||
onDownloadHandler,
|
||||
}: FileListItemProps) {
|
||||
const getSecondaryActions = useCallback(
|
||||
() => (
|
||||
<>
|
||||
<MuiIconButton
|
||||
aria-label="download item"
|
||||
onClick={() => onDownloadHandler()}
|
||||
>
|
||||
<MuiDownloadIcon />
|
||||
</MuiIconButton>
|
||||
<MuiIconButton
|
||||
aria-label="delete item"
|
||||
onClick={() => onDeleteHandler()}
|
||||
>
|
||||
<MuiDeleteIcon />
|
||||
</MuiIconButton>
|
||||
</>
|
||||
),
|
||||
[onDeleteHandler, onDownloadHandler],
|
||||
)
|
||||
|
||||
return (
|
||||
<MuiListItem disablePadding secondaryAction={getSecondaryActions()}>
|
||||
<MuiListItemButton onClick={onClickHandler}>
|
||||
<MuiListItemIcon>
|
||||
<MuiArticleIcon />
|
||||
</MuiListItemIcon>
|
||||
<MuiListItemText
|
||||
primaryTypographyProps={{ "aria-label": "item title" }}
|
||||
primary={title}
|
||||
secondary={byteSizeToUnits(size)}
|
||||
secondaryTypographyProps={{ "aria-label": "item size" }}
|
||||
/>
|
||||
</MuiListItemButton>
|
||||
</MuiListItem>
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* FileList represents an interactive list of files displayed to the user.
|
||||
*/
|
||||
function FileList({ data }: FileListProps) {
|
||||
const { tasks } = useAsyncTaskContext()
|
||||
const { navigate } = useLocationContext()
|
||||
|
||||
const onClickHandler = (uid: string) => {
|
||||
navigate(`/item/${uid}/`)
|
||||
}
|
||||
const onDownloadHandler = () => {
|
||||
console.log("download")
|
||||
}
|
||||
const onDeleteHandler = () => {
|
||||
console.log("delete")
|
||||
}
|
||||
|
||||
const dataWithPlaceholders = [...tasks, ...data]
|
||||
|
||||
const getListItems = useCallback(() => {
|
||||
return dataWithPlaceholders.map((itemData) => (
|
||||
<FileListItem
|
||||
title={itemData.title ?? itemData.filename}
|
||||
size={itemData.size}
|
||||
filename={itemData.filename}
|
||||
onClickHandler={() =>
|
||||
onClickHandler("uid" in itemData ? itemData.uid : "")
|
||||
}
|
||||
onDownloadHandler={onDownloadHandler}
|
||||
onDeleteHandler={onDeleteHandler}
|
||||
key={`file list item ${itemData.filename}`}
|
||||
/>
|
||||
))
|
||||
}, [dataWithPlaceholders])
|
||||
|
||||
return (
|
||||
<MuiList sx={{ width: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{getListItems()}
|
||||
</MuiList>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileList
|
56
frontend/src/components/NavigationBar.tsx
Normal file
56
frontend/src/components/NavigationBar.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { useRef } from "react"
|
||||
|
||||
import AppBar from "@mui/material/AppBar"
|
||||
import Toolbar from "@mui/material/Toolbar"
|
||||
import Button from "@mui/material/Button"
|
||||
import Typography from "@mui/material/Typography"
|
||||
import UploadIcon from "@mui/icons-material/Upload"
|
||||
|
||||
import { useAsyncTaskContext } from "../contexts/AsyncTaskContext"
|
||||
|
||||
function UploadFileButton() {
|
||||
const fileRef = useRef(null)
|
||||
const { addTask, tasks } = useAsyncTaskContext()
|
||||
|
||||
const uploadFile = () => {
|
||||
fileRef.current.click()
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Button color="inherit" startIcon={<UploadIcon />} onClick={uploadFile}>
|
||||
Upload file
|
||||
</Button>
|
||||
<input
|
||||
style={{ display: "none" }}
|
||||
type="file"
|
||||
ref={fileRef}
|
||||
onChange={(e) => {
|
||||
if (e.target.files.length === 0) return
|
||||
|
||||
const selectedFile = e.target.files[0]
|
||||
addTask({
|
||||
type: "upload",
|
||||
filename: selectedFile.name,
|
||||
size: selectedFile.size,
|
||||
title: selectedFile.name,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationBar() {
|
||||
return (
|
||||
<AppBar position="sticky" sx={{ display: "flex" }}>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
Rotini
|
||||
</Typography>
|
||||
<UploadFileButton />
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
)
|
||||
}
|
||||
|
||||
export default NavigationBar
|
66
frontend/src/contexts/AsyncTaskContext.tsx
Normal file
66
frontend/src/contexts/AsyncTaskContext.tsx
Normal file
|
@ -0,0 +1,66 @@
|
|||
import {
|
||||
createContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useContext,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
|
||||
interface UploadTaskData {
|
||||
type: string
|
||||
filename: string
|
||||
title: string
|
||||
size: number
|
||||
}
|
||||
|
||||
type AsyncTask = UploadTaskData
|
||||
|
||||
interface AsyncTaskContextData {
|
||||
/* Tasks currently tracked by the system. */
|
||||
tasks: Array<AsyncTask>
|
||||
/* Utility to append a task to the tasklist. */
|
||||
addTask: (t: AsyncTask) => void
|
||||
}
|
||||
|
||||
const defaultData: AsyncTaskContextData = { tasks: [], addTask: () => {} }
|
||||
|
||||
const _AsyncTaskContext = createContext<AsyncTaskContextData>(defaultData)
|
||||
|
||||
function AsyncTaskContext({
|
||||
children,
|
||||
initialValue,
|
||||
}: { children: ReactNode; initialValue?: Array<AsyncTask> }) {
|
||||
const [asyncTaskData, setAsyncTaskData] = useState<Array<AsyncTask>>(
|
||||
initialValue ?? [],
|
||||
)
|
||||
|
||||
const addTask = useCallback(
|
||||
(task: AsyncTask) => {
|
||||
setAsyncTaskData([...asyncTaskData, task])
|
||||
},
|
||||
[asyncTaskData, setAsyncTaskData],
|
||||
)
|
||||
|
||||
return (
|
||||
<_AsyncTaskContext.Provider value={{ addTask, tasks: asyncTaskData }}>
|
||||
{children}
|
||||
</_AsyncTaskContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* Hook exposing the asynchronous task data and utility functions.
|
||||
*
|
||||
* This relates to globally-available state tracking active async tasks
|
||||
* such as uploads or other processing.
|
||||
*
|
||||
* Returns an object with fields and functions that can be used
|
||||
* to manipulate this state, see return type.
|
||||
*/
|
||||
function useAsyncTaskContext(): AsyncTaskContextData {
|
||||
return useContext(_AsyncTaskContext)
|
||||
}
|
||||
|
||||
export default AsyncTaskContext
|
||||
|
||||
export { useAsyncTaskContext, AsyncTaskContextData, AsyncTask, UploadTaskData }
|
126
frontend/src/contexts/LocationContext.tsx
Normal file
126
frontend/src/contexts/LocationContext.tsx
Normal file
|
@ -0,0 +1,126 @@
|
|||
import {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
createContext,
|
||||
useContext,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
|
||||
interface Location {
|
||||
path: string
|
||||
label: string | null
|
||||
params: {
|
||||
[key: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
interface LocationContextData {
|
||||
location: Location
|
||||
navigate: (path: string) => void
|
||||
}
|
||||
|
||||
const defaultValue = {
|
||||
location: {
|
||||
path: "",
|
||||
label: "",
|
||||
params: {},
|
||||
},
|
||||
navigate: () => {},
|
||||
}
|
||||
|
||||
const _LocationContext = createContext<LocationContextData>(defaultValue)
|
||||
|
||||
/*
|
||||
* Matches the given path with a list of reference paths. Collects templated
|
||||
* arguments along the way.
|
||||
*
|
||||
* The template format is :<label> such that a path
|
||||
*
|
||||
* /item/:itemId/part/:partId/
|
||||
*
|
||||
* would match
|
||||
*
|
||||
* /item/1/part/2/
|
||||
*
|
||||
* with itemId = 1, partId = 2.
|
||||
*
|
||||
* Note that if no match is found, then a location without parameters is returned.
|
||||
*/
|
||||
function deriveLocation(
|
||||
path: string,
|
||||
urlMap: { [key: string]: Array<string> },
|
||||
): Location {
|
||||
const splitPath = path.split("/").filter((part) => Boolean(part))
|
||||
|
||||
for (const [label, pattern] of Object.entries(urlMap)) {
|
||||
// Cannot be a match if differing lengths.
|
||||
if (pattern.length !== splitPath.length) continue
|
||||
|
||||
let collectedParts: { [key: string]: string } | undefined = {}
|
||||
|
||||
for (let idx = 0; idx < pattern.length; idx++) {
|
||||
if (pattern[idx].startsWith(":")) {
|
||||
const patternLabel = pattern[idx].slice(1)
|
||||
collectedParts[patternLabel] = splitPath[idx]
|
||||
} else if (pattern[idx] !== splitPath[idx]) {
|
||||
collectedParts = undefined
|
||||
break
|
||||
} else continue
|
||||
}
|
||||
|
||||
if (collectedParts !== undefined)
|
||||
return { path, label, params: collectedParts }
|
||||
}
|
||||
|
||||
return { path, label: null, params: {} }
|
||||
}
|
||||
|
||||
/*
|
||||
* The LocationContext handles the current location (as defined by
|
||||
* globalThis.location.pathname) and parses URLs based on known
|
||||
* URL patterns.
|
||||
*/
|
||||
export function LocationContext({
|
||||
children,
|
||||
routes,
|
||||
}: { children: ReactNode; routes: { [key: string]: string } }) {
|
||||
const splitUrlPatterns = useMemo(() => {
|
||||
return Object.entries(routes)
|
||||
.map(([label, pattern]: [string, string]): [string, Array<string>] => [
|
||||
label,
|
||||
pattern.split("/").filter((pattern) => Boolean(pattern)),
|
||||
])
|
||||
.reduce((splitUrlMap, current) => {
|
||||
const [label, pattern] = current
|
||||
splitUrlMap[label] = pattern
|
||||
return splitUrlMap
|
||||
}, {} as { [key: string]: Array<string> })
|
||||
}, [routes])
|
||||
|
||||
const [location, setLocation] = useState<Location>(
|
||||
deriveLocation(globalThis.location.pathname, splitUrlPatterns),
|
||||
)
|
||||
|
||||
const navigate = useCallback(
|
||||
(path: string) => {
|
||||
history.pushState({}, "", path)
|
||||
setLocation(deriveLocation(path, splitUrlPatterns))
|
||||
},
|
||||
[setLocation, deriveLocation, splitUrlPatterns],
|
||||
)
|
||||
|
||||
return (
|
||||
<_LocationContext.Provider value={{ location, navigate }}>
|
||||
{children}
|
||||
</_LocationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function useLocationContext() {
|
||||
return useContext(_LocationContext)
|
||||
}
|
||||
|
||||
export default LocationContext
|
||||
|
||||
export { useLocationContext }
|
15
frontend/src/index.html
Normal file
15
frontend/src/index.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
|
||||
<style>
|
||||
body { background-color: #f5f5f5 }
|
||||
</style>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
12
frontend/src/index.tsx
Normal file
12
frontend/src/index.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
|
||||
import App from "./App"
|
||||
|
||||
const rootNode = document.getElementById("app")
|
||||
|
||||
if (!rootNode) throw new Error("Failed to find app root.")
|
||||
|
||||
const root = ReactDOM.createRoot(rootNode)
|
||||
|
||||
root.render(<App />)
|
10
frontend/src/utils.ts
Normal file
10
frontend/src/utils.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
function byteSizeToUnits(byteSize: number): string {
|
||||
if (byteSize < 1000) return `${byteSize} B`
|
||||
|
||||
if (byteSize < 1_000_000) return `${(byteSize / 1000).toFixed(2)} kB`
|
||||
|
||||
if (byteSize < 1_000_000_000) return `${(byteSize / 1_000_000).toFixed(2)} mB`
|
||||
else return `${(byteSize / 1_000_000_000).toFixed(2)} gB`
|
||||
}
|
||||
|
||||
export { byteSizeToUnits }
|
64
frontend/tests/FileList.test.tsx
Normal file
64
frontend/tests/FileList.test.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { within } from "@testing-library/dom"
|
||||
|
||||
import { renderWithContexts as render } from "./helpers"
|
||||
import FileList from "../src/components/FileList"
|
||||
|
||||
describe("FileList", () => {
|
||||
const mockItems = [
|
||||
{ title: "Item 1", filename: "item1.txt", size: 1, uid: "123" },
|
||||
{ title: "Item 2", filename: "item2.txt", size: 1, uid: "456" },
|
||||
]
|
||||
|
||||
const mockAsyncTasks = [
|
||||
{ title: "Async Item 0", filename: "async.txt", size: 2, type: "upload" },
|
||||
]
|
||||
|
||||
test("Renders list items provided", () => {
|
||||
const { getAllByText } = render(<FileList data={mockItems} />)
|
||||
const renderedItems = getAllByText(/Item/)
|
||||
|
||||
expect(renderedItems.length).toEqual(mockItems.length)
|
||||
})
|
||||
|
||||
test("Prepends items in flight as tracked by async task context", () => {
|
||||
const { getAllByText, getByText } = render(
|
||||
<FileList data={[mockItems[0]]} />,
|
||||
{ asyncTaskContext: mockAsyncTasks },
|
||||
)
|
||||
|
||||
const renderedItems = getAllByText(/Item/)
|
||||
|
||||
expect(renderedItems.length).toEqual(2)
|
||||
|
||||
within(renderedItems[0]).getByText(mockAsyncTasks[0].title)
|
||||
within(renderedItems[1]).getByText(mockItems[0].title)
|
||||
})
|
||||
|
||||
describe("FileListItem", () => {
|
||||
test("Renders the item title", () => {
|
||||
const { getByLabelText, debug } = render(
|
||||
<FileList data={[mockItems[0]]} />,
|
||||
)
|
||||
const title = getByLabelText("item title")
|
||||
within(title).getByText(mockItems[0].title)
|
||||
})
|
||||
|
||||
test("Renders the item size", () => {
|
||||
const { getByLabelText, debug } = render(
|
||||
<FileList data={[mockItems[0]]} />,
|
||||
)
|
||||
const title = getByLabelText("item size")
|
||||
within(title).getByText(`${mockItems[0].size} B`)
|
||||
})
|
||||
|
||||
test.each(["download item", "delete item"])(
|
||||
"Renders secondary action buttons (%s)",
|
||||
(action) => {
|
||||
const { getByLabelText, debug } = render(
|
||||
<FileList data={[mockItems[0]]} />,
|
||||
)
|
||||
getByLabelText(action)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
31
frontend/tests/helpers.tsx
Normal file
31
frontend/tests/helpers.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { type ReactNode } from "react"
|
||||
import { render } from "@testing-library/react"
|
||||
|
||||
import AsyncTaskContext, {
|
||||
type AsyncTask,
|
||||
} from "../src/contexts/AsyncTaskContext"
|
||||
import LocationContext from "../src/contexts/LocationContext"
|
||||
|
||||
interface ContextInitialValues {
|
||||
asyncTaskContext: Array<AsyncTask>
|
||||
locationContext: { [key: string]: string }
|
||||
}
|
||||
|
||||
const defaultContextValues = {
|
||||
asyncTaskContext: [],
|
||||
locationContext: { default: "/" },
|
||||
}
|
||||
|
||||
export function renderWithContexts(
|
||||
component: ReactNode,
|
||||
initialValues?: Partial<ContextInitialValues>,
|
||||
) {
|
||||
const contextValues = { ...defaultContextValues, ...(initialValues ?? {}) }
|
||||
return render(
|
||||
<LocationContext routes={contextValues.locationContext}>
|
||||
<AsyncTaskContext initialValue={contextValues.asyncTaskContext}>
|
||||
{component}
|
||||
</AsyncTaskContext>
|
||||
</LocationContext>,
|
||||
)
|
||||
}
|
16
frontend/tsconfig.json
Normal file
16
frontend/tsconfig.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"jsx": "react-jsx",
|
||||
"module": "commonjs",
|
||||
"rootDir": "./src",
|
||||
"moduleResolution": "node",
|
||||
"allowJs": false,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["jest"]
|
||||
}
|
||||
}
|
7064
frontend/yarn.lock
Normal file
7064
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
Reference in a new issue