Skip to content
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

Merged
merged 47 commits into from
Jan 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
c824e44
feat!: integrate new ucanto and upload-client (#119)
Dec 2, 2022
012a565
fix: update vanilla sign-up example (#129)
yusefnapora Dec 2, 2022
0110ae8
fix: update vue examples (#130)
travis Dec 2, 2022
9ccbe67
fix: update solid examples (#131)
travis Dec 2, 2022
8699b40
docs: update docs for new ucanto integration (#135)
yusefnapora Dec 2, 2022
034ada0
chore: update access-client version
Dec 2, 2022
18e976a
feat: introduce react-ui package
travis Dec 1, 2022
e7737c4
fix: render Uploader children rather than markup
travis Dec 1, 2022
89050e8
chore: move to typescript
travis Dec 2, 2022
c4d74e6
wip: initial version of react ui example app
travis Dec 2, 2022
7ec7c33
chore: move Uploader into react-uploader package (#137)
travis Dec 5, 2022
ec98de1
chore: add docstrings to uploader state and component
travis Dec 5, 2022
0454d94
fix: use preferred CID link
travis Dec 5, 2022
bb0e2b2
fix: correct typo
travis Dec 6, 2022
6dec856
feat: UploadsList components (#141)
travis Dec 7, 2022
ba91721
feat: Add Authenticator and SimpleAuthenticator (#152)
travis Dec 9, 2022
b9802ec
Merge branch 'main' into feat/w3ui-ui
travis Dec 15, 2022
ff0b3d4
fix: unclutter PR
travis Dec 15, 2022
27c4de7
fix: one more extraneous change
travis Dec 15, 2022
4896a4a
fix: delete unecessary dev dependencies
travis Dec 15, 2022
0932d8c
fix: clear out package-log.json and reinstall to fix build issues
travis Dec 15, 2022
560676b
chore: appease linter
travis Dec 15, 2022
fdcce6e
fix: a few more linting and compilation errors
travis Dec 15, 2022
642b68c
fix: update react uploads list example
travis Dec 15, 2022
7072602
fix: update vue examples
travis Dec 19, 2022
110907f
feat: W3Upload "drop in" component (#155)
travis Dec 19, 2022
a0220fd
Merge branch 'fix/react-uploads-list' into feat/w3ui-ui
travis Dec 19, 2022
9c5374a
Merge branch 'fix/update-vue-examples' into feat/w3ui-ui
travis Dec 19, 2022
e6a3002
fix: typo
travis Dec 19, 2022
45a605e
fix: Fix docs link from react-uploads-list to keyring page (#196)
natevw Jan 4, 2023
0e5cf4c
Merge branch 'main' into feat/w3ui-ui
travis Jan 4, 2023
7829770
fix: remove react-ui package and react/ui example
travis Jan 4, 2023
93b7ee0
fix: remove react-ui typescript and rollup configs
travis Jan 4, 2023
9b278e2
chore: add basic docs to the react keyring and uploads list
travis Jan 4, 2023
2eaff3a
chore: a bit more documentation
travis Jan 4, 2023
09c362b
fix: remove circular dependency
travis Jan 5, 2023
4b85682
Merge branch 'main' into feat/w3ui-ui
travis Jan 5, 2023
9fc7505
Merge branch 'main' into feat/w3ui-ui
travis Jan 6, 2023
ba8b2f9
fix: don't let callers override important properties
travis Jan 9, 2023
cd140a7
fix: Cid -> CID and tweak storedDAGShards typing
travis Jan 9, 2023
9a02e03
fix: tweaks from review
travis Jan 9, 2023
cf6a576
Merge branch 'main' into feat/w3ui-ui
travis Jan 9, 2023
d7fae7c
fix: one more arrow function conversion
travis Jan 9, 2023
8a7e0ad
feat: use an enum for uploader status
travis Jan 9, 2023
713abcc
fix: don't smother error in authenticator
travis Jan 11, 2023
f1a46b4
fix: unfurl props first in components
travis Jan 11, 2023
8645fbb
Merge branch 'main' into feat/w3ui-ui
travis Jan 12, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>) => {
Copy link
Member

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 and useMemo 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...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In both cases they add scope for subtle bugs since you have to manually specify dependencies.

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 the handleRegisterSubmit 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 calls useAuthenticator (possibly even if they don't use handleRegisterSubmit, 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.

Copy link
Contributor

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.

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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we type the props? Like with T extends FormProps (not sure if that type exists). Either way a generic type T = any might be useful to consumers.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes! I've actually overhauled these types a bit in the as property work I'm doing in #236 so I'd like to punt this particular task there, but I'd also like to get that work merged before we do a release of these components, so worth taking a look over there and seeing if the typing is a bit more to your liking

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 ''.
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)
}
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