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:
Marc 2023-08-05 12:46:28 -04:00 committed by GitHub
parent acb4005fbe
commit 25ae69f6f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 7978 additions and 0 deletions

192
.github/workflows/ci.yml vendored Normal file
View 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
View file

@ -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
View file

@ -0,0 +1 @@
lts/hydrogen

0
frontend/.yarnrc.yml Normal file
View file

8
frontend/jest.config.js Normal file
View 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
View 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
View 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"
}
}
}

View file

@ -0,0 +1,7 @@
#!/bin/bash
if [[ $CI != 1 ]]; then
nvm use
fi
corepack enable && yarn

59
frontend/src/App.tsx Normal file
View 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

View 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

View 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

View 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

View 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 }

View 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
View 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
View 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
View 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 }

View 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)
},
)
})
})

View 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
View 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

File diff suppressed because it is too large Load diff