-
Notifications
You must be signed in to change notification settings - Fork 25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: "Headless" UI components #136
Changes from all commits
c824e44
012a565
0110ae8
9ccbe67
8699b40
034ada0
18e976a
e7737c4
89050e8
c4d74e6
7ec7c33
ec98de1
0454d94
bb0e2b2
6dec856
ba91721
b9802ec
ff0b3d4
27c4de7
4896a4a
0932d8c
560676b
fdcce6e
642b68c
7072602
110907f
a0220fd
9c5374a
e6a3002
45a605e
0e5cf4c
7829770
93b7ee0
9b278e2
2eaff3a
09c362b
4b85682
9fc7505
ba8b2f9
cd140a7
9a02e03
cf6a576
d7fae7c
8a7e0ad
713abcc
f1a46b4
8645fbb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we type the props? Like with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes! I've actually overhauled these types a bit in the |
||
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) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
export * from './providers/Keyring' | ||
export * from './Authenticator' |
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 ''. | ||
travis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
*/ | ||
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) | ||
} |
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' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm interested in other opinions on using
useCallback
anduseMemo
because I very rarely use these but I've seen them being used in other projects by other developers in every instance where applicable.I'd always considered them tools for when there is a noticeable performance issue that needed to be resolved with fewer renders/garbage collections, since they add additional code to components making them a bit more complicated and subsequently a bit less readable. In both cases they add scope for subtle bugs since you have to manually specify dependencies.
I think we should favor simplicity over optimisation for a issue we don't know exists...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep totally agree with this AND in this case because we're developing components designed to be used by outside developers I think it's more important to be careful with this stuff - this is a good example - if we don't use
useCallback
thehandleRegisterSubmit
function will be recreated on every render, triggering unnecessary re-renders in downstream components - in this case, because we pass this callback to the Context, it will potentially trigger unnecessary re-renders in any component that callsuseAuthenticator
(possibly even if they don't usehandleRegisterSubmit
, though I'm not 100% sure about that)tldr: I agree with you philosophically but think we should use
useCallback
consistently for all callbacks in these libraries.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't have a strong opinion about this. I'm happy to go with @travis' instinct here. If react users are going to complain at us for not doing it cos they all do, and it's not too onerous to apply it consistently then let's go!
Alternatively, we could drop these optimisations, and wait for users (or better yet, our perf tests) to flag where we really need this. Optimising existing components can be a great "first contribution" task where folks want to get involved.