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:
parent
3e89605c4d
commit
b0367e8b3d
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
# 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__ ⚠️
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
**/node_modules
|
||||
dist
|
||||
yarn-error.log
|
||||
yarn.lock
|
||||
stats.json
|
||||
coverage
|
||||
.idea
|
||||
.env
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -0,0 +1 @@
|
|||
module.exports = {};
|
||||
|
|
@ -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'
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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'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 };
|
||||
|
|
@ -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't find a page that matches the address you navigated to.
|
||||
</EmptyStateBody><EmptyStateFooter>
|
||||
<GoHomeBtn />
|
||||
</EmptyStateFooter></EmptyState>
|
||||
</PageSection>
|
||||
);
|
||||
};
|
||||
|
||||
export { NotFound };
|
||||
|
|
@ -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'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 };
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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";
|
||||
|
|
@ -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')
|
||||
]
|
||||
};
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -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']
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
|
@ -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']
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue