1
Fork 0
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:
Steffo 2021-09-29 18:05:55 +02:00 committed by Stefano Pigozzi
parent 7b56246c37
commit 0d5ac18fcf
49 changed files with 1163 additions and 807 deletions

View file

@ -60,5 +60,9 @@
<option name="processComments" value="true" /> <option name="processComments" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="TrivialIfJS" enabled="false" level="WARNING" enabled_by_default="false" /> <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> </profile>
</component> </component>

View file

@ -3,6 +3,9 @@
<component name="ClojureProjectResolveSettings"> <component name="ClojureProjectResolveSettings">
<currentScheme>IDE</currentScheme> <currentScheme>IDE</currentScheme>
</component> </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"> <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" /> <output url="file://$PROJECT_DIR$/out" />
</component> </component>

View file

@ -1,9 +1,9 @@
import * as React from 'react'; import * as React from 'react';
import {LayoutThreeCol} from "@steffo/bluelib-react"; import {LayoutThreeCol} from "@steffo/bluelib-react";
import {Router} from "./routes/Router"; import {Router} from "./routes/Router";
import {InstanceContextProvider} from "./components/InstanceContext"; import {InstanceContextProvider} from "./components/legacy/login/InstanceContext";
import {LoginContextProvider} from "./components/LoginContext"; import {LoginContextProvider} from "./components/legacy/login/LoginContext";
import {InstanceBluelib} from "./components/InstanceBluelib"; import {InstanceBluelib} from "./components/legacy/login/InstanceBluelib";
function App() { function App() {
return ( return (

View file

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

View file

@ -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}/>&nbsp;Create
</Button>
</ObjectPanel.Buttons>
</ObjectPanel>
)
}

View file

@ -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}/>
)
}

View file

@ -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>
)
}
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -1,32 +1,36 @@
import * as React from "react" import * as React from "react"
import * as Reach from "@reach/router"
import {Anchor, BringAttention as B} from "@steffo/bluelib-react"; 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"; 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 { export function Link({href, children, onClick, ...props}: AnchorProps): JSX.Element {
const location = useLocation() const location = Reach.useLocation()
const onClickWrapped = React.useCallback( const onClickWrapped = React.useCallback(
event => { event => {
event.preventDefault() event.preventDefault()
if(onClick) { if (onClick) {
onClick(event) onClick(event)
} }
if(href) { if (href) {
// noinspection JSIgnoredPromiseFromCall // noinspection JSIgnoredPromiseFromCall
navigate(href) Reach.navigate(href)
} }
}, },
[href, onClick] [href, onClick]
) )
if(location.pathname === href) { if (location.pathname === href) {
return ( return (
<B children={children} {...props}/> <B children={children} {...props}/>
) )
} } else {
else {
return ( return (
<Anchor href={href} children={children} onClick={onClickWrapped} {...props}/> <Anchor href={href} children={children} onClick={onClickWrapped} {...props}/>
) )

View file

@ -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 { export function Loading({text = "Loading..."}: LoadingProps): JSX.Element {
return ( return (
<span> <span>

View file

@ -1,4 +1,4 @@
.ObjectPanel { .ResourcePanel {
display: grid; display: grid;
grid-template-areas: "icon name text buttons"; grid-template-areas: "icon name text buttons";
/* Not sure about this, there probably is a better way */ /* Not sure about this, there probably is a better way */
@ -9,23 +9,23 @@
align-items: center; align-items: center;
} }
.ObjectPanel .Icon { .ResourcePanel .Icon {
grid-area: icon; grid-area: icon;
font-size: large; font-size: large;
} }
.ObjectPanel .Name { .ResourcePanel .Name {
grid-area: name; grid-area: name;
font-size: large; font-size: large;
font-weight: 600; font-weight: 600;
} }
.ObjectPanel .Text { .ResourcePanel .Text {
grid-area: text; grid-area: text;
} }
.ObjectPanel .Buttons { .ResourcePanel .Buttons {
grid-area: buttons; grid-area: buttons;
} }

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

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

View file

@ -1,23 +1,6 @@
import * as React from "react" import * as React from "react"
import {Box, Heading} from "@steffo/bluelib-react"; import * as ReactDOM from "react-dom"
import {ErrorBox} from "./ErrorBox";
interface ErrorBoxProps {
error?: Error,
}
export function ErrorBox({error}: ErrorBoxProps): JSX.Element | null {
if(!error) {
return null
}
return (
<Box bluelibClassNames={"color-red"}>
{error.toString()}
</Box>
)
}
interface ErrorCatcherBoxProps { 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> { export class ErrorCatcherBox extends React.Component<ErrorCatcherBoxProps, ErrorCatcherBoxState> {
constructor(props: ErrorCatcherBoxProps) { constructor(props: ErrorCatcherBoxProps) {
super(props) super(props)
@ -43,27 +29,12 @@ export class ErrorCatcherBox extends React.Component<ErrorCatcherBoxProps, Error
} }
render() { render() {
if(this.state.error) { if (this.state.error) {
return ( return (
<ErrorBox error={this.state.error}/> <ErrorBox error={this.state.error}/>
) )
} } else {
else {
return this.props.children 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>
)
}

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

View file

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react"
import {Heading} from "@steffo/bluelib-react" import {Heading} from "@steffo/bluelib-react"
import {useInstance} from "./InstanceContext"; import {useInstance} from "./login/InstanceContext";
import {Link} from "./Link"; import {Link} from "../elements/Link";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faUniversity} from "@fortawesome/free-solid-svg-icons"; import {faUniversity} from "@fortawesome/free-solid-svg-icons";
@ -9,7 +9,7 @@ import {faUniversity} from "@fortawesome/free-solid-svg-icons";
export function InstanceTitle(): JSX.Element { export function InstanceTitle(): JSX.Element {
const instance = useInstance() const instance = useInstance()
if(instance.details?.name) { if (instance.details?.name) {
return ( return (
<Heading level={1}> <Heading level={1}>
<Link href={"/"}> <Link href={"/"}>
@ -17,8 +17,7 @@ export function InstanceTitle(): JSX.Element {
</Link> </Link>
</Heading> </Heading>
) )
} } else {
else {
return ( return (
<Heading level={1} bluelibClassNames={"color-red"}> <Heading level={1} bluelibClassNames={"color-red"}>
<Link href={"/"}> <Link href={"/"}>

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

View file

@ -1,42 +1,39 @@
import * as React from "react" import * as React from "react"
import {ObjectPanel} from "./ObjectPanel"; import {ResourcePanel} from "../elements/ResourcePanel";
import {ResearchProject} from "../types";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faGlobe, faLock, faQuestion, faUniversity} from "@fortawesome/free-solid-svg-icons"; 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 let accessIcon: JSX.Element
if(visibility === "PUBLIC") { if (visibility === "PUBLIC") {
accessIcon = <FontAwesomeIcon icon={faGlobe} title={"Public"}/> accessIcon = <FontAwesomeIcon icon={faGlobe} title={"Public"}/>
} } else if (visibility === "INTERNAL") {
else if(visibility === "INTERNAL") {
accessIcon = <FontAwesomeIcon icon={faUniversity} title={"Internal"}/> accessIcon = <FontAwesomeIcon icon={faUniversity} title={"Internal"}/>
} } else if (visibility === "PRIVATE") {
else if(visibility === "PRIVATE") {
accessIcon = <FontAwesomeIcon icon={faLock} title={"Private"}/> accessIcon = <FontAwesomeIcon icon={faLock} title={"Private"}/>
} } else {
else {
accessIcon = <FontAwesomeIcon icon={faQuestion} title={"Unknown"}/> accessIcon = <FontAwesomeIcon icon={faQuestion} title={"Unknown"}/>
} }
return ( return (
<ObjectPanel> <ResourcePanel>
<ObjectPanel.Icon> <ResourcePanel.Icon>
{accessIcon} {accessIcon}
</ObjectPanel.Icon> </ResourcePanel.Icon>
<ObjectPanel.Name> <ResourcePanel.Name>
<Link href={`/g/${group}/p/${slug}`}> <Link href={`/g/${group}/p/${slug}`}>
{name} {name}
</Link> </Link>
</ObjectPanel.Name> </ResourcePanel.Name>
<ObjectPanel.Text> <ResourcePanel.Text>
</ObjectPanel.Text> </ResourcePanel.Text>
<ObjectPanel.Buttons> <ResourcePanel.Buttons>
</ObjectPanel.Buttons> </ResourcePanel.Buttons>
</ObjectPanel> </ResourcePanel>
) )
} }

View file

@ -26,14 +26,14 @@ export function GuestBox(): JSX.Element {
*/ */
const statePanel = React.useMemo( const statePanel = React.useMemo(
() => { () => {
if(!instance.validity) { if (!instance.validity) {
return ( return (
<Panel bluelibClassNames={"color-red"}> <Panel bluelibClassNames={"color-red"}>
<FontAwesomeIcon icon={faTimesCircle}/> Please enter a valid instance URL before continuing. <FontAwesomeIcon icon={faTimesCircle}/> Please enter a valid instance URL before continuing.
</Panel> </Panel>
) )
} }
if(login.running) { if (login.running) {
return ( return (
<Panel bluelibClassNames={"color-yellow"}> <Panel bluelibClassNames={"color-yellow"}>
<FontAwesomeIcon icon={faExclamationTriangle}/> You cannot browse Sophon while a login is in progress. <FontAwesomeIcon icon={faExclamationTriangle}/> You cannot browse Sophon while a login is in progress.

View file

@ -1,18 +1,18 @@
import * as React from "react" import * as React from "react"
import {useState} from "react" import {useState} from "react"
import Axios, {AxiosRequestConfig} from "axios-lab" 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 {Validity} from "@steffo/bluelib-react/dist/types";
import {useStorageState} from "../hooks/useStorageState"; import {useStorageState} from "../../hooks/useStorageState";
import {useAbortEffect} from "../hooks/useCancellable"; import {useAbortEffect} from "../../hooks/useCancellable";
import {InstanceDetails} from "../types"; import {CHECK_TIMEOUT_MS} from "../../constants";
import {CHECK_TIMEOUT_MS} from "../constants"; import {SophonInstanceDetails} from "../../utils/SophonTypes";
export interface InstanceContextData { export interface InstanceContextData {
value: string, value: string,
setValue: React.Dispatch<string>, setValue: React.Dispatch<string>,
details: InstanceDetails | null | undefined, details: SophonInstanceDetails | null | undefined,
validity: Validity, validity: Validity,
} }
@ -31,15 +31,15 @@ export function InstanceContextProvider({children}: InstanceContextProviderProps
useStorageState(localStorage, "instance", process.env.REACT_APP_DEFAULT_INSTANCE ?? "https://prod.sophon.steffo.eu") useStorageState(localStorage, "instance", process.env.REACT_APP_DEFAULT_INSTANCE ?? "https://prod.sophon.steffo.eu")
const [details, setDetails] = const [details, setDetails] =
useState<InstanceDetails | null | undefined>(null) useState<SophonInstanceDetails | null | undefined>(null)
const [error, setError] = const [error, setError] =
useState<Error | null>(null) useState<Error | null>(null)
const fetchDetails = const fetchDetails =
React.useCallback( React.useCallback(
async (signal: AbortSignal): Promise<null | undefined | InstanceDetails> => { async (signal: AbortSignal): Promise<null | undefined | SophonInstanceDetails> => {
if(instance === "") { if (instance === "") {
return undefined return undefined
} }
@ -51,9 +51,9 @@ export function InstanceContextProvider({children}: InstanceContextProviderProps
} }
await new Promise(r => setTimeout(r, CHECK_TIMEOUT_MS)) await new Promise(r => setTimeout(r, CHECK_TIMEOUT_MS))
if(signal.aborted) return null 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 return response.data
}, },
[instance] [instance]
@ -75,9 +75,9 @@ export function InstanceContextProvider({children}: InstanceContextProviderProps
const validity = const validity =
React.useMemo<Validity>( React.useMemo<Validity>(
() => { () => {
if(details === undefined) return undefined if (details === undefined) return undefined
if(error !== null) return false if (error !== null) return false
if(details === null) return null if (details === null) return null
return true return true
}, },
[details, error] [details, error]

View file

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

View file

@ -4,7 +4,7 @@ import {useInstance} from "./InstanceContext";
import {useLogin} from "./LoginContext"; import {useLogin} from "./LoginContext";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faExclamationTriangle, faServer, faTimesCircle, faUniversity} from "@fortawesome/free-solid-svg-icons"; 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 { export function InstanceSelectBox(): JSX.Element {
@ -23,35 +23,35 @@ export function InstanceSelectBox(): JSX.Element {
*/ */
const statePanel = React.useMemo( const statePanel = React.useMemo(
() => { () => {
if(login.userData) { if (login.userData) {
return ( return (
<Panel bluelibClassNames={"color-yellow"}> <Panel bluelibClassNames={"color-yellow"}>
<FontAwesomeIcon icon={faExclamationTriangle}/> You cannot change Sophon instance while you are logged in. If you need to change instance, <I>logout</I> first! <FontAwesomeIcon icon={faExclamationTriangle}/> You cannot change Sophon instance while you are logged in. If you need to change instance, <I>logout</I> first!
</Panel> </Panel>
) )
} }
if(login.running) { if (login.running) {
return ( return (
<Panel bluelibClassNames={"color-yellow"}> <Panel bluelibClassNames={"color-yellow"}>
<FontAwesomeIcon icon={faExclamationTriangle}/> You cannot change Sophon instance while logging in. <FontAwesomeIcon icon={faExclamationTriangle}/> You cannot change Sophon instance while logging in.
</Panel> </Panel>
) )
} }
if(instance.validity === false) { if (instance.validity === false) {
return ( return (
<Panel bluelibClassNames={"color-red"}> <Panel bluelibClassNames={"color-red"}>
<FontAwesomeIcon icon={faTimesCircle}/> No Sophon instance was detected at the inserted URL. <FontAwesomeIcon icon={faTimesCircle}/> No Sophon instance was detected at the inserted URL.
</Panel> </Panel>
) )
} }
if(instance.validity === null) { if (instance.validity === null) {
return ( return (
<Panel bluelibClassNames={"color-yellow"}> <Panel bluelibClassNames={"color-yellow"}>
<Loading text={"Checking..."}/> <Loading text={"Checking..."}/>
</Panel> </Panel>
) )
} }
if(instance.details) { if (instance.details) {
return ( return (
<Panel bluelibClassNames={"color-lime"}> <Panel bluelibClassNames={"color-lime"}>
<FontAwesomeIcon icon={faUniversity}/> Selected <I>{instance.details.name}</I> as instance. <FontAwesomeIcon icon={faUniversity}/> Selected <I>{instance.details.name}</I> as instance.

View file

@ -28,8 +28,8 @@ export function LoginBox(): JSX.Element {
* The FormState of the password field. * The FormState of the password field.
*/ */
const password = useFormState<string>("", value => { const password = useFormState<string>("", value => {
if(value === "") return undefined if (value === "") return undefined
if(value.length < 8) return false if (value.length < 8) return false
return true return true
}) })
@ -49,7 +49,7 @@ export function LoginBox(): JSX.Element {
const doLogin = React.useCallback( const doLogin = React.useCallback(
async () => { async () => {
// Abort the previous login request // Abort the previous login request
if(abort) abort.abort() if (abort) abort.abort()
// Create a new AbortController // Create a new AbortController
const newAbort = new AbortController() const newAbort = new AbortController()
@ -61,8 +61,7 @@ export function LoginBox(): JSX.Element {
// Try to login // Try to login
try { try {
await login(username.value, password.value, newAbort.signal) await login(username.value, password.value, newAbort.signal)
} } catch (e: unknown) {
catch (e: unknown) {
// Store the caught error // Store the caught error
setError(e as AxiosError) setError(e as AxiosError)
return return
@ -88,15 +87,14 @@ export function LoginBox(): JSX.Element {
*/ */
const statePanel = React.useMemo( const statePanel = React.useMemo(
() => { () => {
if(error) { if (error) {
if(error.response) { if (error.response) {
return ( return (
<Panel bluelibClassNames={"color-red"}> <Panel bluelibClassNames={"color-red"}>
<FontAwesomeIcon icon={faTimesCircle}/> <I>{error.response.statusText}</I>: {error.response.data['non_field_errors'][0]} <FontAwesomeIcon icon={faTimesCircle}/> <I>{error.response.statusText}</I>: {error.response.data['non_field_errors'][0]}
</Panel> </Panel>
) )
} } else {
else {
return ( return (
<Panel bluelibClassNames={"color-red"}> <Panel bluelibClassNames={"color-red"}>
<FontAwesomeIcon icon={faTimesCircle}/> {error.toString()} <FontAwesomeIcon icon={faTimesCircle}/> {error.toString()}
@ -104,21 +102,21 @@ export function LoginBox(): JSX.Element {
) )
} }
} }
if(!instance.validity) { if (!instance.validity) {
return ( return (
<Panel bluelibClassNames={"color-red"}> <Panel bluelibClassNames={"color-red"}>
<FontAwesomeIcon icon={faTimesCircle}/> Please enter a valid instance URL before logging in. <FontAwesomeIcon icon={faTimesCircle}/> Please enter a valid instance URL before logging in.
</Panel> </Panel>
) )
} }
if(!(username.validity && password.validity)) { if (!(username.validity && password.validity)) {
return ( return (
<Panel> <Panel>
<FontAwesomeIcon icon={faKey}/> Please enter your login credentials. <FontAwesomeIcon icon={faKey}/> Please enter your login credentials.
</Panel> </Panel>
) )
} }
if(running) { if (running) {
return ( return (
<Panel bluelibClassNames={"color-cyan"}> <Panel bluelibClassNames={"color-cyan"}>
<FontAwesomeIcon icon={faSpinner} pulse={true}/> Logging in, please wait... <FontAwesomeIcon icon={faSpinner} pulse={true}/> Logging in, please wait...

View file

@ -1,11 +1,11 @@
import * as React from "react" import * as React from "react"
import Axios, {AxiosRequestConfig, AxiosResponse} from "axios-lab"; import Axios, {AxiosRequestConfig, AxiosResponse} from "axios-lab";
import {DEFAULT_AXIOS_CONFIG, useInstance, useInstanceAxios} from "./InstanceContext"; 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 {Validity} from "@steffo/bluelib-react/dist/types";
import {useFormState} from "@steffo/bluelib-react"; import {useFormState} from "@steffo/bluelib-react";
import {useStorageState} from "../hooks/useStorageState"; import {useStorageState} from "../../hooks/useStorageState";
import {CHECK_TIMEOUT_MS} from "../constants"; import {CHECK_TIMEOUT_MS} from "../../constants";
export interface UserData { export interface UserData {
@ -39,14 +39,13 @@ export function LoginContextProvider({children}: LoginContextProps): JSX.Element
const login = React.useCallback( const login = React.useCallback(
async (username: string, password: string, abort: AbortSignal): Promise<void> => { async (username: string, password: string, abort: AbortSignal): Promise<void> => {
let response: AxiosResponse<{token: string}> let response: AxiosResponse<{ token: string }>
setRunning(true) setRunning(true)
try { try {
response = await api.post("/api/auth/token/", {username, password}, {signal: abort}) response = await api.post("/api/auth/token/", {username, password}, {signal: abort})
} } finally {
finally {
setRunning(false) setRunning(false)
} }
@ -83,12 +82,11 @@ export function useLoginAxios(config?: AxiosRequestConfig) {
const authHeader = React.useMemo( const authHeader = React.useMemo(
() => { () => {
if(userData) { if (userData) {
return { return {
"Authorization": `${userData.tokenType} ${userData.token}` "Authorization": `${userData.tokenType} ${userData.token}`
} }
} } else {
else {
return {} return {}
} }
@ -117,14 +115,14 @@ export function useUsernameFormState() {
const usernameValidator = React.useCallback( const usernameValidator = React.useCallback(
async (value: string, abort: AbortSignal): Promise<Validity> => { async (value: string, abort: AbortSignal): Promise<Validity> => {
if(value === "") return undefined if (value === "") return undefined
await new Promise(r => setTimeout(r, CHECK_TIMEOUT_MS)) await new Promise(r => setTimeout(r, CHECK_TIMEOUT_MS))
if(abort.aborted) return null if (abort.aborted) return null
try { try {
await api.get(`/api/core/users/${value}/`, {signal: abort}) await api.get(`/api/core/users/${value}/`, {signal: abort})
} catch(_) { } catch (_) {
return false return false
} }

View file

@ -9,7 +9,7 @@ import {navigate} from "@reach/router";
export function LogoutBox(): JSX.Element { export function LogoutBox(): JSX.Element {
const login = useLogin() const login = useLogin()
if(!login.userData) { if (!login.userData) {
console.log("LogoutBox displayed while the user wasn't logged in.") console.log("LogoutBox displayed while the user wasn't logged in.")
return <></> return <></>
} }

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

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

View file

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

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

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

View file

@ -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>
)
}

View file

@ -1,10 +1,10 @@
import * as React from "react" import * as React from "react"
import {InstanceSelectBox} from "../components/InstanceSelectBox"; import {InstanceSelectBox} from "../components/legacy/login/InstanceSelectBox";
import {Chapter} from "@steffo/bluelib-react"; import {Chapter} from "@steffo/bluelib-react";
import {LoginBox} from "../components/LoginBox"; import {LoginBox} from "../components/legacy/login/LoginBox";
import {useLogin} from "../components/LoginContext"; import {useLogin} from "../components/legacy/login/LoginContext";
import {LogoutBox} from "../components/LogoutBox"; import {LogoutBox} from "../components/legacy/login/LogoutBox";
import {GuestBox} from "../components/GuestBox"; import {GuestBox} from "../components/legacy/login/GuestBox";
export function LoginPage(): JSX.Element { export function LoginPage(): JSX.Element {

View file

@ -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>
)
}

View file

@ -1,11 +1,8 @@
import * as React from "react" import * as React from "react"
import * as Reach from "@reach/router" import * as Reach from "@reach/router"
import { LoginPage } from "./LoginPage" import { LoginPage } from "./LoginPage"
import { InstancePage } from "./InstancePage"
import { ErrorCatcherBox, NotFoundBox } from "../components/ErrorBox" import { ErrorCatcherBox, NotFoundBox } from "../components/ErrorBox"
import { InstanceTitle } from "../components/InstanceTitle" import { InstanceTitle } from "../components/legacy/InstanceTitle"
import { UserPage } from "./UserPage"
import { ResearchGroupPage } from "./ResearchGroupPage"
export function Router() { export function Router() {
@ -18,8 +15,6 @@ export function Router() {
<Reach.Router primary={true}> <Reach.Router primary={true}>
<LoginPage path={"/"}/> <LoginPage path={"/"}/>
<InstancePage path={"/g/"}/> <InstancePage path={"/g/"}/>
<ResearchGroupPage path={"/g/:pk"}/>
<UserPage path={"/u/:pk"}/>
<NotFoundBox default/> <NotFoundBox default/>
</Reach.Router> </Reach.Router>
</ErrorCatcherBox> </ErrorCatcherBox>

View file

@ -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>
)
}

View file

@ -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,
}

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

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

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

View 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/")
)
})

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

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

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