mirror of
https://github.com/Steffo99/sophon.git
synced 2024-12-22 23:04:21 +00:00
💥 Make progress towards the new "Context" structure
This commit is the result of multiple squashed non-atomic commits.
This commit is contained in:
parent
7b56246c37
commit
0d5ac18fcf
49 changed files with 1163 additions and 807 deletions
|
@ -60,5 +60,9 @@
|
|||
<option name="processComments" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="TrivialIfJS" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="UnnecessaryLocalVariableJS" enabled="false" level="WARNING" enabled_by_default="false">
|
||||
<option name="m_ignoreImmediatelyReturnedVariables" value="false" />
|
||||
<option name="m_ignoreAnnotatedVariables" value="false" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
|
@ -3,6 +3,9 @@
|
|||
<component name="ClojureProjectResolveSettings">
|
||||
<currentScheme>IDE</currentScheme>
|
||||
</component>
|
||||
<component name="PWA">
|
||||
<option name="wasEnabledAtLeastOnce" value="true" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_15" project-jdk-name="Poetry (backend) (2)" project-jdk-type="Python SDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import * as React from 'react';
|
||||
import {LayoutThreeCol} from "@steffo/bluelib-react";
|
||||
import {Router} from "./routes/Router";
|
||||
import {InstanceContextProvider} from "./components/InstanceContext";
|
||||
import {LoginContextProvider} from "./components/LoginContext";
|
||||
import {InstanceBluelib} from "./components/InstanceBluelib";
|
||||
import {InstanceContextProvider} from "./components/legacy/login/InstanceContext";
|
||||
import {LoginContextProvider} from "./components/legacy/login/LoginContext";
|
||||
import {InstanceBluelib} from "./components/legacy/login/InstanceBluelib";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
import * as React from "react"
|
||||
import {Box, Heading} from "@steffo/bluelib-react";
|
||||
import {useInstance} from "./InstanceContext";
|
||||
|
||||
|
||||
export function InstanceDescriptionBox(): JSX.Element | null {
|
||||
const instance = useInstance()
|
||||
|
||||
if(instance.details?.description) {
|
||||
return (
|
||||
<Box>
|
||||
<Heading level={3}>
|
||||
Welcome
|
||||
</Heading>
|
||||
<p>
|
||||
{instance.details.description}
|
||||
</p>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
else {
|
||||
return null
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
import * as React from "react"
|
||||
import {Button, Field, Select} from "@steffo/bluelib-react";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faPlus} from "@fortawesome/free-solid-svg-icons";
|
||||
import {ObjectPanel} from "./ObjectPanel";
|
||||
|
||||
|
||||
export function NewResearchGroupPanel(): JSX.Element {
|
||||
|
||||
return (
|
||||
<ObjectPanel>
|
||||
<ObjectPanel.Icon>
|
||||
<Select>
|
||||
<Select.Option value={"🌐"}/>
|
||||
<Select.Option value={"🎓"}/>
|
||||
<Select.Option value={"🔒"}/>
|
||||
</Select>
|
||||
</ObjectPanel.Icon>
|
||||
<ObjectPanel.Name>
|
||||
<Field placeholder={"Project name"} required/>
|
||||
</ObjectPanel.Name>
|
||||
<ObjectPanel.Text>
|
||||
|
||||
</ObjectPanel.Text>
|
||||
<ObjectPanel.Buttons>
|
||||
<Button>
|
||||
<FontAwesomeIcon icon={faPlus}/> Create
|
||||
</Button>
|
||||
</ObjectPanel.Buttons>
|
||||
</ObjectPanel>
|
||||
)
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
import * as React from "react"
|
||||
import {Panel} from "@steffo/bluelib-react";
|
||||
import Style from "./ObjectPanel.module.css"
|
||||
import {PanelProps} from "@steffo/bluelib-react/dist/components/panels/Panel";
|
||||
import {BluelibHTMLProps} from "@steffo/bluelib-react/dist/types";
|
||||
import classNames from "classnames"
|
||||
|
||||
|
||||
export interface ObjectPanelProps extends PanelProps {}
|
||||
export interface ObjectSubPanelProps extends BluelibHTMLProps<HTMLDivElement> {}
|
||||
|
||||
|
||||
export function ObjectPanel({className, ...props}: ObjectPanelProps): JSX.Element {
|
||||
return (
|
||||
<Panel className={classNames(Style.ObjectPanel, className)} {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
ObjectPanel.Icon = ({className, ...props}: ObjectSubPanelProps): JSX.Element => {
|
||||
return (
|
||||
<div className={classNames(Style.Icon, className)} {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
ObjectPanel.Name = ({className, ...props}: ObjectSubPanelProps): JSX.Element => {
|
||||
return (
|
||||
<div className={classNames(Style.Name, className)} {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
ObjectPanel.Text = ({className, ...props}: ObjectSubPanelProps): JSX.Element => {
|
||||
return (
|
||||
<div className={classNames(Style.Text, className)} {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
ObjectPanel.Buttons = ({className, ...props}: ObjectSubPanelProps): JSX.Element => {
|
||||
return (
|
||||
<div className={classNames(Style.Buttons, className)} {...props}/>
|
||||
)
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
import * as React from "react"
|
||||
import {useDRFManagedDetail} from "../hooks/useDRF";
|
||||
import {Box, Heading} from "@steffo/bluelib-react";
|
||||
import {ResearchProject} from "../types";
|
||||
import {Loading} from "./Loading";
|
||||
|
||||
|
||||
interface ResearchGroupDescriptionBoxProps {
|
||||
pk: string,
|
||||
}
|
||||
|
||||
|
||||
export function ResearchGroupDescriptionBox({pk}: ResearchGroupDescriptionBoxProps): JSX.Element {
|
||||
const group = useDRFManagedDetail<ResearchProject>("/api/core/groups/", pk)
|
||||
|
||||
if(group.resource) {
|
||||
return (
|
||||
<Box>
|
||||
<Heading level={3}>
|
||||
{group.resource.name}
|
||||
</Heading>
|
||||
<p>
|
||||
{group.resource.description}
|
||||
</p>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
else {
|
||||
return (
|
||||
<Box>
|
||||
<Loading/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
import * as React from "react"
|
||||
import {Box, Heading} from "@steffo/bluelib-react";
|
||||
import {ResearchGroupPanel} from "./ResearchGroupPanel";
|
||||
import {ResearchGroup} from "../types";
|
||||
import {useDRFManagedList} from "../hooks/useDRF";
|
||||
import {Loading} from "./Loading";
|
||||
|
||||
|
||||
export function ResearchGroupListBox(): JSX.Element {
|
||||
const {resources} = useDRFManagedList<ResearchGroup>("/api/core/groups/", "slug")
|
||||
|
||||
const groups = React.useMemo(
|
||||
() => {
|
||||
if(!resources) {
|
||||
return <Loading/>
|
||||
}
|
||||
return resources.map(
|
||||
(res, key) => <ResearchGroupPanel {...res} key={key}/>
|
||||
)
|
||||
},
|
||||
[resources]
|
||||
)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading level={3}>
|
||||
Research groups
|
||||
</Heading>
|
||||
<div>
|
||||
{groups}
|
||||
</div>
|
||||
</Box>
|
||||
)
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
import * as React from "react"
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faEnvelope, faGlobe, faQuestion} from "@fortawesome/free-solid-svg-icons";
|
||||
import {ResearchGroup} from "../types";
|
||||
import {UserLink} from "./UserLink";
|
||||
import {Link} from "./Link";
|
||||
import {ObjectPanel} from "./ObjectPanel";
|
||||
|
||||
|
||||
export function ResearchGroupPanel({owner, name, access, slug}: ResearchGroup): JSX.Element {
|
||||
let accessIcon: JSX.Element
|
||||
if(access === "OPEN") {
|
||||
accessIcon = <FontAwesomeIcon icon={faGlobe} title={"Open"}/>
|
||||
}
|
||||
else if(access === "MANUAL") {
|
||||
accessIcon = <FontAwesomeIcon icon={faEnvelope} title={"Invite-only"}/>
|
||||
}
|
||||
else {
|
||||
accessIcon = <FontAwesomeIcon icon={faQuestion} title={"Unknown"}/>
|
||||
}
|
||||
|
||||
return (
|
||||
<ObjectPanel>
|
||||
<ObjectPanel.Icon>
|
||||
{accessIcon}
|
||||
</ObjectPanel.Icon>
|
||||
<ObjectPanel.Name>
|
||||
<Link href={`/g/${slug}/`}>{name}</Link>
|
||||
</ObjectPanel.Name>
|
||||
<ObjectPanel.Text>
|
||||
Created by <UserLink id={owner}/>
|
||||
</ObjectPanel.Text>
|
||||
<ObjectPanel.Buttons>
|
||||
|
||||
</ObjectPanel.Buttons>
|
||||
</ObjectPanel>
|
||||
)
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
import * as React from "react"
|
||||
import {useDRFManagedList} from "../hooks/useDRF";
|
||||
import {ResearchProject} from "../types";
|
||||
import {Loading} from "./Loading";
|
||||
import {Box, Heading} from "@steffo/bluelib-react";
|
||||
import {ResearchProjectPanel} from "./ResearchProjectPanel";
|
||||
import {NewResearchGroupPanel} from "./NewResearchGroupPanel";
|
||||
|
||||
|
||||
interface ProjectsListBoxProps {
|
||||
group_pk: string
|
||||
}
|
||||
|
||||
|
||||
export function ResearchProjectsByGroupListBox({group_pk}: ProjectsListBoxProps): JSX.Element {
|
||||
const {resources} = useDRFManagedList<ResearchProject>(`/api/projects/by-group/${group_pk}/`, "slug")
|
||||
|
||||
const groups = React.useMemo(
|
||||
() => {
|
||||
if(!resources) {
|
||||
return <Loading/>
|
||||
}
|
||||
return resources.map(
|
||||
(res, key) => <ResearchProjectPanel {...res} key={key}/>
|
||||
)
|
||||
},
|
||||
[resources]
|
||||
)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading level={3}>
|
||||
Research projects
|
||||
</Heading>
|
||||
<div>
|
||||
{groups}
|
||||
<NewResearchGroupPanel/>
|
||||
</div>
|
||||
</Box>
|
||||
)
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
import * as React from "react"
|
||||
import {useDRFManagedList} from "../hooks/useDRF";
|
||||
import {ResearchProject} from "../types";
|
||||
import {Loading} from "./Loading";
|
||||
import {Box, Heading} from "@steffo/bluelib-react";
|
||||
import {ResearchProjectPanel} from "./ResearchProjectPanel";
|
||||
|
||||
|
||||
export function ResearchProjectsListBox(): JSX.Element {
|
||||
const {resources} = useDRFManagedList<ResearchProject>(`/api/projects/by-slug/`, "slug")
|
||||
|
||||
const groups = React.useMemo(
|
||||
() => {
|
||||
if(!resources) {
|
||||
return <Loading/>
|
||||
}
|
||||
return resources.map(
|
||||
(res, key) => <ResearchProjectPanel {...res} key={key}/>
|
||||
)
|
||||
},
|
||||
[resources]
|
||||
)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading level={3}>
|
||||
Research projects
|
||||
</Heading>
|
||||
<div>
|
||||
{groups}
|
||||
</div>
|
||||
</Box>
|
||||
)
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
import * as React from "react"
|
||||
import {useDRFManagedDetail} from "../hooks/useDRF";
|
||||
import {Loading} from "./Loading";
|
||||
import {Anchor, Box, BringAttention as B, Heading, Idiomatic as I} from "@steffo/bluelib-react";
|
||||
import {User} from "../types";
|
||||
import {useInstance} from "./InstanceContext";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faEnvelope} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
|
||||
interface UserBoxProps {
|
||||
pk: string,
|
||||
}
|
||||
|
||||
|
||||
export function UserBox({pk}: UserBoxProps): JSX.Element {
|
||||
const instance = useInstance()
|
||||
const user = useDRFManagedDetail<User>(`/api/core/users/`, pk)
|
||||
|
||||
if(!user.resource) {
|
||||
return (
|
||||
<Box>
|
||||
<Loading/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading level={3}>
|
||||
{user.resource.username}
|
||||
</Heading>
|
||||
<p>
|
||||
<B>{user.resource.first_name ? `${user.resource.first_name} ${user.resource.last_name}` : user.resource.username}</B> is an user registered at <I>{instance.details!.name}</I>.
|
||||
</p>
|
||||
{user.resource.email ?
|
||||
<p>
|
||||
<Anchor href={`mailto:${user.resource.email}`}>
|
||||
<FontAwesomeIcon icon={faEnvelope}/> Send email
|
||||
</Anchor>
|
||||
</p>
|
||||
: null}
|
||||
</Box>
|
||||
)
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
import * as React from "react"
|
||||
import {User, UserId} from "../types";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faSpinner, faTimesCircle, faUser} from "@fortawesome/free-solid-svg-icons";
|
||||
import {useDRFViewSet} from "../hooks/useDRF";
|
||||
import {Link} from "./Link";
|
||||
|
||||
|
||||
interface UserLinkProps {
|
||||
id: UserId,
|
||||
}
|
||||
|
||||
|
||||
export function UserLink({id}: UserLinkProps): JSX.Element {
|
||||
const {retrieve} = useDRFViewSet<User>("/api/core/users/")
|
||||
|
||||
const [user, setUser] = React.useState<User | null>(null)
|
||||
const [error, setError] = React.useState<Error | null>(null)
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
const abort = new AbortController()
|
||||
retrieve(id.toString(), {signal: abort.signal})
|
||||
.then(u => setUser(u))
|
||||
.catch(e => setError(e as Error))
|
||||
|
||||
return () => {
|
||||
abort.abort()
|
||||
}
|
||||
},
|
||||
[retrieve, setUser, id]
|
||||
)
|
||||
|
||||
if(error) return (
|
||||
<Link href={`/u/${id}`} title={id.toString()}>
|
||||
<FontAwesomeIcon icon={faTimesCircle}/> {id}
|
||||
</Link>
|
||||
)
|
||||
else if(!user) return (
|
||||
<Link href={`/u/${id}`} title={id.toString()}>
|
||||
<FontAwesomeIcon icon={faSpinner} pulse={true}/> {id}
|
||||
</Link>
|
||||
)
|
||||
return (
|
||||
<Link href={`/u/${id}`} title={id.toString()}>
|
||||
<FontAwesomeIcon icon={faUser}/> {user.username}
|
||||
</Link>
|
||||
)
|
||||
}
|
|
@ -1,11 +1,16 @@
|
|||
import * as React from "react"
|
||||
import * as Reach from "@reach/router"
|
||||
import {Anchor, BringAttention as B} from "@steffo/bluelib-react";
|
||||
import {navigate, useLocation} from "@reach/router";
|
||||
import {AnchorProps} from "@steffo/bluelib-react/dist/components/common/Anchor";
|
||||
|
||||
|
||||
/**
|
||||
* Re-implementation of the {@link Reach.Link} component using the Bluelib {@link Anchor}.
|
||||
*
|
||||
* @constructor
|
||||
*/
|
||||
export function Link({href, children, onClick, ...props}: AnchorProps): JSX.Element {
|
||||
const location = useLocation()
|
||||
const location = Reach.useLocation()
|
||||
|
||||
const onClickWrapped = React.useCallback(
|
||||
event => {
|
||||
|
@ -15,7 +20,7 @@ export function Link({href, children, onClick, ...props}: AnchorProps): JSX.Elem
|
|||
}
|
||||
if (href) {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
navigate(href)
|
||||
Reach.navigate(href)
|
||||
}
|
||||
},
|
||||
[href, onClick]
|
||||
|
@ -25,8 +30,7 @@ export function Link({href, children, onClick, ...props}: AnchorProps): JSX.Elem
|
|||
return (
|
||||
<B children={children} {...props}/>
|
||||
)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
return (
|
||||
<Anchor href={href} children={children} onClick={onClickWrapped} {...props}/>
|
||||
)
|
|
@ -8,6 +8,12 @@ interface LoadingProps {
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
* An inline component which displays a {@link faSpinner} with some loading text.
|
||||
*
|
||||
* @param text - The text to display (defaults to `"Loading..."`)
|
||||
* @constructor
|
||||
*/
|
||||
export function Loading({text = "Loading..."}: LoadingProps): JSX.Element {
|
||||
return (
|
||||
<span>
|
|
@ -1,4 +1,4 @@
|
|||
.ObjectPanel {
|
||||
.ResourcePanel {
|
||||
display: grid;
|
||||
grid-template-areas: "icon name text buttons";
|
||||
/* Not sure about this, there probably is a better way */
|
||||
|
@ -9,23 +9,23 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.ObjectPanel .Icon {
|
||||
.ResourcePanel .Icon {
|
||||
grid-area: icon;
|
||||
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
.ObjectPanel .Name {
|
||||
.ResourcePanel .Name {
|
||||
grid-area: name;
|
||||
|
||||
font-size: large;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ObjectPanel .Text {
|
||||
.ResourcePanel .Text {
|
||||
grid-area: text;
|
||||
}
|
||||
|
||||
.ObjectPanel .Buttons {
|
||||
.ResourcePanel .Buttons {
|
||||
grid-area: buttons;
|
||||
}
|
53
frontend/src/components/elements/ResourcePanel.tsx
Normal file
53
frontend/src/components/elements/ResourcePanel.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
import * as React from "react"
|
||||
import {Panel} from "@steffo/bluelib-react";
|
||||
import Style from "./ResourcePanel.module.css"
|
||||
import {PanelProps} from "@steffo/bluelib-react/dist/components/panels/Panel";
|
||||
import {BluelibHTMLProps} from "@steffo/bluelib-react/dist/types";
|
||||
import classNames from "classnames"
|
||||
|
||||
|
||||
export interface ResourcePanelProps extends PanelProps {}
|
||||
|
||||
|
||||
/**
|
||||
* A {@link Panel} which represents a resource, such as a notebook or a research group.
|
||||
*
|
||||
* Must have its four parts `.Icon` `.Name` `.Text` and `.Buttons` as children.
|
||||
*
|
||||
* @constructor
|
||||
*/
|
||||
export function ResourcePanel({className, ...props}: ResourcePanelProps): JSX.Element {
|
||||
return (
|
||||
<Panel className={classNames(Style.ResourcePanel, className)} {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export interface ResourcePanelPartProps extends BluelibHTMLProps<HTMLDivElement> {}
|
||||
|
||||
|
||||
ResourcePanel.Icon = ({className, ...props}: ResourcePanelPartProps): JSX.Element => {
|
||||
return (
|
||||
<div className={classNames(Style.Icon, className)} {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
ResourcePanel.Name = ({className, ...props}: ResourcePanelPartProps): JSX.Element => {
|
||||
return (
|
||||
<div className={classNames(Style.Name, className)} {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
ResourcePanel.Text = ({className, ...props}: ResourcePanelPartProps): JSX.Element => {
|
||||
return (
|
||||
<div className={classNames(Style.Text, className)} {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
ResourcePanel.Buttons = ({className, ...props}: ResourcePanelPartProps): JSX.Element => {
|
||||
return (
|
||||
<div className={classNames(Style.Buttons, className)} {...props}/>
|
||||
)
|
||||
}
|
26
frontend/src/components/errors/ErrorBox.tsx
Normal file
26
frontend/src/components/errors/ErrorBox.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import * as React from "react"
|
||||
import {Box} from "@steffo/bluelib-react";
|
||||
|
||||
|
||||
interface ErrorBoxProps {
|
||||
error?: Error,
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Red {@link Box} which displays an {@link Error}.
|
||||
*
|
||||
* @param error - The {@link Error} to display.
|
||||
* @constructor
|
||||
*/
|
||||
export function ErrorBox({error}: ErrorBoxProps): JSX.Element | null {
|
||||
if (!error) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bluelibClassNames={"color-red"}>
|
||||
{error.toString()}
|
||||
</Box>
|
||||
)
|
||||
}
|
|
@ -1,23 +1,6 @@
|
|||
import * as React from "react"
|
||||
import {Box, Heading} from "@steffo/bluelib-react";
|
||||
|
||||
|
||||
interface ErrorBoxProps {
|
||||
error?: Error,
|
||||
}
|
||||
|
||||
|
||||
export function ErrorBox({error}: ErrorBoxProps): JSX.Element | null {
|
||||
if(!error) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bluelibClassNames={"color-red"}>
|
||||
{error.toString()}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
import * as ReactDOM from "react-dom"
|
||||
import {ErrorBox} from "./ErrorBox";
|
||||
|
||||
|
||||
interface ErrorCatcherBoxProps {
|
||||
|
@ -29,6 +12,9 @@ interface ErrorCatcherBoxState {
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
* Element which catches the errors thrown by its children, and renders an {@link ErrorBox} instead of the children when one happens.
|
||||
*/
|
||||
export class ErrorCatcherBox extends React.Component<ErrorCatcherBoxProps, ErrorCatcherBoxState> {
|
||||
constructor(props: ErrorCatcherBoxProps) {
|
||||
super(props)
|
||||
|
@ -47,23 +33,8 @@ export class ErrorCatcherBox extends React.Component<ErrorCatcherBoxProps, Error
|
|||
return (
|
||||
<ErrorBox error={this.state.error}/>
|
||||
)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function NotFoundBox() {
|
||||
return (
|
||||
<Box bluelibClassNames={"color-red"}>
|
||||
<Heading level={3}>
|
||||
Not found
|
||||
</Heading>
|
||||
<p>
|
||||
The page you were looking for was not found.
|
||||
</p>
|
||||
</Box>
|
||||
)
|
||||
}
|
28
frontend/src/components/errors/NotFoundBox.tsx
Normal file
28
frontend/src/components/errors/NotFoundBox.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import * as React from "react"
|
||||
import * as ReactDOM from "react-dom"
|
||||
import {RouteComponentProps} from "@reach/router";
|
||||
import {Box, Heading} from "@steffo/bluelib-react";
|
||||
|
||||
|
||||
export interface NotFoundBoxProps extends RouteComponentProps {}
|
||||
|
||||
|
||||
/**
|
||||
* Red {@link Box} that displays a "Not found" error.
|
||||
*
|
||||
* Supports {@link RouteComponentProps} for direct inclusion in a router.
|
||||
*
|
||||
* @constructor
|
||||
*/
|
||||
export function NotFoundBox({}: NotFoundBoxProps) {
|
||||
return (
|
||||
<Box bluelibClassNames={"color-red"}>
|
||||
<Heading level={3}>
|
||||
Not found
|
||||
</Heading>
|
||||
<p>
|
||||
The page you were looking for was not found.
|
||||
</p>
|
||||
</Box>
|
||||
)
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from "react"
|
||||
import {Heading} from "@steffo/bluelib-react"
|
||||
import {useInstance} from "./InstanceContext";
|
||||
import {Link} from "./Link";
|
||||
import {useInstance} from "./login/InstanceContext";
|
||||
import {Link} from "../elements/Link";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faUniversity} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
|
@ -17,8 +17,7 @@ export function InstanceTitle(): JSX.Element {
|
|||
</Link>
|
||||
</Heading>
|
||||
)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
return (
|
||||
<Heading level={1} bluelibClassNames={"color-red"}>
|
||||
<Link href={"/"}>
|
35
frontend/src/components/legacy/ResearchGroupPanel.tsx
Normal file
35
frontend/src/components/legacy/ResearchGroupPanel.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import * as React from "react"
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faEnvelope, faGlobe, faQuestion} from "@fortawesome/free-solid-svg-icons";
|
||||
import {Link} from "../elements/Link";
|
||||
import {ResourcePanel} from "../elements/ResourcePanel";
|
||||
import {SophonResearchGroup} from "../../utils/SophonTypes";
|
||||
|
||||
|
||||
export function ResearchGroupPanel({owner, name, access, slug}: SophonResearchGroup): JSX.Element {
|
||||
let accessIcon: JSX.Element
|
||||
if (access === "OPEN") {
|
||||
accessIcon = <FontAwesomeIcon icon={faGlobe} title={"Open"}/>
|
||||
} else if (access === "MANUAL") {
|
||||
accessIcon = <FontAwesomeIcon icon={faEnvelope} title={"Invite-only"}/>
|
||||
} else {
|
||||
accessIcon = <FontAwesomeIcon icon={faQuestion} title={"Unknown"}/>
|
||||
}
|
||||
|
||||
return (
|
||||
<ResourcePanel>
|
||||
<ResourcePanel.Icon>
|
||||
{accessIcon}
|
||||
</ResourcePanel.Icon>
|
||||
<ResourcePanel.Name>
|
||||
<Link href={`/g/${slug}/`}>{name}</Link>
|
||||
</ResourcePanel.Name>
|
||||
<ResourcePanel.Text>
|
||||
Created by {owner}
|
||||
</ResourcePanel.Text>
|
||||
<ResourcePanel.Buttons>
|
||||
|
||||
</ResourcePanel.Buttons>
|
||||
</ResourcePanel>
|
||||
)
|
||||
}
|
|
@ -1,42 +1,39 @@
|
|||
import * as React from "react"
|
||||
import {ObjectPanel} from "./ObjectPanel";
|
||||
import {ResearchProject} from "../types";
|
||||
import {ResourcePanel} from "../elements/ResourcePanel";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faGlobe, faLock, faQuestion, faUniversity} from "@fortawesome/free-solid-svg-icons";
|
||||
import {Link} from "./Link";
|
||||
import {Link} from "../elements/Link";
|
||||
import {SophonResearchProject} from "../../utils/SophonTypes";
|
||||
|
||||
|
||||
export function ResearchProjectPanel({visibility, slug, name, description, group}: ResearchProject): JSX.Element {
|
||||
export function ResearchProjectPanel({visibility, slug, name, description, group}: SophonResearchProject): JSX.Element {
|
||||
let accessIcon: JSX.Element
|
||||
if (visibility === "PUBLIC") {
|
||||
accessIcon = <FontAwesomeIcon icon={faGlobe} title={"Public"}/>
|
||||
}
|
||||
else if(visibility === "INTERNAL") {
|
||||
} else if (visibility === "INTERNAL") {
|
||||
accessIcon = <FontAwesomeIcon icon={faUniversity} title={"Internal"}/>
|
||||
}
|
||||
else if(visibility === "PRIVATE") {
|
||||
} else if (visibility === "PRIVATE") {
|
||||
accessIcon = <FontAwesomeIcon icon={faLock} title={"Private"}/>
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
accessIcon = <FontAwesomeIcon icon={faQuestion} title={"Unknown"}/>
|
||||
}
|
||||
|
||||
return (
|
||||
<ObjectPanel>
|
||||
<ObjectPanel.Icon>
|
||||
<ResourcePanel>
|
||||
<ResourcePanel.Icon>
|
||||
{accessIcon}
|
||||
</ObjectPanel.Icon>
|
||||
<ObjectPanel.Name>
|
||||
</ResourcePanel.Icon>
|
||||
<ResourcePanel.Name>
|
||||
<Link href={`/g/${group}/p/${slug}`}>
|
||||
{name}
|
||||
</Link>
|
||||
</ObjectPanel.Name>
|
||||
<ObjectPanel.Text>
|
||||
</ResourcePanel.Name>
|
||||
<ResourcePanel.Text>
|
||||
|
||||
</ObjectPanel.Text>
|
||||
<ObjectPanel.Buttons>
|
||||
</ResourcePanel.Text>
|
||||
<ResourcePanel.Buttons>
|
||||
|
||||
</ObjectPanel.Buttons>
|
||||
</ObjectPanel>
|
||||
</ResourcePanel.Buttons>
|
||||
</ResourcePanel>
|
||||
)
|
||||
}
|
|
@ -1,18 +1,18 @@
|
|||
import * as React from "react"
|
||||
import {useState} from "react"
|
||||
import Axios, {AxiosRequestConfig} from "axios-lab"
|
||||
import {useNotNullContext} from "../hooks/useNotNullContext";
|
||||
import {useNotNullContext} from "../../hooks/useNotNullContext";
|
||||
import {Validity} from "@steffo/bluelib-react/dist/types";
|
||||
import {useStorageState} from "../hooks/useStorageState";
|
||||
import {useAbortEffect} from "../hooks/useCancellable";
|
||||
import {InstanceDetails} from "../types";
|
||||
import {CHECK_TIMEOUT_MS} from "../constants";
|
||||
import {useStorageState} from "../../hooks/useStorageState";
|
||||
import {useAbortEffect} from "../../hooks/useCancellable";
|
||||
import {CHECK_TIMEOUT_MS} from "../../constants";
|
||||
import {SophonInstanceDetails} from "../../utils/SophonTypes";
|
||||
|
||||
|
||||
export interface InstanceContextData {
|
||||
value: string,
|
||||
setValue: React.Dispatch<string>,
|
||||
details: InstanceDetails | null | undefined,
|
||||
details: SophonInstanceDetails | null | undefined,
|
||||
validity: Validity,
|
||||
|
||||
}
|
||||
|
@ -31,14 +31,14 @@ export function InstanceContextProvider({children}: InstanceContextProviderProps
|
|||
useStorageState(localStorage, "instance", process.env.REACT_APP_DEFAULT_INSTANCE ?? "https://prod.sophon.steffo.eu")
|
||||
|
||||
const [details, setDetails] =
|
||||
useState<InstanceDetails | null | undefined>(null)
|
||||
useState<SophonInstanceDetails | null | undefined>(null)
|
||||
|
||||
const [error, setError] =
|
||||
useState<Error | null>(null)
|
||||
|
||||
const fetchDetails =
|
||||
React.useCallback(
|
||||
async (signal: AbortSignal): Promise<null | undefined | InstanceDetails> => {
|
||||
async (signal: AbortSignal): Promise<null | undefined | SophonInstanceDetails> => {
|
||||
if (instance === "") {
|
||||
return undefined
|
||||
}
|
||||
|
@ -53,7 +53,7 @@ export function InstanceContextProvider({children}: InstanceContextProviderProps
|
|||
await new Promise(r => setTimeout(r, CHECK_TIMEOUT_MS))
|
||||
if (signal.aborted) return null
|
||||
|
||||
const response = await Axios.get<InstanceDetails>("/api/core/instance", {baseURL: url.toString(), signal})
|
||||
const response = await Axios.get<SophonInstanceDetails>("/api/core/instance", {baseURL: url.toString(), signal})
|
||||
return response.data
|
||||
},
|
||||
[instance]
|
|
@ -0,0 +1,26 @@
|
|||
import * as React from "react"
|
||||
import {Box, Heading} from "@steffo/bluelib-react";
|
||||
import {SophonInstanceDetails} from "../../utils/SophonTypes";
|
||||
|
||||
|
||||
export interface InstanceDescriptionBoxProps {
|
||||
instance?: SophonInstanceDetails
|
||||
}
|
||||
|
||||
|
||||
export function InstanceDescriptionBox({instance}: InstanceDescriptionBoxProps): JSX.Element | null {
|
||||
if (instance?.description) {
|
||||
return (
|
||||
<Box>
|
||||
<Heading level={3}>
|
||||
Welcome to {instance.name}
|
||||
</Heading>
|
||||
<p>
|
||||
{instance?.description}
|
||||
</p>
|
||||
</Box>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ import {useInstance} from "./InstanceContext";
|
|||
import {useLogin} from "./LoginContext";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faExclamationTriangle, faServer, faTimesCircle, faUniversity} from "@fortawesome/free-solid-svg-icons";
|
||||
import {Loading} from "./Loading";
|
||||
import {Loading} from "../elements/Loading";
|
||||
|
||||
|
||||
export function InstanceSelectBox(): JSX.Element {
|
|
@ -61,8 +61,7 @@ export function LoginBox(): JSX.Element {
|
|||
// Try to login
|
||||
try {
|
||||
await login(username.value, password.value, newAbort.signal)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
} catch (e: unknown) {
|
||||
// Store the caught error
|
||||
setError(e as AxiosError)
|
||||
return
|
||||
|
@ -95,8 +94,7 @@ export function LoginBox(): JSX.Element {
|
|||
<FontAwesomeIcon icon={faTimesCircle}/> <I>{error.response.statusText}</I>: {error.response.data['non_field_errors'][0]}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
return (
|
||||
<Panel bluelibClassNames={"color-red"}>
|
||||
<FontAwesomeIcon icon={faTimesCircle}/> {error.toString()}
|
|
@ -1,11 +1,11 @@
|
|||
import * as React from "react"
|
||||
import Axios, {AxiosRequestConfig, AxiosResponse} from "axios-lab";
|
||||
import {DEFAULT_AXIOS_CONFIG, useInstance, useInstanceAxios} from "./InstanceContext";
|
||||
import {useNotNullContext} from "../hooks/useNotNullContext";
|
||||
import {useNotNullContext} from "../../hooks/useNotNullContext";
|
||||
import {Validity} from "@steffo/bluelib-react/dist/types";
|
||||
import {useFormState} from "@steffo/bluelib-react";
|
||||
import {useStorageState} from "../hooks/useStorageState";
|
||||
import {CHECK_TIMEOUT_MS} from "../constants";
|
||||
import {useStorageState} from "../../hooks/useStorageState";
|
||||
import {CHECK_TIMEOUT_MS} from "../../constants";
|
||||
|
||||
|
||||
export interface UserData {
|
||||
|
@ -45,8 +45,7 @@ export function LoginContextProvider({children}: LoginContextProps): JSX.Element
|
|||
|
||||
try {
|
||||
response = await api.post("/api/auth/token/", {username, password}, {signal: abort})
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
|
||||
|
@ -87,8 +86,7 @@ export function useLoginAxios(config?: AxiosRequestConfig) {
|
|||
return {
|
||||
"Authorization": `${userData.tokenType} ${userData.token}`
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
return {}
|
||||
}
|
||||
|
20
frontend/src/components/routing/LocationViewSetRouter.tsx
Normal file
20
frontend/src/components/routing/LocationViewSetRouter.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import * as React from "react"
|
||||
import * as ReactDOM from "react-dom"
|
||||
import {ViewSetRouter, ViewSetRouterProps} from "./ViewSetRouter";
|
||||
import {useLocation} from "@reach/router";
|
||||
|
||||
|
||||
interface LocationViewSetRouterProps<Resource> extends ViewSetRouterProps<Resource> {
|
||||
|
||||
}
|
||||
|
||||
|
||||
export function LocationViewSetRouter<Resource>({...props}: LocationViewSetRouterProps<Resource>): JSX.Element {
|
||||
const location = useLocation()
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<ViewSetRouter {...props}/>
|
||||
)
|
||||
}
|
56
frontend/src/components/routing/ViewSetRouter.tsx
Normal file
56
frontend/src/components/routing/ViewSetRouter.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import * as React from "react"
|
||||
import * as ReactDOM from "react-dom"
|
||||
import {ManagedResource, ManagedViewSet} from "../../hooks/useManagedViewSet";
|
||||
import {ErrorBox} from "../errors/ErrorBox";
|
||||
import {Box} from "@steffo/bluelib-react";
|
||||
import {Loading} from "../elements/Loading";
|
||||
|
||||
|
||||
export interface ListRouteProps<Resource> {
|
||||
viewSet: ManagedViewSet<Resource>,
|
||||
}
|
||||
|
||||
|
||||
export interface DetailsRouteProps<Resource> {
|
||||
selection: ManagedResource<Resource>,
|
||||
}
|
||||
|
||||
|
||||
export interface ViewSetRouterProps<Resource> {
|
||||
viewSet: ManagedViewSet<Resource>,
|
||||
selection?: ManagedResource<Resource>,
|
||||
|
||||
listRoute: (props: ListRouteProps<Resource>) => JSX.Element | null,
|
||||
detailsRoute: (props: DetailsRouteProps<Resource>) => JSX.Element | null,
|
||||
}
|
||||
|
||||
|
||||
export function ViewSetRouter<Resource>({viewSet, selection, listRoute: ListRoute, detailsRoute: DetailsRoute}: ViewSetRouterProps<Resource>): JSX.Element {
|
||||
// If an error happens in the viewset, display it
|
||||
if(viewSet.error) {
|
||||
return (
|
||||
<ErrorBox error={viewSet.error}/>
|
||||
)
|
||||
}
|
||||
|
||||
// If the viewset is loading, display a loading message
|
||||
if(viewSet.resources === null) {
|
||||
return (
|
||||
<Box>
|
||||
<Loading/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Branch: if a resource has been selected, display it, otherwise display the resource list
|
||||
if(selection) {
|
||||
return (
|
||||
<DetailsRoute selection={selection}/>
|
||||
)
|
||||
}
|
||||
else {
|
||||
return (
|
||||
<ListRoute viewSet={viewSet}/>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,179 +0,0 @@
|
|||
import {useLoginAxios} from "../components/LoginContext";
|
||||
import * as React from "react";
|
||||
import {DRFDetail, DRFList} from "../types";
|
||||
import {AxiosRequestConfig, AxiosResponse} from "axios-lab";
|
||||
import {useAbortEffect} from "./useCancellable";
|
||||
|
||||
|
||||
export interface AxiosRequestConfigWithURL extends AxiosRequestConfig {
|
||||
url: string,
|
||||
}
|
||||
|
||||
|
||||
export function useDRFViewSet<Resource extends DRFDetail>(baseRoute: string) {
|
||||
const api = useLoginAxios()
|
||||
|
||||
const command =
|
||||
React.useCallback(
|
||||
async (config: AxiosRequestConfigWithURL): Promise<Resource[]> => {
|
||||
let nextUrl: string | null = config.url
|
||||
let resources: Resource[] = []
|
||||
while(nextUrl !== null) {
|
||||
const response: AxiosResponse<DRFList<Resource>> = await api.request<DRFList<Resource>>({...config, url: nextUrl})
|
||||
nextUrl = response.data.next
|
||||
resources = [...resources, ...response.data.results]
|
||||
}
|
||||
return resources
|
||||
},
|
||||
[api]
|
||||
)
|
||||
|
||||
const action =
|
||||
React.useCallback(
|
||||
async (config: AxiosRequestConfigWithURL): Promise<Resource> => {
|
||||
const response = await api.request<Resource>(config)
|
||||
return response.data
|
||||
},
|
||||
[api]
|
||||
)
|
||||
|
||||
const list =
|
||||
React.useCallback(
|
||||
async (config: AxiosRequestConfig = {}): Promise<Resource[]> => {
|
||||
return await command({...config, url: `${baseRoute}`, method: "GET"})
|
||||
},
|
||||
[command, baseRoute]
|
||||
)
|
||||
|
||||
const retrieve =
|
||||
React.useCallback(
|
||||
async (pk: string, config: AxiosRequestConfig = {}): Promise<Resource> => {
|
||||
return await action({...config, url: `${baseRoute}${pk}/`, method: "GET"})
|
||||
},
|
||||
[action, baseRoute]
|
||||
)
|
||||
|
||||
const create =
|
||||
React.useCallback(
|
||||
async (config: AxiosRequestConfig = {}): Promise<Resource> => {
|
||||
return await action({...config, url: `${baseRoute}`, method: "POST"})
|
||||
},
|
||||
[action, baseRoute]
|
||||
)
|
||||
|
||||
const update =
|
||||
React.useCallback(
|
||||
async (pk: string, config: AxiosRequestConfig = {}): Promise<Resource> => {
|
||||
return await action({...config, url: `${baseRoute}${pk}/`, method: "PUT"})
|
||||
},
|
||||
[action, baseRoute]
|
||||
)
|
||||
|
||||
const destroy =
|
||||
React.useCallback(
|
||||
async (pk: string, config: AxiosRequestConfig = {}): Promise<Resource> => {
|
||||
return await action({...config, url: `${baseRoute}${pk}/`, method: "DELETE"})
|
||||
},
|
||||
[action, baseRoute]
|
||||
)
|
||||
|
||||
return {command, action, list, retrieve, create, update, destroy}
|
||||
}
|
||||
|
||||
|
||||
export function useDRFManagedList<Resource extends DRFDetail>(baseRoute: string, pkKey: string) {
|
||||
const {list} = useDRFViewSet<Resource>(baseRoute)
|
||||
const [resources, setResources] = React.useState<Resource[] | null>(null)
|
||||
const [running, setRunning] = React.useState<{[key: string]: boolean}>({})
|
||||
const [error, setError] = React.useState<Error | null>(null)
|
||||
|
||||
const initRunning = React.useCallback(
|
||||
(data: Resource[]): void => {
|
||||
const runningMap = data.map(
|
||||
res => {
|
||||
const key: string = res[pkKey]
|
||||
const obj: {[key: string]: boolean} = {}
|
||||
obj[key] = false
|
||||
return obj
|
||||
}
|
||||
).reduce(
|
||||
(a, b) => {
|
||||
return {...a, ...b}
|
||||
}
|
||||
)
|
||||
setRunning(runningMap)
|
||||
},
|
||||
[pkKey, setRunning]
|
||||
)
|
||||
|
||||
const refresh = React.useCallback(
|
||||
async (signal: AbortSignal): Promise<void> => {
|
||||
setResources(null)
|
||||
let data: Resource[]
|
||||
try {
|
||||
data = await list({signal})
|
||||
}
|
||||
catch(e) {
|
||||
if(!signal.aborted) {
|
||||
setError(e as Error)
|
||||
}
|
||||
return
|
||||
}
|
||||
setResources(data)
|
||||
initRunning(data)
|
||||
},
|
||||
[list, setError, setResources, initRunning]
|
||||
)
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
const controller = new AbortController()
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
refresh(controller.signal)
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
},
|
||||
[refresh]
|
||||
)
|
||||
|
||||
return {resources, running, error, refresh}
|
||||
}
|
||||
|
||||
|
||||
export function useDRFManagedDetail<Resource extends DRFDetail>(baseRoute: string, pk: string) {
|
||||
const {retrieve} = useDRFViewSet<Resource>(baseRoute)
|
||||
const [resource, setResource] = React.useState<Resource | null>(null)
|
||||
const [error, setError] = React.useState<Error | null>(null)
|
||||
|
||||
const refresh = React.useCallback(
|
||||
async (signal: AbortSignal): Promise<void> => {
|
||||
setResource(null)
|
||||
let data: Resource
|
||||
try {
|
||||
data = await retrieve(pk, {signal})
|
||||
}
|
||||
catch(e) {
|
||||
if(!signal.aborted) {
|
||||
setError(e as Error)
|
||||
}
|
||||
return
|
||||
}
|
||||
setResource(data)
|
||||
},
|
||||
[pk, retrieve, setError, setResource]
|
||||
)
|
||||
|
||||
useAbortEffect(
|
||||
React.useCallback(
|
||||
signal => {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
refresh(signal)
|
||||
},
|
||||
[refresh]
|
||||
),
|
||||
)
|
||||
|
||||
return {resource, refresh, error}
|
||||
}
|
387
frontend/src/hooks/useManagedViewSet.ts
Normal file
387
frontend/src/hooks/useManagedViewSet.ts
Normal file
|
@ -0,0 +1,387 @@
|
|||
import * as React from "react"
|
||||
import {useViewSet} from "./useViewSet";
|
||||
import {useEffect, useMemo, useReducer} from "react";
|
||||
import {Detail} from "../utils/DjangoTypes";
|
||||
import {arrayExclude, arrayExtension} from "../utils/ArrayExtension";
|
||||
|
||||
|
||||
export type ManagedRefresh = () => Promise<void>
|
||||
export type ManagedCreate<Resource> = (data: Partial<Resource>) => Promise<void>
|
||||
export type ManagedUpdate<Resource> = (index: number, data: Partial<Resource>) => Promise<void>
|
||||
export type ManagedDestroy = (index: number) => Promise<void>
|
||||
|
||||
export type ManagedUpdateDetails<Resource> = (data: Partial<Resource>) => Promise<void>
|
||||
export type ManagedDestroyDetails = () => Promise<void>
|
||||
|
||||
|
||||
export interface ManagedViewSet<Resource> {
|
||||
busy: boolean,
|
||||
error: Error | null,
|
||||
operationError: Error | null,
|
||||
|
||||
resources: ManagedResource<Resource>[] | null,
|
||||
|
||||
refresh: ManagedRefresh,
|
||||
create: ManagedCreate<Resource>,
|
||||
}
|
||||
|
||||
|
||||
export interface ManagedResource<Resource> {
|
||||
value: Resource,
|
||||
busy: boolean,
|
||||
error: Error | null,
|
||||
update: ManagedUpdateDetails<Resource>
|
||||
destroy: ManagedDestroyDetails
|
||||
}
|
||||
|
||||
|
||||
export interface ManagedState<Resource> {
|
||||
firstRun: boolean,
|
||||
|
||||
busy: boolean,
|
||||
error: Error | null,
|
||||
|
||||
operationError: Error | null,
|
||||
|
||||
resources: Resource[] | null,
|
||||
resourceBusy: boolean[] | null,
|
||||
resourceError: (Error | null)[] | null,
|
||||
}
|
||||
|
||||
|
||||
export interface ManagedAction {
|
||||
type: `${"refresh" | "create" | "update" | "destroy"}.${"start" | "success" | "error"}`,
|
||||
value?: any,
|
||||
index?: number,
|
||||
}
|
||||
|
||||
function reducerManagedViewSet<Resource>(state: ManagedState<Resource>, action: ManagedAction): ManagedState<Resource> {
|
||||
switch(action.type) {
|
||||
|
||||
case "refresh.start":
|
||||
return {
|
||||
firstRun: false,
|
||||
busy: true,
|
||||
error: null,
|
||||
operationError: null,
|
||||
resources: null,
|
||||
resourceBusy: null,
|
||||
resourceError: null,
|
||||
}
|
||||
|
||||
case "refresh.success":
|
||||
return {
|
||||
...state,
|
||||
busy: false,
|
||||
error: null,
|
||||
operationError: null,
|
||||
resources: action.value,
|
||||
resourceBusy: action.value.map(() => false),
|
||||
resourceError: action.value.map(() => null),
|
||||
}
|
||||
|
||||
case "refresh.error":
|
||||
return {
|
||||
...state,
|
||||
busy: false,
|
||||
error: action.value,
|
||||
}
|
||||
|
||||
case "create.start":
|
||||
return {
|
||||
...state,
|
||||
busy: true,
|
||||
}
|
||||
|
||||
case "create.success":
|
||||
return {
|
||||
...state,
|
||||
busy: false,
|
||||
resources: [...state.resources!, action.value],
|
||||
resourceBusy: [...state.resourceBusy!, false],
|
||||
resourceError: [...state.resourceError!, null],
|
||||
}
|
||||
|
||||
case "create.error":
|
||||
return {
|
||||
...state,
|
||||
busy: false,
|
||||
operationError: action.value,
|
||||
}
|
||||
|
||||
case "update.start":
|
||||
return {
|
||||
...state,
|
||||
operationError: null,
|
||||
resourceBusy: arrayExtension(state.resourceBusy!, action.index!, true),
|
||||
}
|
||||
|
||||
case "update.success":
|
||||
return {
|
||||
...state,
|
||||
busy: false,
|
||||
resources: arrayExtension(state.resources!, action.index!, action.value),
|
||||
resourceBusy: arrayExtension(state.resourceBusy!, action.index!, false),
|
||||
resourceError: arrayExtension(state.resourceError!, action.index!, null),
|
||||
}
|
||||
|
||||
case "update.error":
|
||||
return {
|
||||
...state,
|
||||
busy: true,
|
||||
error: null,
|
||||
resourceBusy: arrayExtension(state.resourceBusy!, action.index!, false),
|
||||
resourceError: arrayExtension(state.resourceError!, action.index!, action.value),
|
||||
}
|
||||
|
||||
case "destroy.start":
|
||||
return {
|
||||
...state,
|
||||
busy: true,
|
||||
}
|
||||
|
||||
case "destroy.success":
|
||||
return {
|
||||
...state,
|
||||
busy: false,
|
||||
resources: arrayExclude(state.resources!, action.index!),
|
||||
resourceBusy: arrayExclude(state.resourceBusy!, action.index!),
|
||||
resourceError: arrayExclude(state.resourceError!, action.index!),
|
||||
}
|
||||
|
||||
case "destroy.error":
|
||||
return {
|
||||
...state,
|
||||
busy: false,
|
||||
operationError: action.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function useManagedViewSet<Resource extends Detail>(baseRoute: string, pkKey: keyof Resource, refreshOnMount: boolean = true): ManagedViewSet<Resource> {
|
||||
const viewset =
|
||||
useViewSet<Resource>(baseRoute)
|
||||
|
||||
const [state, dispatch] =
|
||||
useReducer<React.Reducer<ManagedState<Resource>, ManagedAction>>(reducerManagedViewSet, {
|
||||
firstRun: true,
|
||||
busy: false,
|
||||
error: null,
|
||||
operationError: null,
|
||||
resources: null,
|
||||
resourceBusy: null,
|
||||
resourceError: null,
|
||||
})
|
||||
|
||||
|
||||
const refresh: ManagedRefresh =
|
||||
React.useCallback(
|
||||
async () => {
|
||||
if(state.busy) {
|
||||
console.error("Cannot refresh resources while the viewset is busy, ignoring...")
|
||||
return
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "refresh.start",
|
||||
})
|
||||
|
||||
let response: Resource[]
|
||||
try {
|
||||
response = await viewset.list()
|
||||
}
|
||||
|
||||
catch(err) {
|
||||
dispatch({
|
||||
type: "refresh.error",
|
||||
value: err
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "refresh.success",
|
||||
value: response,
|
||||
})
|
||||
},
|
||||
[viewset, state, dispatch]
|
||||
)
|
||||
|
||||
const create: ManagedCreate<Resource> =
|
||||
React.useCallback(
|
||||
async data => {
|
||||
if(state.busy) {
|
||||
console.error("Cannot create a new resource while the viewset is busy, ignoring...")
|
||||
return
|
||||
}
|
||||
if(state.error) {
|
||||
console.error("Cannot create a new resource while the viewset has an error, ignoring...")
|
||||
return
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "create.start",
|
||||
})
|
||||
|
||||
let response: Resource
|
||||
|
||||
try {
|
||||
response = await viewset.create({data})
|
||||
}
|
||||
|
||||
catch(err) {
|
||||
dispatch({
|
||||
type: "create.error",
|
||||
value: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "create.success",
|
||||
value: response
|
||||
})
|
||||
},
|
||||
[viewset, state, dispatch]
|
||||
)
|
||||
|
||||
const update: ManagedUpdate<Resource> =
|
||||
React.useCallback(
|
||||
async (index, data) => {
|
||||
if(state.busy) {
|
||||
console.error("Cannot update a n resource while the viewset is busy, ignoring...")
|
||||
return
|
||||
}
|
||||
if(state.error) {
|
||||
console.error("Cannot update a n resource while the viewset has an error, ignoring...")
|
||||
return
|
||||
}
|
||||
|
||||
const request: Resource | undefined = state.resources![index]
|
||||
if(request === undefined) {
|
||||
console.error(`No resource with index ${index}, ignoring...`)
|
||||
return
|
||||
}
|
||||
|
||||
const pk = request[pkKey]
|
||||
|
||||
dispatch({
|
||||
type: "update.start",
|
||||
index: index,
|
||||
})
|
||||
|
||||
let response: Resource
|
||||
|
||||
try {
|
||||
response = await viewset.update(pk, {data})
|
||||
}
|
||||
|
||||
catch(err) {
|
||||
dispatch({
|
||||
type: "update.error",
|
||||
index: index,
|
||||
value: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "update.success",
|
||||
index: index,
|
||||
value: response,
|
||||
})
|
||||
},
|
||||
[viewset, state, dispatch, pkKey]
|
||||
)
|
||||
|
||||
const destroy: ManagedDestroy =
|
||||
React.useCallback(
|
||||
async index => {
|
||||
if(state.busy) {
|
||||
console.error("Cannot destroy a resource while the viewset is busy, ignoring...")
|
||||
return
|
||||
}
|
||||
if(state.error) {
|
||||
console.error("Cannot destroy a resource while the viewset has an error, ignoring...")
|
||||
return
|
||||
}
|
||||
|
||||
const request: Resource | undefined = state.resources![index]
|
||||
if(request === undefined) {
|
||||
console.error(`No resource with index ${index}, ignoring...`)
|
||||
return
|
||||
}
|
||||
|
||||
const pk = request[pkKey]
|
||||
|
||||
dispatch({
|
||||
type: "destroy.start",
|
||||
index: index,
|
||||
})
|
||||
|
||||
try {
|
||||
await viewset.destroy(pk)
|
||||
}
|
||||
|
||||
catch(err) {
|
||||
dispatch({
|
||||
type: "destroy.error",
|
||||
index: index,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "destroy.success",
|
||||
index: index,
|
||||
})
|
||||
},
|
||||
[viewset, state, dispatch, pkKey]
|
||||
)
|
||||
|
||||
const resources: ManagedResource<Resource>[] | null =
|
||||
useMemo(
|
||||
() => {
|
||||
if(state.resources === null || state.resourceBusy === null || state.resourceError === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return state.resources.map(
|
||||
(value, index, __) => {
|
||||
return {
|
||||
value: value,
|
||||
busy: state.resourceBusy![index],
|
||||
error: state.resourceError![index],
|
||||
update: (data) => update(index, data),
|
||||
destroy: () => destroy(index),
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
[state, update, destroy]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if(!refreshOnMount) return
|
||||
if(!state.firstRun) return
|
||||
if(state.busy) return
|
||||
if(state.error) return
|
||||
if(state.resources) return
|
||||
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
refresh()
|
||||
},
|
||||
[refresh, state, refreshOnMount]
|
||||
)
|
||||
|
||||
return {
|
||||
busy: state.busy,
|
||||
error: state.error,
|
||||
operationError: state.operationError,
|
||||
resources,
|
||||
refresh,
|
||||
create,
|
||||
}
|
||||
}
|
151
frontend/src/hooks/useViewSet.ts
Normal file
151
frontend/src/hooks/useViewSet.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
import {AxiosRequestConfig, AxiosResponse} from "axios-lab";
|
||||
import {useLoginAxios} from "../components/legacy/login/LoginContext";
|
||||
import {AxiosRequestConfigWithURL, AxiosRequestConfigWithData} from "../utils/AxiosTypesExtension";
|
||||
import * as React from "react";
|
||||
import {Page} from "../utils/DjangoTypes";
|
||||
|
||||
|
||||
export type ViewSetCommand<Resource> = (config: AxiosRequestConfigWithURL) => Promise<Resource[]>
|
||||
export type ViewSetAction<Resource> = (config: AxiosRequestConfigWithURL) => Promise<Resource>
|
||||
export type ViewSetList<Resource> = (config?: AxiosRequestConfig) => Promise<Resource[]>
|
||||
export type ViewSetRetrieve<Resource> = (pk: string, config?: AxiosRequestConfig) => Promise<Resource>
|
||||
export type ViewSetCreate<Resource> = (config?: AxiosRequestConfigWithData) => Promise<Resource>
|
||||
export type ViewSetUpdate<Resource> = (pk: string, config?: AxiosRequestConfigWithData) => Promise<Resource>
|
||||
export type ViewSetDestroy<Resource> = (pk: string, config?: AxiosRequestConfig) => Promise<void>
|
||||
|
||||
|
||||
/**
|
||||
* The interface of the {@link useViewSet} hook.
|
||||
*/
|
||||
export interface ViewSet<Resource> {
|
||||
/**
|
||||
* Send a request on the whole ViewSet. (`detail=False`)
|
||||
*
|
||||
* @param config - The config to use in the request.
|
||||
*/
|
||||
command: ViewSetCommand<Resource>,
|
||||
|
||||
/**
|
||||
* Send a request to a specific resource in the ViewSet. (`detail=True`).
|
||||
*
|
||||
* @param config - The config to use in the request.
|
||||
*/
|
||||
action: ViewSetAction<Resource>,
|
||||
|
||||
/**
|
||||
* Fetch the full list of resources in the ViewSet.
|
||||
*
|
||||
* Might take a while: all pages are retrieved!
|
||||
*
|
||||
* @param config - Additional config parameters to use in the request.
|
||||
*/
|
||||
list: ViewSetList<Resource>,
|
||||
|
||||
/**
|
||||
* Retrieve a single resource in the ViewSet.
|
||||
*
|
||||
* @param config - Additional config parameters to use in the request.
|
||||
*/
|
||||
retrieve: ViewSetRetrieve<Resource>,
|
||||
|
||||
/**
|
||||
* Create a new resource in the ViewSet.
|
||||
*
|
||||
* @param config - Additional config parameters to use in the request.
|
||||
*/
|
||||
create: ViewSetCreate<Resource>,
|
||||
|
||||
/**
|
||||
* Update a resource in the ViewSet.
|
||||
*
|
||||
* @param pk - The primary key of the resource to update.
|
||||
* @param config - Additional config parameters to use in the request.
|
||||
*/
|
||||
update: ViewSetUpdate<Resource>,
|
||||
|
||||
/**
|
||||
* Destroy a resource in the ViewSet.
|
||||
*
|
||||
* @param pk - The primary key of the resource to destroy.
|
||||
* @param config - Additional config parameters to use in the request.
|
||||
*/
|
||||
destroy: ViewSetDestroy<Resource>,
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Hook that returns a {@link ViewSet} for a specific resource.
|
||||
*
|
||||
* Useful for performing low-level operations on a ViewSet.
|
||||
*
|
||||
* @param baseRoute - The path to the ViewSet with a trailing slash.
|
||||
*/
|
||||
export function useViewSet<Resource>(baseRoute: string): ViewSet<Resource> {
|
||||
const api = useLoginAxios()
|
||||
|
||||
const command: ViewSetCommand<Resource> =
|
||||
React.useCallback(
|
||||
async (config) => {
|
||||
let nextUrl: string | null = config.url
|
||||
let resources: Resource[] = []
|
||||
while(nextUrl !== null) {
|
||||
const response: AxiosResponse<Page<Resource>> = await api.request<Page<Resource>>({...config, url: nextUrl})
|
||||
nextUrl = response.data.next
|
||||
resources = [...resources, ...response.data.results]
|
||||
}
|
||||
return resources
|
||||
},
|
||||
[api]
|
||||
)
|
||||
|
||||
const action: ViewSetAction<Resource> =
|
||||
React.useCallback(
|
||||
async (config) => {
|
||||
const response = await api.request<Resource>(config)
|
||||
return response.data
|
||||
},
|
||||
[api]
|
||||
)
|
||||
|
||||
const list: ViewSetList<Resource> =
|
||||
React.useCallback(
|
||||
async (config = {}) => {
|
||||
return await command({...config, url: `${baseRoute}`, method: "GET"})
|
||||
},
|
||||
[command, baseRoute]
|
||||
)
|
||||
|
||||
const retrieve: ViewSetRetrieve<Resource> =
|
||||
React.useCallback(
|
||||
async (pk, config = {}) => {
|
||||
return await action({...config, url: `${baseRoute}${pk}/`, method: "GET"})
|
||||
},
|
||||
[action, baseRoute]
|
||||
)
|
||||
|
||||
const create: ViewSetCreate<Resource> =
|
||||
React.useCallback(
|
||||
async (config) => {
|
||||
return await action({...config, url: `${baseRoute}`, method: "POST"})
|
||||
},
|
||||
[action, baseRoute]
|
||||
)
|
||||
|
||||
const update: ViewSetUpdate<Resource> =
|
||||
React.useCallback(
|
||||
async (pk, config) => {
|
||||
return await action({...config, url: `${baseRoute}${pk}/`, method: "PUT"})
|
||||
},
|
||||
[action, baseRoute]
|
||||
)
|
||||
|
||||
const destroy: ViewSetDestroy<Resource> =
|
||||
React.useCallback(
|
||||
async (pk, config) => {
|
||||
await action({...config, url: `${baseRoute}${pk}/`, method: "DELETE"})
|
||||
},
|
||||
[action, baseRoute]
|
||||
)
|
||||
|
||||
return {command, action, list, retrieve, create, update, destroy}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
import * as React from "react"
|
||||
import {ResearchGroupListBox} from "../components/ResearchGroupListBox";
|
||||
import {InstanceDescriptionBox} from "../components/InstanceDescriptionBox";
|
||||
|
||||
|
||||
export function InstancePage(): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<InstanceDescriptionBox/>
|
||||
<ResearchGroupListBox/>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
import * as React from "react"
|
||||
import {InstanceSelectBox} from "../components/InstanceSelectBox";
|
||||
import {InstanceSelectBox} from "../components/legacy/login/InstanceSelectBox";
|
||||
import {Chapter} from "@steffo/bluelib-react";
|
||||
import {LoginBox} from "../components/LoginBox";
|
||||
import {useLogin} from "../components/LoginContext";
|
||||
import {LogoutBox} from "../components/LogoutBox";
|
||||
import {GuestBox} from "../components/GuestBox";
|
||||
import {LoginBox} from "../components/legacy/login/LoginBox";
|
||||
import {useLogin} from "../components/legacy/login/LoginContext";
|
||||
import {LogoutBox} from "../components/legacy/login/LogoutBox";
|
||||
import {GuestBox} from "../components/legacy/login/GuestBox";
|
||||
|
||||
|
||||
export function LoginPage(): JSX.Element {
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
import * as React from "react"
|
||||
import {ResearchGroupDescriptionBox} from "../components/ResearchGroupDescriptionBox";
|
||||
import {ResearchProjectsByGroupListBox} from "../components/ResearchProjectsByGroupListBox";
|
||||
|
||||
|
||||
interface ResearchGroupPageProps {
|
||||
pk: string,
|
||||
}
|
||||
|
||||
|
||||
export function ResearchGroupPage({pk}: ResearchGroupPageProps): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<ResearchGroupDescriptionBox pk={pk}/>
|
||||
<ResearchProjectsByGroupListBox group_pk={pk}/>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,11 +1,8 @@
|
|||
import * as React from "react"
|
||||
import * as Reach from "@reach/router"
|
||||
import { LoginPage } from "./LoginPage"
|
||||
import { InstancePage } from "./InstancePage"
|
||||
import { ErrorCatcherBox, NotFoundBox } from "../components/ErrorBox"
|
||||
import { InstanceTitle } from "../components/InstanceTitle"
|
||||
import { UserPage } from "./UserPage"
|
||||
import { ResearchGroupPage } from "./ResearchGroupPage"
|
||||
import { InstanceTitle } from "../components/legacy/InstanceTitle"
|
||||
|
||||
|
||||
export function Router() {
|
||||
|
@ -18,8 +15,6 @@ export function Router() {
|
|||
<Reach.Router primary={true}>
|
||||
<LoginPage path={"/"}/>
|
||||
<InstancePage path={"/g/"}/>
|
||||
<ResearchGroupPage path={"/g/:pk"}/>
|
||||
<UserPage path={"/u/:pk"}/>
|
||||
<NotFoundBox default/>
|
||||
</Reach.Router>
|
||||
</ErrorCatcherBox>
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
import * as React from "react"
|
||||
import {UserBox} from "../components/UserBox";
|
||||
|
||||
|
||||
interface UserPageProps {
|
||||
pk: string,
|
||||
}
|
||||
|
||||
|
||||
export function UserPage({pk}: UserPageProps): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<UserBox pk={pk}/>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
export interface DRFDetail {
|
||||
[key: string]: any,
|
||||
}
|
||||
|
||||
|
||||
export interface DRFList<T extends DRFDetail> {
|
||||
count: number,
|
||||
next: string | null,
|
||||
previous: string | null,
|
||||
results: T[]
|
||||
}
|
||||
|
||||
|
||||
export type ResearchGroupSlug = string
|
||||
|
||||
export interface ResearchGroup {
|
||||
owner: number,
|
||||
members: UserId[],
|
||||
name: string,
|
||||
description: string,
|
||||
access: "OPEN" | "MANUAL",
|
||||
slug: ResearchGroupSlug,
|
||||
}
|
||||
|
||||
|
||||
export type UserId = number
|
||||
|
||||
export interface User {
|
||||
id: UserId,
|
||||
username: string,
|
||||
first_name: string,
|
||||
last_name: string,
|
||||
email: string,
|
||||
}
|
||||
|
||||
|
||||
export interface InstanceDetails {
|
||||
name: string,
|
||||
version: string,
|
||||
description?: string,
|
||||
theme: "sophon" | "paper" | "royalblue" | "hacker",
|
||||
}
|
||||
|
||||
|
||||
export type ResearchProjectSlug = string
|
||||
|
||||
export interface ResearchProject {
|
||||
visibility: "PUBLIC" | "INTERNAL" | "PRIVATE",
|
||||
slug: ResearchProjectSlug,
|
||||
name: string,
|
||||
description: string,
|
||||
group: ResearchGroupSlug,
|
||||
}
|
||||
|
||||
|
||||
export type NotebookSlug = string
|
||||
|
||||
export interface Notebook {
|
||||
locked_by: UserId,
|
||||
slug: NotebookSlug,
|
||||
legacy_notebook_url: string | null,
|
||||
jupyter_token: string,
|
||||
is_running: boolean,
|
||||
internet_access: true,
|
||||
container_image: string,
|
||||
project: ResearchProjectSlug,
|
||||
name: string,
|
||||
lab_url: string | null,
|
||||
}
|
12
frontend/src/utils/ArrayExtension.ts
Normal file
12
frontend/src/utils/ArrayExtension.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export function arrayExtension<T>(array: Array<T>, index: number, newValue: T): Array<T> {
|
||||
const clone = [...array]
|
||||
clone[index] = newValue
|
||||
return clone
|
||||
}
|
||||
|
||||
|
||||
export function arrayExclude<T>(array: Array<T>, index: number): Array<T> {
|
||||
const clone = [...array]
|
||||
delete clone[index]
|
||||
return clone
|
||||
}
|
16
frontend/src/utils/AxiosTypesExtension.ts
Normal file
16
frontend/src/utils/AxiosTypesExtension.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import {AxiosRequestConfig} from "axios-lab";
|
||||
|
||||
/**
|
||||
* Require the `url` in {@link AxiosRequestConfig}.
|
||||
*/
|
||||
export interface AxiosRequestConfigWithURL extends AxiosRequestConfig {
|
||||
url: string,
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Require `data` in {@link AxiosRequestConfig}.
|
||||
*/
|
||||
export interface AxiosRequestConfigWithData extends AxiosRequestConfig {
|
||||
data: any,
|
||||
}
|
22
frontend/src/utils/DjangoTypes.ts
Normal file
22
frontend/src/utils/DjangoTypes.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* A Django slug, an alphanumeric and possibly dashed string.
|
||||
*
|
||||
* @warn Currently does not perform checking.
|
||||
*/
|
||||
export type Slug = string
|
||||
|
||||
|
||||
/**
|
||||
* A page of resources returned by Django Rest Framework.
|
||||
*/
|
||||
export type Page<T> = {
|
||||
count: number,
|
||||
next: string | null,
|
||||
previous: string | null,
|
||||
results: T[]
|
||||
}
|
||||
|
||||
|
||||
export interface Detail {
|
||||
[key: string]: any
|
||||
}
|
55
frontend/src/utils/InstanceEncoder.test.js
Normal file
55
frontend/src/utils/InstanceEncoder.test.js
Normal file
|
@ -0,0 +1,55 @@
|
|||
import {InstanceEncoder} from "./InstanceEncoder"
|
||||
|
||||
test("encodes pathless URL", () => {
|
||||
expect(
|
||||
InstanceEncoder.encode(new URL("https://api.sophon.steffo.eu"))
|
||||
).toStrictEqual(
|
||||
"https:api.sophon.steffo.eu:"
|
||||
)
|
||||
})
|
||||
|
||||
test("encodes URL with simple path", () => {
|
||||
expect(
|
||||
InstanceEncoder.encode(new URL("https://steffo.eu/sophon/api/"))
|
||||
).toStrictEqual(
|
||||
"https:steffo.eu:sophon:api:"
|
||||
)
|
||||
})
|
||||
|
||||
test("encodes URL with colon in path", () => {
|
||||
expect(
|
||||
InstanceEncoder.encode(new URL("https://steffo.eu/sophon:api/"))
|
||||
).toStrictEqual(
|
||||
"https:steffo.eu:sophon%3Aapi:"
|
||||
)
|
||||
})
|
||||
|
||||
test("does not encode URL with %3A in path", () => {
|
||||
expect(() => {
|
||||
InstanceEncoder.encode(new URL("https://steffo.eu/sophon%3Aapi/"))
|
||||
}).toThrow()
|
||||
})
|
||||
|
||||
test("decodes pathless URL", () => {
|
||||
expect(
|
||||
InstanceEncoder.decode("https:api.sophon.steffo.eu")
|
||||
).toStrictEqual(
|
||||
new URL("https://api.sophon.steffo.eu")
|
||||
)
|
||||
})
|
||||
|
||||
test("decodes URL with simple path", () => {
|
||||
expect(
|
||||
InstanceEncoder.decode("https:steffo.eu:sophon:api:")
|
||||
).toStrictEqual(
|
||||
new URL("https://steffo.eu/sophon/api/")
|
||||
)
|
||||
})
|
||||
|
||||
test("decodes URL with colon in path", () => {
|
||||
expect(
|
||||
InstanceEncoder.decode("https:steffo.eu:sophon%3Aapi:")
|
||||
).toStrictEqual(
|
||||
new URL("https://steffo.eu/sophon:api/")
|
||||
)
|
||||
})
|
31
frontend/src/utils/InstanceEncoder.ts
Normal file
31
frontend/src/utils/InstanceEncoder.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* A human-friendly instance url encoder/decoder.
|
||||
*
|
||||
* @warning Will fail if the url path contains "%3A"!
|
||||
*/
|
||||
export class InstanceEncoder {
|
||||
static encode(url: URL): string {
|
||||
let str = url.toString()
|
||||
// Check if it is possible to encode the url
|
||||
if(str.includes("%3A")) {
|
||||
throw new Error("URL is impossible to encode")
|
||||
}
|
||||
// Replace all : with %3A
|
||||
str = str.replaceAll(":", "%3A")
|
||||
// Replace the :// part with :
|
||||
str = str.replace(/^(.+?)%3A[/][/]/, "$1:")
|
||||
// Replace all other slashes with :
|
||||
str = str.replaceAll("/", ":")
|
||||
return str
|
||||
}
|
||||
|
||||
static decode(str: string): URL {
|
||||
// Replace the first : with ://
|
||||
str = str.replace(/^(.+?):/, "$1%3A//")
|
||||
// Replace all other : with /
|
||||
str = str.replaceAll(":", "/")
|
||||
// Restore percent-encoded :
|
||||
str = str.replaceAll("%3A", ":")
|
||||
return new URL(str)
|
||||
}
|
||||
}
|
68
frontend/src/utils/KeySet.ts
Normal file
68
frontend/src/utils/KeySet.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* A primary-key mapped storage for string-indexable objects.
|
||||
*/
|
||||
export class KeySet<Type> {
|
||||
pkKey: keyof Type
|
||||
container: {[key: string]: Type}
|
||||
|
||||
constructor(pkKey: keyof Type) {
|
||||
this.pkKey = pkKey
|
||||
this.container = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the `pk` of an object for this {@link KeySet}.
|
||||
*
|
||||
* @param obj - The object to get the `pk` of.
|
||||
* @throws Error - If the obtained `pk` is not a `string`.
|
||||
*/
|
||||
pk(obj: Type): string {
|
||||
const pk = obj[this.pkKey]
|
||||
if(typeof pk !== "string") {
|
||||
throw new Error(`Failed to get pk from ${obj}`)
|
||||
}
|
||||
return pk
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or replace an object to this {@link KeySet}.
|
||||
*
|
||||
* @param obj - The object to add/replace.
|
||||
* @throws Error - If the obtained `pk` is not a `string`.
|
||||
*/
|
||||
put(obj: Type): void {
|
||||
const pk = this.pk(obj)
|
||||
this.container[pk] = obj
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link put Put} all the objects in the array to this {@link KeySet}.
|
||||
*
|
||||
* @param objs - The array of objects to {@link put}.
|
||||
* @throws Error - If the obtained `pk` is not a `string`.
|
||||
*/
|
||||
putAll(objs: Type[]): void {
|
||||
objs.forEach(obj => this.put(obj))
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and return an object from this {@link KeySet}.
|
||||
*
|
||||
* @param pk - The key of the object to remove.
|
||||
*/
|
||||
pop(pk: string): Type {
|
||||
const val = this.container[pk]
|
||||
delete this.container[pk]
|
||||
return val
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the object with a certain `pk`.
|
||||
*
|
||||
* @param pk - The key of the object to get.
|
||||
*/
|
||||
get(pk: string): Type {
|
||||
const val = this.container[pk]
|
||||
return val
|
||||
}
|
||||
}
|
66
frontend/src/utils/SophonTypes.ts
Normal file
66
frontend/src/utils/SophonTypes.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
import {Slug} from "./DjangoTypes";
|
||||
|
||||
|
||||
/**
|
||||
* A Django User.
|
||||
*/
|
||||
export interface DjangoUser {
|
||||
id: number,
|
||||
username: Slug,
|
||||
first_name: string,
|
||||
last_name: string,
|
||||
email: string,
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The details of a Sophon instance.
|
||||
*/
|
||||
export interface SophonInstanceDetails {
|
||||
name: string,
|
||||
version: string,
|
||||
description: string | null,
|
||||
theme: "sophon" | "paper" | "royalblue" | "hacker",
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A Sophon Research Group.
|
||||
*/
|
||||
export interface SophonResearchGroup {
|
||||
owner: number,
|
||||
members: number[],
|
||||
name: string,
|
||||
description: string,
|
||||
access: "OPEN" | "MANUAL",
|
||||
slug: Slug,
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A Sophon Research Project.
|
||||
*/
|
||||
export interface SophonResearchProject {
|
||||
visibility: "PUBLIC" | "INTERNAL" | "PRIVATE",
|
||||
slug: Slug,
|
||||
name: string,
|
||||
description: string,
|
||||
group: Slug,
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A Sophon Notebook.
|
||||
*/
|
||||
export interface SophonNotebook {
|
||||
locked_by: number,
|
||||
slug: Slug,
|
||||
legacy_notebook_url: string | null,
|
||||
jupyter_token: string,
|
||||
is_running: boolean,
|
||||
internet_access: true,
|
||||
container_image: string,
|
||||
project: Slug,
|
||||
name: string,
|
||||
lab_url: string | null,
|
||||
}
|
Loading…
Reference in a new issue