feat(ws): initial commit for frontend (#19)

* feat: client ui frontend scaffolding

In this PR:
- UI frontend scaffolding
- Github Action for frontend and backend

Most of the content of this PR is extract from https://github.com/patternfly/patternfly-react-seed/tree/v6. Thank you so much patternfly team for the seed!

Signed-off-by: Eder Ignatowicz <ignatowicz@gmail.com>

* Changes requested by code review

Signed-off-by: Eder Ignatowicz <ignatowicz@gmail.com>

* Fixing icons

Signed-off-by: Eder Ignatowicz <ignatowicz@gmail.com>

---------

Signed-off-by: Eder Ignatowicz <ignatowicz@gmail.com>
This commit is contained in:
Eder Ignatowicz 2024-06-27 20:48:41 -04:00 committed by GitHub
parent 3e89605c4d
commit b0367e8b3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 19842 additions and 1 deletions

35
.github/workflows/ui-bff-build.yml vendored Normal file
View File

@ -0,0 +1,35 @@
name: UI - BFF - Test and Build
on:
push:
branches: [ "main", "notebooks-v2", "v*-branch" ]
pull_request:
paths: [ "workspaces/backend/**" ]
branches: [ "main", "notebooks-v2", "v*-branch" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: workspaces/backend/go.mod
check-latest: true
cache-dependency-path: workspaces/backend/go.sum
- name: Build
working-directory: workspaces/backend
run: make build
- name: Check if there are uncommitted file changes
working-directory: workspaces/backend
run: |
clean=$(git status --porcelain)
if [[ -z "$clean" ]]; then
echo "Empty git status --porcelain: $clean"
else
echo "Uncommitted file changes detected: $clean"
git diff
exit 1
fi

45
.github/workflows/ui-frontend-build.yml vendored Normal file
View File

@ -0,0 +1,45 @@
name: UI - Frontend - Test and Build
on:
push:
branches: [ "main", "notebooks-v2", "v*-branch" ]
pull_request:
paths: [ "workspaces/frontend/**" ]
branches: [ "main", "notebooks-v2", "v*-branch" ]
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
working-directory: workspaces/frontend
run: npm install
- name: Run tests
working-directory: workspaces/frontend
run: npm run test:coverage
- name: Clean
working-directory: workspaces/frontend
run: npm run clean
- name: Build
working-directory: workspaces/frontend
run: npm run build
- name: Check if there are uncommitted file changes
working-directory: workspaces/frontend
run: |
clean=$(git status --porcelain)
if [[ -z "$clean" ]]; then
echo "Empty git status --porcelain: $clean"
else
echo "Uncommitted file changes detected: $clean"
git diff
exit 1
fi

View File

@ -1,5 +1,5 @@
# Kubeflow Workspaces Backend # Kubeflow Workspaces Backend
The Kubeflow Workspaces Backend is the _backend for frontend_ (BFF) used by the Kubeflow Workspaces UI as part of [Kubeflow Notebooks 2.0](https://github.com/kubeflow/kubeflow/issues/7156). The Kubeflow Workspaces Backend is the _backend for frontend_ (BFF) used by the Kubeflow Workspaces Frontend as part of [Kubeflow Notebooks 2.0](https://github.com/kubeflow/kubeflow/issues/7156).
> ⚠️ __Warning__ ⚠️ > ⚠️ __Warning__ ⚠️
> >

8
workspaces/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
**/node_modules
dist
yarn-error.log
yarn.lock
stats.json
coverage
.idea
.env

View File

@ -0,0 +1,43 @@
# Kubeflow Workspaces Frontend
The Kubeflow Workspaces Frontend is the web user interface used to monitor and manage Kubeflow Workspaces as part of [Kubeflow Notebooks 2.0](https://github.com/kubeflow/kubeflow/issues/7156).
> ⚠️ __Warning__ ⚠️
>
> The Kubeflow Workspaces Frontend is a work in progress and is __NOT__ currently ready for use.
> We greatly appreciate any contributions.
## Pre-requisites:
TBD
## Development
```sh
# Install development/build dependencies
npm install
# Start the development server
npm run start:dev
# Run a production build (outputs to "dist" dir)
npm run build
# Run the test suite
npm run test
# Run the test suite with coverage
npm run test:coverage
# Run the linter
npm run lint
# Run the code formatter
npm run format
# Launch a tool to inspect the bundle size
npm run bundle-profile:analyze
# Start the express server (run a production build first)
npm run start
```

View File

@ -0,0 +1 @@
module.exports = {};

View File

@ -0,0 +1,32 @@
// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html
module.exports = {
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
collectCoverage: false,
// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',
// An array of directory names to be searched recursively up from the requiring module's location
moduleDirectories: [
'node_modules',
'<rootDir>/src'
],
// A map from regular expressions to module names that allow to stub out resources with a single module
moduleNameMapper: {
'\\.(css|less)$': '<rootDir>/__mocks__/styleMock.js',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/__mocks__/fileMock.js',
'@app/(.*)': '<rootDir>/src/app/$1'
},
// A preset that is used as a base for Jest's configuration
preset: 'ts-jest/presets/js-with-ts',
// The test environment that will be used for testing.
testEnvironment: 'jsdom'
};

18702
workspaces/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,80 @@
{
"name": "kubeflow-workspaces-frontend",
"version": "0.0.1",
"description": "The Kubeflow Workspaces Frontend is the web user interface used to monitor and manage Kubeflow Workspaces as part of Kubeflow Notebooks 2.0",
"repository": "https://github.com/kubeflow/notebooks.git",
"homepage": "https://github.com/kubeflow/notebooks",
"license": "Apache-2.0",
"private": true,
"scripts": {
"prebuild": "npm run type-check && npm run clean",
"build": "webpack --config webpack.prod.js",
"start": "sirv dist --cors --single --host --port 8080",
"start:dev": "webpack serve --color --progress --config webpack.dev.js",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"eslint": "eslint --ext .tsx,.js ./src/",
"lint": "npm run eslint",
"format": "prettier --check --write ./src/**/*.{tsx,ts}",
"type-check": "tsc --noEmit",
"ci-checks": "npm run type-check && npm run lint && npm run test:coverage",
"build:bundle-profile": "webpack --config webpack.prod.js --profile --json > stats.json",
"bundle-profile:analyze": "npm run build:bundle-profile && webpack-bundle-analyzer ./stats.json",
"clean": "rimraf dist"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "14.4.3",
"@types/jest": "^29.5.3",
"@types/react-router-dom": "^5.3.3",
"@types/victory": "^33.1.5",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.11.0",
"css-minimizer-webpack-plugin": "^5.0.1",
"dotenv-webpack": "^8.0.1",
"eslint": "^8.57.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.34.2",
"eslint-plugin-react-hooks": "^4.6.2",
"html-webpack-plugin": "^5.6.0",
"imagemin": "^8.0.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"mini-css-extract-plugin": "^2.9.0",
"postcss": "^8.4.38",
"prettier": "^3.3.0",
"prop-types": "^15.8.1",
"raw-loader": "^4.0.2",
"react-axe": "^3.5.4",
"react-docgen-typescript-loader": "^3.7.2",
"react-router-dom": "^5.3.4",
"regenerator-runtime": "^0.13.11",
"rimraf": "^5.0.7",
"style-loader": "^3.3.4",
"svg-url-loader": "^8.0.0",
"terser-webpack-plugin": "^5.3.10",
"ts-jest": "^29.1.4",
"ts-loader": "^9.5.1",
"tsconfig-paths-webpack-plugin": "^4.1.0",
"tslib": "^2.6.0",
"typescript": "^5.4.5",
"url-loader": "^4.1.1",
"webpack": "^5.91.0",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.2",
"webpack-merge": "^5.10.0"
},
"dependencies": {
"@patternfly/react-core": "6.0.0-alpha.68",
"@patternfly/react-icons": "6.0.0-alpha.24",
"@patternfly/react-styles": "6.0.0-alpha.24",
"react": "^18",
"react-dom": "^18",
"sirv-cli": "^2.0.2"
}
}

View File

@ -0,0 +1,107 @@
import * as React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import {
Button,
Flex,
Masthead,
MastheadContent,
MastheadToggle,
Nav,
NavExpandable,
NavItem,
NavList,
Page,
PageSidebar,
PageSidebarBody,
SkipToContent,
Title
} from '@patternfly/react-core';
import { IAppRoute, IAppRouteGroup, routes } from '@app/routes';
import { BarsIcon } from '@patternfly/react-icons';
interface IAppLayout {
children: React.ReactNode;
}
const AppLayout: React.FunctionComponent<IAppLayout> = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = React.useState(true);
const masthead = (
<Masthead>
<MastheadToggle>
<Button variant="plain" onClick={() => setSidebarOpen(!sidebarOpen)} aria-label="Global navigation">
<BarsIcon />
</Button>
</MastheadToggle>
<MastheadContent>
<Flex>
<Title headingLevel="h2" size="3xl">
Kubeflow Workspaces
</Title>
</Flex>
</MastheadContent>
</Masthead>
);
const location = useLocation();
const renderNavItem = (route: IAppRoute, index: number) => (
<NavItem key={`${route.label}-${index}`} id={`${route.label}-${index}`} isActive={route.path === location.pathname}>
<NavLink exact={route.exact} to={route.path}>
{route.label}
</NavLink>
</NavItem>
);
const renderNavGroup = (group: IAppRouteGroup, groupIndex: number) => (
<NavExpandable
key={`${group.label}-${groupIndex}`}
id={`${group.label}-${groupIndex}`}
title={group.label}
isActive={group.routes.some((route) => route.path === location.pathname)}
>
{group.routes.map((route, idx) => route.label && renderNavItem(route, idx))}
</NavExpandable>
);
const Navigation = (
<Nav id="nav-primary-simple">
<NavList id="nav-list-simple">
{routes.map(
(route, idx) => route.label && (!route.routes ? renderNavItem(route, idx) : renderNavGroup(route, idx))
)}
</NavList>
</Nav>
);
const Sidebar = (
<PageSidebar>
<PageSidebarBody>
{Navigation}
</PageSidebarBody>
</PageSidebar>
);
const pageId = 'primary-app-container';
const PageSkipToContent = (
<SkipToContent onClick={(event) => {
event.preventDefault();
const primaryContentContainer = document.getElementById(pageId);
primaryContentContainer && primaryContentContainer.focus();
}} href={`#${pageId}`}>
Skip to Content
</SkipToContent>
);
return (
<Page
mainContainerId={pageId}
masthead={masthead}
sidebar={sidebarOpen && Sidebar}
skipToContent={PageSkipToContent}>
{children}
</Page>
);
};
export { AppLayout };

View File

@ -0,0 +1,31 @@
import * as React from 'react';
import {
Button,
EmptyState,
EmptyStateBody,
EmptyStateFooter,
EmptyStateVariant,
PageSection,
Text,
TextContent
} from '@patternfly/react-core';
import { CubesIcon } from '@patternfly/react-icons';
const Dashboard: React.FunctionComponent = () => (
<PageSection>
<EmptyState variant={EmptyStateVariant.full} titleText="Empty State (Dashboard Module)" icon={CubesIcon}>
<EmptyStateBody>
<TextContent>
<Text component="p">
This represents an the empty state pattern in Patternfly 6. Hopefully it&apos;s simple enough to use but
flexible enough to meet a variety of needs.
</Text>
</TextContent>
</EmptyStateBody><EmptyStateFooter>
<Button variant="primary">Primary Action</Button>
</EmptyStateFooter></EmptyState>
</PageSection>
);
export { Dashboard };

View File

@ -0,0 +1,31 @@
import * as React from 'react';
import { ExclamationTriangleIcon } from '@patternfly/react-icons';
import { Button, EmptyState, EmptyStateBody, EmptyStateFooter, PageSection } from '@patternfly/react-core';
import { useHistory } from 'react-router-dom';
const NotFound: React.FunctionComponent = () => {
function GoHomeBtn() {
const history = useHistory();
function handleClick() {
history.push('/');
}
return (
<Button onClick={handleClick}>Take me home</Button>
);
}
return (
<PageSection>
<EmptyState titleText="404 Page not found" variant="full" icon={ExclamationTriangleIcon}>
<EmptyStateBody>
We didn&apos;t find a page that matches the address you navigated to.
</EmptyStateBody><EmptyStateFooter>
<GoHomeBtn />
</EmptyStateFooter></EmptyState>
</PageSection>
);
};
export { NotFound };

View File

@ -0,0 +1,31 @@
import * as React from 'react';
import { CubesIcon } from '@patternfly/react-icons';
import {
Button,
EmptyState,
EmptyStateBody,
EmptyStateFooter,
EmptyStateVariant,
PageSection,
Text,
TextContent
} from '@patternfly/react-core';
const Support: React.FunctionComponent = () => (
<PageSection>
<EmptyState variant={EmptyStateVariant.full} titleText="Empty State (Stub Support Module)" icon={CubesIcon}>
<EmptyStateBody>
<TextContent>
<Text component="p">
This represents an the empty state pattern in Patternfly 6. Hopefully it&apos;s simple enough to use but
flexible enough to meet a variety of needs.
</Text>
</TextContent>
</EmptyStateBody><EmptyStateFooter>
<Button variant="primary">Primary Action</Button>
</EmptyStateFooter></EmptyState>
</PageSection>
);
export { Support };

View File

@ -0,0 +1,206 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`App tests should render default App component 1`] = `
<DocumentFragment>
<div
class="pf-v6-c-page"
>
<div
class="pf-v6-c-skip-to-content"
>
<a
aria-disabled="false"
class="pf-v6-c-button pf-m-primary"
data-ouia-component-id="OUIA-Generated-Button-primary-1"
data-ouia-component-type="PF6/Button"
data-ouia-safe="true"
href="#primary-app-container"
>
Skip to Content
</a>
</div>
<header
class="pf-v6-c-masthead pf-m-display-inline-on-md"
>
<span
class="pf-v6-c-masthead__toggle"
>
<button
aria-disabled="false"
aria-label="Global navigation"
class="pf-v6-c-button pf-m-plain"
data-ouia-component-id="OUIA-Generated-Button-plain-1"
data-ouia-component-type="PF6/Button"
data-ouia-safe="true"
type="button"
>
<svg
aria-hidden="true"
class="pf-v6-svg"
fill="currentColor"
height="1em"
role="img"
viewBox="0 0 448 512"
width="1em"
>
<path
d="M16 132h416c8.837 0 16-7.163 16-16V76c0-8.837-7.163-16-16-16H16C7.163 60 0 67.163 0 76v40c0 8.837 7.163 16 16 16zm0 160h416c8.837 0 16-7.163 16-16v-40c0-8.837-7.163-16-16-16H16c-8.837 0-16 7.163-16 16v40c0 8.837 7.163 16 16 16zm0 160h416c8.837 0 16-7.163 16-16v-40c0-8.837-7.163-16-16-16H16c-8.837 0-16 7.163-16 16v40c0 8.837 7.163 16 16 16z"
/>
</svg>
</button>
</span>
<div
class="pf-v6-c-masthead__content"
>
<div
class="pf-v6-l-flex"
>
<h2
class="pf-v6-c-title pf-m-3xl"
data-ouia-component-id="OUIA-Generated-Title-1"
data-ouia-component-type="PF6/Title"
data-ouia-safe="true"
>
Kubeflow Workspaces
</h2>
</div>
</div>
</header>
<div
aria-hidden="false"
class="pf-v6-c-page__sidebar pf-m-expanded"
id="page-sidebar"
>
<div
class="pf-v6-c-page__sidebar-body"
>
<nav
aria-label="Global"
class="pf-v6-c-nav"
data-ouia-component-id="OUIA-Generated-Nav-1"
data-ouia-component-type="PF6/Nav"
data-ouia-safe="true"
id="nav-primary-simple"
>
<ul
class="pf-v6-c-nav__list"
id="nav-list-simple"
role="list"
>
<li
class="pf-v6-c-nav__item"
data-ouia-component-id="OUIA-Generated-NavItem-1"
data-ouia-component-type="PF6/NavItem"
data-ouia-safe="true"
>
<a
aria-current="page"
class="pf-v6-c-nav__link pf-m-current active"
href="/"
>
Dashboard
</a>
</li>
<li
class="pf-v6-c-nav__item"
data-ouia-component-id="OUIA-Generated-NavItem-2"
data-ouia-component-type="PF6/NavItem"
data-ouia-safe="true"
>
<a
class="pf-v6-c-nav__link"
href="/support"
>
Support
</a>
</li>
</ul>
</nav>
</div>
</div>
<div
class="pf-v6-c-page__main-container"
>
<main
class="pf-v6-c-page__main"
id="primary-app-container"
tabindex="-1"
>
<section
class="pf-v6-c-page__main-section"
>
<div
class="pf-v6-c-empty-state"
>
<div
class="pf-v6-c-empty-state__content"
>
<div
class="pf-v6-c-empty-state__header"
>
<div
class="pf-v6-c-empty-state__icon"
>
<svg
aria-hidden="true"
class="pf-v6-svg"
fill="currentColor"
height="1em"
role="img"
viewBox="0 0 512 512"
width="1em"
>
<path
d="M488.6 250.2L392 214V105.5c0-15-9.3-28.4-23.4-33.7l-100-37.5c-8.1-3.1-17.1-3.1-25.3 0l-100 37.5c-14.1 5.3-23.4 18.7-23.4 33.7V214l-96.6 36.2C9.3 255.5 0 268.9 0 283.9V394c0 13.6 7.7 26.1 19.9 32.2l100 50c10.1 5.1 22.1 5.1 32.2 0l103.9-52 103.9 52c10.1 5.1 22.1 5.1 32.2 0l100-50c12.2-6.1 19.9-18.6 19.9-32.2V283.9c0-15-9.3-28.4-23.4-33.7zM358 214.8l-85 31.9v-68.2l85-37v73.3zM154 104.1l102-38.2 102 38.2v.6l-102 41.4-102-41.4v-.6zm84 291.1l-85 42.5v-79.1l85-38.8v75.4zm0-112l-102 41.4-102-41.4v-.6l102-38.2 102 38.2v.6zm240 112l-85 42.5v-79.1l85-38.8v75.4zm0-112l-102 41.4-102-41.4v-.6l102-38.2 102 38.2v.6z"
/>
</svg>
</div>
<div
class="pf-v6-c-empty-state__title"
>
<h1
class="pf-v6-c-empty-state__title-text"
>
Empty State (Dashboard Module)
</h1>
</div>
</div>
<div
class="pf-v6-c-empty-state__body"
>
<div
class="pf-v6-c-content"
>
<p
class="pf-v6-c-content--p"
data-ouia-component-id="OUIA-Generated-Text-1"
data-ouia-component-type="PF6/Text"
data-ouia-safe="true"
data-pf-content="true"
>
This represents an the empty state pattern in Patternfly 6. Hopefully it's simple enough to use but flexible enough to meet a variety of needs.
</p>
</div>
</div>
<div
class="pf-v6-c-empty-state__footer"
>
<button
aria-disabled="false"
class="pf-v6-c-button pf-m-primary"
data-ouia-component-id="OUIA-Generated-Button-primary-2"
data-ouia-component-type="PF6/Button"
data-ouia-safe="true"
type="button"
>
Primary Action
</button>
</div>
</div>
</div>
</section>
</main>
</div>
</div>
</DocumentFragment>
`;

View File

@ -0,0 +1,5 @@
html,
body,
#root {
height: 100%;
}

View File

@ -0,0 +1,20 @@
import * as React from "react";
import App from "@app/index";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
describe("App tests", () => {
test("should render default App component", () => {
const { asFragment } = render(<App />);
expect(asFragment()).toMatchSnapshot();
});
it("should render a nav-toggle button", () => {
render(<App />);
expect(
screen.getByRole("button", { name: "Global navigation" }),
).toBeVisible();
});
});

View File

@ -0,0 +1,16 @@
import * as React from "react";
import "@patternfly/react-core/dist/styles/base.css";
import { BrowserRouter as Router } from "react-router-dom";
import { AppLayout } from "@app/AppLayout/AppLayout";
import { AppRoutes } from "@app/routes";
import "@app/app.css";
const App: React.FunctionComponent = () => (
<Router>
<AppLayout>
<AppRoutes />
</AppLayout>
</Router>
);
export default App;

View File

@ -0,0 +1,114 @@
import * as React from "react";
import {
Route,
RouteComponentProps,
Switch,
useLocation,
} from "react-router-dom";
import { Dashboard } from "@app/Dashboard/Dashboard";
import { Support } from "@app/Support/Support";
import { NotFound } from "@app/NotFound/NotFound";
import { useDocumentTitle } from "@app/utils/useDocumentTitle";
let routeFocusTimer: number;
export interface IAppRoute {
label?: string; // Excluding the label will exclude the route from the nav sidebar in AppLayout
/* eslint-disable @typescript-eslint/no-explicit-any */
component:
| React.ComponentType<RouteComponentProps<any>>
| React.ComponentType<any>;
/* eslint-enable @typescript-eslint/no-explicit-any */
exact?: boolean;
path: string;
title: string;
routes?: undefined;
}
export interface IAppRouteGroup {
label: string;
routes: IAppRoute[];
}
export type AppRouteConfig = IAppRoute | IAppRouteGroup;
const routes: AppRouteConfig[] = [
{
component: Dashboard,
exact: true,
label: "Dashboard",
path: "/",
title: "Kubeflow Workspaces | Main",
},
{
component: Support,
exact: true,
label: "Support",
path: "/support",
title: "Kubeflow Workspaces | Support Page",
},
];
// a custom hook for sending focus to the primary content container
// after a view has loaded so that subsequent press of tab key
// sends focus directly to relevant content
// may not be necessary if https://github.com/ReactTraining/react-router/issues/5210 is resolved
const useA11yRouteChange = () => {
const { pathname } = useLocation();
React.useEffect(() => {
routeFocusTimer = window.setTimeout(() => {
const mainContainer = document.getElementById("primary-app-container");
if (mainContainer) {
mainContainer.focus();
}
}, 50);
return () => {
window.clearTimeout(routeFocusTimer);
};
}, [pathname]);
};
const RouteWithTitleUpdates = ({
component: Component,
title,
...rest
}: IAppRoute) => {
useA11yRouteChange();
useDocumentTitle(title);
function routeWithTitle(routeProps: RouteComponentProps) {
return <Component {...rest} {...routeProps} />;
}
return <Route render={routeWithTitle} {...rest} />;
};
const PageNotFound = ({ title }: { title: string }) => {
useDocumentTitle(title);
return <Route component={NotFound} />;
};
const flattenedRoutes: IAppRoute[] = routes.reduce(
(flattened, route) => [
...flattened,
...(route.routes ? route.routes : [route]),
],
[] as IAppRoute[],
);
const AppRoutes = (): React.ReactElement => (
<Switch>
{flattenedRoutes.map(({ path, exact, component, title }, idx) => (
<RouteWithTitleUpdates
path={path}
exact={exact}
component={component}
key={idx}
title={title}
/>
))}
<PageNotFound title="404 Page Not Found" />
</Switch>
);
export { AppRoutes, routes };

View File

@ -0,0 +1,13 @@
import * as React from "react";
// a custom hook for setting the page title
export function useDocumentTitle(title: string) {
React.useEffect(() => {
const originalTitle = document.title;
document.title = title;
return () => {
document.title = originalTitle;
};
}, [title]);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<title>Kubeflow Workspaces</title>
<meta content="Kubeflow Workspaces" id="appName" name="application-name">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<link href="/images/favicon.ico" rel="icon" type="image/x-icon">
<base href="/">
</head>
<body>
<noscript>Enabling JavaScript is required to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,25 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from '@app/index';
if (process.env.NODE_ENV !== 'production') {
const config = {
rules: [
{
id: 'color-contrast',
enabled: false
}
]
};
// eslint-disable-next-line @typescript-eslint/no-var-requires, no-undef
const axe = require('react-axe');
axe(React, ReactDOM, 1000, config);
}
const root = ReactDOM.createRoot(document.getElementById('root') as Element);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

12
workspaces/frontend/src/typings.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
declare module "*.png";
declare module "*.jpg";
declare module "*.jpeg";
declare module "*.gif";
declare module "*.svg";
declare module "*.css";
declare module "*.wav";
declare module "*.mp3";
declare module "*.m4a";
declare module "*.rdf";
declare module "*.ttl";
declare module "*.pdf";

View File

@ -0,0 +1,14 @@
const path = require('path');
module.exports = {
stylePaths: [
path.resolve(__dirname, 'src'),
path.resolve(__dirname, 'node_modules/patternfly'),
path.resolve(__dirname, 'node_modules/@patternfly/patternfly'),
path.resolve(__dirname, 'node_modules/@patternfly/react-styles/css'),
path.resolve(__dirname, 'node_modules/@patternfly/react-core/dist/styles/base.css'),
path.resolve(__dirname, 'node_modules/@patternfly/react-core/dist/esm/@patternfly/patternfly'),
path.resolve(__dirname, 'node_modules/@patternfly/react-core/node_modules/@patternfly/react-styles/css'),
path.resolve(__dirname, 'node_modules/@patternfly/react-table/node_modules/@patternfly/react-styles/css'),
path.resolve(__dirname, 'node_modules/@patternfly/react-inline-edit-extension/node_modules/@patternfly/react-styles/css')
]
};

View File

@ -0,0 +1,44 @@
{
"compilerOptions": {
"baseUrl": ".",
"rootDir": ".",
"outDir": "dist",
"module": "esnext",
"target": "ES6",
"lib": [
"es6",
"dom"
],
"sourceMap": true,
"jsx": "react",
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": false,
"allowJs": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"paths": {
"@app/*": [
"src/app/*"
],
"@assets/*": [
"node_modules/@patternfly/react-core/dist/styles/assets/*"
]
},
"importHelpers": true,
"skipLibCheck": true
},
"include": [
"**/*.ts",
"**/*.tsx",
"**/*.jsx",
"**/*.js",
".eslintrc.js"
],
"exclude": [
"node_modules"
]
}

View File

@ -0,0 +1,136 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const Dotenv = require('dotenv-webpack');
const BG_IMAGES_DIRNAME = 'bgimages';
const ASSET_PATH = process.env.ASSET_PATH || '/';
module.exports = (env) => {
return {
module: {
rules: [
{
test: /\.(tsx|ts|jsx)?$/,
use: [
{
loader: 'ts-loader',
options: {
transpileOnly: true,
experimentalWatchApi: true
}
}
]
},
{
test: /\.(svg|ttf|eot|woff|woff2)$/,
type: 'asset/resource',
// only process modules with this loader
// if they live under a 'fonts' or 'pficon' directory
include: [
path.resolve(__dirname, 'node_modules/patternfly/dist/fonts'),
path.resolve(__dirname, 'node_modules/@patternfly/react-core/dist/styles/assets/fonts'),
path.resolve(__dirname, 'node_modules/@patternfly/react-core/dist/styles/assets/pficon'),
path.resolve(__dirname, 'node_modules/@patternfly/patternfly/assets/fonts'),
path.resolve(__dirname, 'node_modules/@patternfly/patternfly/assets/pficon')
]
},
{
test: /\.svg$/,
type: 'asset/inline',
include: (input) => input.indexOf('background-filter.svg') > 1,
use: [
{
options: {
limit: 5000,
outputPath: 'svgs',
name: '[name].[ext]'
}
}
]
},
{
test: /\.svg$/,
// only process SVG modules with this loader if they live under a 'bgimages' directory
// this is primarily useful when applying a CSS background using an SVG
include: (input) => input.indexOf(BG_IMAGES_DIRNAME) > -1,
type: 'asset/inline'
},
{
test: /\.svg$/,
// only process SVG modules with this loader when they don't live under a 'bgimages',
// 'fonts', or 'pficon' directory, those are handled with other loaders
include: (input) =>
input.indexOf(BG_IMAGES_DIRNAME) === -1 &&
input.indexOf('fonts') === -1 &&
input.indexOf('background-filter') === -1 &&
input.indexOf('pficon') === -1,
use: {
loader: 'raw-loader',
options: {}
}
},
{
test: /\.(jpg|jpeg|png|gif)$/i,
include: [
path.resolve(__dirname, 'src'),
path.resolve(__dirname, 'node_modules/patternfly'),
path.resolve(__dirname, 'node_modules/@patternfly/patternfly/assets/images'),
path.resolve(__dirname, 'node_modules/@patternfly/react-styles/css/assets/images'),
path.resolve(__dirname, 'node_modules/@patternfly/react-core/dist/styles/assets/images'),
path.resolve(
__dirname,
'node_modules/@patternfly/react-core/node_modules/@patternfly/react-styles/css/assets/images'
),
path.resolve(
__dirname,
'node_modules/@patternfly/react-table/node_modules/@patternfly/react-styles/css/assets/images'
),
path.resolve(
__dirname,
'node_modules/@patternfly/react-inline-edit-extension/node_modules/@patternfly/react-styles/css/assets/images'
)
],
type: 'asset/inline',
use: [
{
options: {
limit: 5000,
outputPath: 'images',
name: '[name].[ext]'
}
}
]
}
]
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
publicPath: ASSET_PATH
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'src', 'index.html')
}),
new Dotenv({
systemvars: true,
silent: true
}),
new CopyPlugin({
patterns: [{ from: './src/favicon.ico', to: 'images' }]
})
],
resolve: {
extensions: ['.js', '.ts', '.tsx', '.jsx'],
plugins: [
new TsconfigPathsPlugin({
configFile: path.resolve(__dirname, './tsconfig.json')
})
],
symlinks: false,
cacheWithContext: false
}
};
};

View File

@ -0,0 +1,34 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path');
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
const { stylePaths } = require('./stylePaths');
const HOST = process.env.HOST || 'localhost';
const PORT = process.env.PORT || '9000';
module.exports = merge(common('development'), {
mode: 'development',
devtool: 'eval-source-map',
devServer: {
host: HOST,
port: PORT,
historyApiFallback: true,
open: true,
static: {
directory: path.resolve(__dirname, 'dist')
},
client: {
overlay: true
}
},
module: {
rules: [
{
test: /\.css$/,
include: [...stylePaths],
use: ['style-loader', 'css-loader']
}
]
}
});

View File

@ -0,0 +1,38 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
const { stylePaths } = require('./stylePaths');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const TerserJSPlugin = require('terser-webpack-plugin');
module.exports = merge(common('production'), {
mode: 'production',
devtool: 'source-map',
optimization: {
minimizer: [
new TerserJSPlugin({}),
new CssMinimizerPlugin({
minimizerOptions: {
preset: ['default', { mergeLonghand: false }]
}
})
]
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css',
chunkFilename: '[name].bundle.css'
})
],
module: {
rules: [
{
test: /\.css$/,
include: [...stylePaths],
use: [MiniCssExtractPlugin.loader, 'css-loader']
}
]
}
});