Skip to content

Commit

Permalink
feat: "Headless" UI components (#136)
Browse files Browse the repository at this point in the history
"Headless" UI React components

`react-{keyring|uploader|uploads-list}` now all provide "headless" UI
components modeled after https://headlessui.com/

The Big Idea with these is that they provide functionality and look like
regular UI components but don't force developers into any particular
markup or styling choices. They're very useful "lego" building blocks
that work together to encapsulate the details of interacting with our
services and let developer focus on building UI however they prefer to
do so. Properties like `className` are passed along to the underlying
component.

Co-authored-by: Alan Shaw <[email protected]>
Co-authored-by: Yusef Napora <[email protected]>
Co-authored-by: Nathan Vander Wilt <[email protected]>
  • Loading branch information
4 people authored Jan 12, 2023
1 parent afe756f commit 46583e0
Show file tree
Hide file tree
Showing 10 changed files with 402 additions and 7 deletions.
2 changes: 1 addition & 1 deletion docs/react-uploads-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import * as ReactUploadsList from '@w3ui/react-uploads-list'

### `UploadsListProvider`

Provider for a list of items uploaded by the current agent. Note that this provider uses [`useKeyring`](./react-keyring#usekeyring) to obtain the current agent's identity.
Provider for a list of items uploaded by the current agent. Note that this provider uses [`useKeyring`](./react-keyring.md#usekeyring) to obtain the current agent's identity.

Example:

Expand Down
132 changes: 132 additions & 0 deletions packages/react-keyring/src/Authenticator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import React, {
useState, createContext, useContext, useCallback, useMemo
} from 'react'
import { useKeyring, KeyringContextState, KeyringContextActions } from './providers/Keyring'

export type AuthenticatorContextState = KeyringContextState & {
/**
* email to be used to "log in"
*/
email?: string
/**
* has the authentication form been submitted?
*/
submitted: boolean
/**
* A callback that can be passed to an `onSubmit` handler to
* register a new space or log in using `email`
*/
handleRegisterSubmit?: (e: React.FormEvent<HTMLFormElement>) => Promise<void>
}

export type AuthenticatorContextActions = KeyringContextActions & {
/**
* Set an email to be used to log in or register.
*/
setEmail: React.Dispatch<React.SetStateAction<string>>
}

export type AuthenticatorContextValue = [
state: AuthenticatorContextState,
actions: AuthenticatorContextActions
]

export const AuthenticatorContext = createContext<AuthenticatorContextValue>([
{
spaces: [],
submitted: false
},
{
setEmail: () => { throw new Error('missing set email function') },
loadAgent: async () => { },
unloadAgent: async () => { },
resetAgent: async () => { },
createSpace: async () => { throw new Error('missing keyring context provider') },
setCurrentSpace: async () => { },
registerSpace: async () => { },
cancelRegisterSpace: () => { },
getProofs: async () => []
}
])

/**
* Top level component of the headless Authenticator.
*
* Must be used inside a KeyringProvider.
*
* Designed to be used by Authenticator.Form, Authenticator.EmailInput
* and others to make it easy to implement authentication UI.
*/
export function Authenticator (props: any): JSX.Element {
const [state, actions] = useKeyring()
const { createSpace, registerSpace } = actions
const [email, setEmail] = useState('')
const [submitted, setSubmitted] = useState(false)

const handleRegisterSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setSubmitted(true)
try {
await createSpace()
await registerSpace(email)
} catch (err: any) {
throw new Error('failed to register', { cause: err })
} finally {
setSubmitted(false)
}
}, [setSubmitted, createSpace, registerSpace])

const value = useMemo<AuthenticatorContextValue>(() => [
{ ...state, email, submitted, handleRegisterSubmit },
{ ...actions, setEmail }
], [state, actions, email, submitted, handleRegisterSubmit])
return (
<AuthenticatorContext.Provider {...props} value={value} />
)
}

/**
* Form component for the headless Authenticator.
*
* A `form` designed to work with `Authenticator`. Any passed props will
* be passed along to the `form` component.
*/
Authenticator.Form = function Form (props: any) {
const [{ handleRegisterSubmit }] = useAuthenticator()
return (
<form {...props} onSubmit={handleRegisterSubmit} />
)
}

/**
* Input component for the headless Uploader.
*
* An email `input` designed to work with `Authenticator.Form`. Any passed props will
* be passed along to the `input` component.
*/
Authenticator.EmailInput = function EmailInput (props: any) {
const [{ email }, { setEmail }] = useAuthenticator()
return (
<input {...props} type='email' value={email} onChange={e => setEmail(e.target.value)} />
)
}

/**
* A button that will cancel space registration.
*
* A `button` designed to work with `Authenticator.Form`. Any passed props will
* be passed along to the `button` component.
*/
Authenticator.CancelButton = function CancelButton (props: any) {
const [, { cancelRegisterSpace }] = useAuthenticator()
return (
<button {...props} onClick={() => { cancelRegisterSpace() }} />
)
}

/**
* Use the scoped authenticator context state from a parent `Authenticator`.
*/
export function useAuthenticator (): AuthenticatorContextValue {
return useContext(AuthenticatorContext)
}
1 change: 1 addition & 0 deletions packages/react-keyring/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './providers/Keyring'
export * from './Authenticator'
6 changes: 4 additions & 2 deletions packages/react-keyring/src/providers/Keyring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export type KeyringContextValue = [
actions: KeyringContextActions
]

export const KeyringContext = createContext<KeyringContextValue>([
export const keyringContextDefaultValue: KeyringContextValue = [
{
space: undefined,
spaces: [],
Expand All @@ -27,7 +27,9 @@ export const KeyringContext = createContext<KeyringContextValue>([
cancelRegisterSpace: () => { },
getProofs: async () => []
}
])
]

export const KeyringContext = createContext<KeyringContextValue>(keyringContextDefaultValue)

export interface KeyringProviderProps extends ServiceConfig {
children?: JSX.Element
Expand Down
147 changes: 147 additions & 0 deletions packages/react-uploader/src/Uploader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import React, { useContext, useMemo, createContext, useState } from 'react'
import { Link, Version } from 'multiformats'
import { CARMetadata, UploaderContextState, UploaderContextActions } from '@w3ui/uploader-core'
import { useUploader } from './providers/Uploader'

export enum Status {
Idle = 'idle',
Uploading = 'uploading',
Failed = 'failed',
Succeeded = 'succeeded'
}

export type UploaderComponentContextState = UploaderContextState & {
/**
* A string indicating the status of this component - can be 'uploading', 'done' or ''.
*/
status: Status
/**
* Error thrown by upload process.
*/
error?: Error
/**
* a File to be uploaded
*/
file?: File
/**
* A callback that can be passed to an `onSubmit` handler to
* upload `file` to web3.storage via the w3up API
*/
handleUploadSubmit?: (e: Event) => Promise<void>
/**
* The CID of a successful upload
*/
dataCID?: Link<unknown, number, number, Version>
/**
* Shards of a DAG uploaded to web3.storage
*/
storedDAGShards: CARMetadata[]
}

export type UploaderComponentContextActions = UploaderContextActions & {
/**
* Set a file to be uploaded to web3.storage. The file will be uploaded
* when `handleUploadSubmit` is called.
*/
setFile: React.Dispatch<React.SetStateAction<File | undefined>>
}

export type UploaderComponentContextValue = [
state: UploaderComponentContextState,
actions: UploaderComponentContextActions
]

const UploaderComponentContext = createContext<UploaderComponentContextValue>([
{
status: Status.Idle,
storedDAGShards: []
},
{
setFile: () => { throw new Error('missing set file function') },
uploadFile: async () => { throw new Error('missing uploader context provider') },
uploadDirectory: async () => { throw new Error('missing uploader context provider') }
}
])

export interface UploaderComponentProps {
children?: JSX.Element
}

/**
* Top level component of the headless Uploader.
*
* Designed to be used with Uploader.Form and Uploader.Input
* to easily create a custom component for uploading files to
* web3.storage.
*/
export const Uploader = ({
children
}: UploaderComponentProps): JSX.Element => {
const [uploaderState, uploaderActions] = useUploader()
const [file, setFile] = useState<File>()
const [dataCID, setDataCID] = useState<Link<unknown, number, number, Version>>()
const [status, setStatus] = useState(Status.Idle)
const [error, setError] = useState()

const handleUploadSubmit = async (e: Event): Promise<void> => {
e.preventDefault()
if (file != null) {
try {
setError(undefined)
setStatus(Status.Uploading)
const cid = await uploaderActions.uploadFile(file)
setDataCID(cid)
setStatus(Status.Succeeded)
} catch (err: any) {
setError(err)
setStatus(Status.Failed)
}
}
}

const uploaderComponentContextValue = useMemo<UploaderComponentContextValue>(() => [
{ ...uploaderState, file, dataCID, status, error, handleUploadSubmit },
{ ...uploaderActions, setFile }
], [uploaderState, file, dataCID, status, error, handleUploadSubmit, uploaderActions, setFile])

return (
<UploaderComponentContext.Provider value={uploaderComponentContextValue}>
{children}
</UploaderComponentContext.Provider>
)
}

/**
* Input component for the headless Uploader.
*
* A file `input` designed to work with `Uploader`. Any passed props will
* be passed along to the `input` component.
*/
Uploader.Input = (props: any): JSX.Element => {
const [, { setFile }] = useContext(UploaderComponentContext)
return (
<input {...props} type='file' onChange={e => setFile(e.target.files?.[0])} />
)
}

/**
* Form component for the headless Uploader.
*
* A `form` designed to work with `Uploader`. Any passed props will
* be passed along to the `form` component.
*/
Uploader.Form = ({ children, ...props }: { children: React.ReactNode } & any): JSX.Element => {
const [{ handleUploadSubmit }] = useContext(UploaderComponentContext)
return (
<form {...props} onSubmit={handleUploadSubmit}>
{children}
</form>
)
}

/**
* Use the scoped uploader context state from a parent `Uploader`.
*/
export function useUploaderComponent (): UploaderComponentContextValue {
return useContext(UploaderComponentContext)
}
1 change: 1 addition & 0 deletions packages/react-uploader/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export type { Service, CARMetadata } from '@w3ui/uploader-core'
export { uploadFile, uploadDirectory } from '@w3ui/uploader-core'
export * from './providers/Uploader'
export * from './Uploader'
6 changes: 4 additions & 2 deletions packages/react-uploader/src/providers/Uploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ export type UploaderContextValue = [
actions: UploaderContextActions
]

const UploaderContext = createContext<UploaderContextValue>([
export const uploaderContextDefaultValue: UploaderContextValue = [
{ storedDAGShards: [] },
{
uploadFile: async () => { throw new Error('missing uploader context provider') },
uploadDirectory: async () => { throw new Error('missing uploader context provider') }
}
])
]

export const UploaderContext = createContext<UploaderContextValue>(uploaderContextDefaultValue)

export interface UploaderProviderProps extends ServiceConfig {
children?: JSX.Element
Expand Down
Loading

0 comments on commit 46583e0

Please sign in to comment.