Finalize the Effect View rename and refresh docs, examples, and tooling (#59)
## Summary - Rename `effect-fc-next` to `effect-view` and update package metadata, internal symbols, imports, and repository references. - Promote the Effect 4 example to `packages/example` and move the legacy Effect 3 example to `packages/effect-fc-example`. - Add `effect-view` to the npm publishing workflow. - Update the Vite Fast Refresh plugin for `effect-view` and rename `effectViewPlugin()` to `effectView()`. - Upgrade Effect, React, TypeScript, build tooling, and related dependencies. - Refresh the documentation and homepage. ## Documentation - Add a dedicated Async guide covering: - Suspense fallbacks - Hook ordering - Memoization - Structural prop equality - Clarify synchronous and asynchronous component behavior. - Document runner scope and service requirements. - Explain stable construction of Query, Mutation, and Form instances. - Expand focused Lens and Form examples, including curried focus helpers. - Document the optional `@effect/platform-browser` dependency for window-focus query refresh. - Improve the Forms guide structure and examples. - Fix responsive homepage headline wrapping and update homepage copy. ## Notable API changes - Package rename: ```diff - effect-fc-next + effect-view --------- Co-authored-by: Julien Valverdé <julien.valverde@mailo.com> Reviewed-on: #59
This commit was merged in pull request #59.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { Container, Flex, Theme } from "@radix-ui/themes"
|
||||
import { createRootRoute, Link, Outlet } from "@tanstack/react-router"
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
||||
|
||||
import "@radix-ui/themes/styles.css"
|
||||
import "../index.css"
|
||||
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: Root
|
||||
})
|
||||
|
||||
function Root() {
|
||||
return (
|
||||
<Theme>
|
||||
<Container mb="4">
|
||||
<Flex direction="row" justify="center" align="center" gap="2">
|
||||
<Link to="/">Index</Link>
|
||||
<Link to="/blank">Blank</Link>
|
||||
</Flex>
|
||||
</Container>
|
||||
|
||||
<Outlet />
|
||||
|
||||
<TanStackRouterDevtools />
|
||||
</Theme>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { HttpClient } from "@effect/platform"
|
||||
import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Array, Effect, flow, Option, Schema } from "effect"
|
||||
import { Async, Component, Memoized } from "effect-fc"
|
||||
import * as React from "react"
|
||||
import { runtime } from "@/runtime"
|
||||
|
||||
|
||||
const Post = Schema.Struct({
|
||||
userId: Schema.Int,
|
||||
id: Schema.Int,
|
||||
title: Schema.String,
|
||||
body: Schema.String,
|
||||
})
|
||||
|
||||
interface AsyncFetchPostViewProps {
|
||||
readonly id: number
|
||||
}
|
||||
|
||||
class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(props: AsyncFetchPostViewProps) {
|
||||
const post = yield* Component.useOnChange(() => HttpClient.HttpClient.pipe(
|
||||
Effect.tap(Effect.sleep("500 millis")),
|
||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ props.id }`)),
|
||||
Effect.andThen(response => response.json),
|
||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
||||
), [props.id])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Heading>{post.title}</Heading>
|
||||
<Text>{post.body}</Text>
|
||||
</div>
|
||||
)
|
||||
}).pipe(
|
||||
Async.async,
|
||||
Async.withOptions({ defaultFallback: <Text>Default fallback</Text> }),
|
||||
Memoized.memoized,
|
||||
) {}
|
||||
|
||||
|
||||
const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
|
||||
const [text, setText] = React.useState("Typing here should not trigger a refetch of the post")
|
||||
const [id, setId] = React.useState(1)
|
||||
|
||||
const AsyncFetchPost = yield* AsyncFetchPostView.use
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Flex direction="column" align="stretch" gap="2">
|
||||
<TextField.Root
|
||||
value={text}
|
||||
onChange={e => setText(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Slider
|
||||
value={[id]}
|
||||
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
|
||||
/>
|
||||
|
||||
<AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} />
|
||||
</Flex>
|
||||
</Container>
|
||||
)
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
export const Route = createFileRoute("/async")({
|
||||
component: AsyncRouteComponent,
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
|
||||
export const Route = createFileRoute("/blank")({
|
||||
component: RouteComponent
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/blank"!</div>
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Container, Flex, Text, TextField } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Console, Effect } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
import * as React from "react"
|
||||
import { runtime } from "@/runtime"
|
||||
|
||||
|
||||
class SubService extends Effect.Service<SubService>()("SubService", {
|
||||
effect: (value: string) => Effect.succeed({ value })
|
||||
}) {}
|
||||
|
||||
const SubComponent = Component.makeUntraced("SubComponent")(function*() {
|
||||
const service = yield* SubService
|
||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
yield* Effect.addFinalizer(() => Console.log("SubComponent unmounted"))
|
||||
yield* Console.log("SubComponent mounted")
|
||||
}))
|
||||
|
||||
return <Text>{service.value}</Text>
|
||||
})
|
||||
|
||||
const ContextView = Component.makeUntraced("ContextView")(function*() {
|
||||
const [serviceValue, setServiceValue] = React.useState("test")
|
||||
const SubServiceLayer = React.useMemo(() => SubService.Default(serviceValue), [serviceValue])
|
||||
const SubComponentFC = yield* Effect.provide(SubComponent.use, yield* Component.useLayer(SubServiceLayer))
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Flex direction="column" align="center">
|
||||
<TextField.Root value={serviceValue} onChange={e => setServiceValue(e.target.value)} />
|
||||
<SubComponentFC />
|
||||
</Flex>
|
||||
</Container>
|
||||
)
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
export const Route = createFileRoute("/dev/context")({
|
||||
component: ContextView
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Flex, Text, TextField } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
||||
import { Effect } from "effect"
|
||||
import { Component, Memoized } from "effect-fc"
|
||||
import * as React from "react"
|
||||
import { runtime } from "@/runtime"
|
||||
|
||||
|
||||
const RouteComponent = Component.makeUntraced("RouteComponent")(function*() {
|
||||
const [value, setValue] = React.useState("")
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2">
|
||||
<TextField.Root
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
/>
|
||||
|
||||
{yield* Effect.map(SubComponent.use, FC => <FC />)}
|
||||
{yield* Effect.map(MemoizedSubComponent.use, FC => <FC />)}
|
||||
</Flex>
|
||||
)
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
class SubComponent extends Component.makeUntraced("SubComponent")(function*() {
|
||||
const id = yield* makeUuid4.pipe(Effect.provide(GetRandomValues.CryptoRandom))
|
||||
return <Text>{id}</Text>
|
||||
}) {}
|
||||
|
||||
class MemoizedSubComponent extends Memoized.memoized(SubComponent) {}
|
||||
|
||||
export const Route = createFileRoute("/dev/memo")({
|
||||
component: RouteComponent,
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Button, Container, Flex, Text } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect"
|
||||
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
|
||||
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
|
||||
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
|
||||
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
||||
import { runtime } from "@/runtime"
|
||||
|
||||
|
||||
const email = Schema.pattern<typeof Schema.String>(
|
||||
/^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i,
|
||||
|
||||
{
|
||||
identifier: "email",
|
||||
title: "email",
|
||||
message: () => "Not an email address",
|
||||
},
|
||||
)
|
||||
|
||||
const RegisterFormSchema = Schema.Struct({
|
||||
email: Schema.String.pipe(email),
|
||||
password: Schema.String.pipe(Schema.minLength(3)),
|
||||
birth: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
|
||||
})
|
||||
|
||||
const RegisterFormSubmitSchema = Schema.Struct({
|
||||
email: Schema.transformOrFail(
|
||||
Schema.String,
|
||||
Schema.String,
|
||||
{
|
||||
decode: (input, _options, ast) => input !== "admin@admin.com"
|
||||
? ParseResult.succeed(input)
|
||||
: ParseResult.fail(new ParseResult.Type(ast, input, "This email is already in use.")),
|
||||
encode: ParseResult.succeed,
|
||||
},
|
||||
),
|
||||
password: Schema.String,
|
||||
birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
|
||||
})
|
||||
|
||||
class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", {
|
||||
scoped: Effect.gen(function*() {
|
||||
const form = yield* SubmittableForm.service({
|
||||
schema: RegisterFormSchema.pipe(
|
||||
Schema.compose(
|
||||
Schema.transformOrFail(
|
||||
Schema.typeSchema(RegisterFormSchema),
|
||||
Schema.typeSchema(RegisterFormSchema),
|
||||
{
|
||||
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
|
||||
encode: ParseResult.succeed,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
initialEncodedValue: { email: "", password: "", birth: Option.none() },
|
||||
f: Effect.fnUntraced(function*([value]) {
|
||||
yield* Effect.sleep("500 millis")
|
||||
return yield* Schema.decode(RegisterFormSubmitSchema)(value)
|
||||
}),
|
||||
})
|
||||
|
||||
return {
|
||||
form,
|
||||
emailField: Form.focusObjectOn(form, "email"),
|
||||
passwordField: Form.focusObjectOn(form, "password"),
|
||||
birthField: Form.focusObjectOn(form, "birth"),
|
||||
} as const
|
||||
})
|
||||
}) {}
|
||||
|
||||
class RegisterFormView extends Component.make("RegisterFormView")(function*() {
|
||||
const form = yield* RegisterFormService
|
||||
const [canCommit, submitResult] = yield* Subscribable.useAll([
|
||||
form.form.canCommit,
|
||||
form.form.mutation.result,
|
||||
])
|
||||
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
const TextFieldFormInput = yield* TextFieldFormInputView.use
|
||||
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
|
||||
|
||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
|
||||
yield* Console.log("RegisterFormView mounted")
|
||||
}))
|
||||
|
||||
|
||||
return (
|
||||
<Container width="300">
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault()
|
||||
void runPromise(form.form.submit)
|
||||
}}>
|
||||
<Flex direction="column" gap="2">
|
||||
<TextFieldFormInput
|
||||
form={form.emailField}
|
||||
debounce="250 millis"
|
||||
/>
|
||||
|
||||
<TextFieldFormInput
|
||||
form={form.passwordField}
|
||||
debounce="250 millis"
|
||||
/>
|
||||
|
||||
<TextFieldOptionalFormInput
|
||||
type="datetime-local"
|
||||
form={form.birthField}
|
||||
defaultValue=""
|
||||
/>
|
||||
|
||||
<Button disabled={!canCommit}>Submit</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
|
||||
{Match.value(submitResult).pipe(
|
||||
Match.tag("Initial", () => <></>),
|
||||
Match.tag("Running", () => <Text>Submitting...</Text>),
|
||||
Match.tag("Success", () => <Text>Submitted successfully!</Text>),
|
||||
Match.tag("Failure", e => <Text>Error: {e.cause.toString()}</Text>),
|
||||
Match.exhaustive,
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}) {}
|
||||
|
||||
const RegisterPage = Component.make("RegisterPageView")(function*() {
|
||||
const RegisterForm = yield* Effect.provide(
|
||||
RegisterFormView.use,
|
||||
yield* Component.useLayer(RegisterFormService.Default),
|
||||
)
|
||||
|
||||
return <RegisterForm />
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
|
||||
export const Route = createFileRoute("/form")({
|
||||
component: RegisterPage
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
import { runtime } from "@/runtime"
|
||||
import { TodosState } from "@/todo/TodosState"
|
||||
import { TodosView } from "@/todo/TodosView"
|
||||
|
||||
|
||||
const TodosStateLive = TodosState.Default("todos")
|
||||
|
||||
const Index = Component.make("IndexView")(function*() {
|
||||
const Todos = yield* Effect.provide(
|
||||
TodosView.use,
|
||||
yield* Component.useLayer(TodosStateLive),
|
||||
)
|
||||
|
||||
return <Todos />
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: Index
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { HttpClient, type HttpClientError } from "@effect/platform"
|
||||
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream, SubscriptionRef } from "effect"
|
||||
import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc"
|
||||
import { runtime } from "@/runtime"
|
||||
|
||||
|
||||
const Post = Schema.Struct({
|
||||
userId: Schema.Int,
|
||||
id: Schema.Int,
|
||||
title: Schema.String,
|
||||
body: Schema.String,
|
||||
})
|
||||
|
||||
const ResultView = Component.make("ResultView")(function*() {
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1))
|
||||
const key = Stream.map(idLens.changes, id => [id] as const)
|
||||
|
||||
const query = yield* Query.service({
|
||||
key,
|
||||
f: ([id]) => HttpClient.HttpClient.pipe(
|
||||
Effect.tap(Effect.sleep("500 millis")),
|
||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
||||
Effect.andThen(response => response.json),
|
||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
||||
),
|
||||
staleTime: "10 seconds",
|
||||
})
|
||||
|
||||
const mutation = yield* Mutation.make({
|
||||
f: ([id]: readonly [id: number]) => HttpClient.HttpClient.pipe(
|
||||
Effect.tap(Effect.sleep("500 millis")),
|
||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
||||
Effect.andThen(response => response.json),
|
||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
||||
),
|
||||
})
|
||||
|
||||
return [idLens, query, mutation] as const
|
||||
}))
|
||||
|
||||
const [id, setId] = yield* Lens.useState(idLens)
|
||||
const [queryResult, mutationResult] = yield* Subscribable.useAll([query.result, mutation.result])
|
||||
|
||||
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
||||
Effect.andThen(observer => observer.subscribe),
|
||||
Effect.andThen(Stream.fromQueue),
|
||||
Stream.unwrapScoped,
|
||||
Stream.runForEach(flow(
|
||||
Cause.failures,
|
||||
Chunk.findFirst(e => e._tag === "RequestError" || e._tag === "ResponseError"),
|
||||
Option.match({
|
||||
onSome: e => Console.log("ResultView HttpClient error", e),
|
||||
onNone: () => Effect.void,
|
||||
}),
|
||||
)),
|
||||
Effect.forkScoped,
|
||||
))
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Flex direction="column" align="center" gap="2">
|
||||
<Slider
|
||||
value={[id]}
|
||||
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{Match.value(queryResult).pipe(
|
||||
Match.tag("Running", () => <Text>Loading...</Text>),
|
||||
Match.tag("Success", result => <>
|
||||
<Heading>{result.value.title}</Heading>
|
||||
<Text>{result.value.body}</Text>
|
||||
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
|
||||
</>),
|
||||
Match.tag("Failure", result =>
|
||||
<Text>An error has occured: {result.cause.toString()}</Text>
|
||||
),
|
||||
Match.orElse(() => <></>),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Flex direction="row" justify="center" align="center" gap="1">
|
||||
<Button onClick={() => runPromise(query.refresh)}>Refresh</Button>
|
||||
<Button onClick={() => runPromise(query.invalidateCache)}>Invalidate cache</Button>
|
||||
</Flex>
|
||||
|
||||
<div>
|
||||
{Match.value(mutationResult).pipe(
|
||||
Match.tag("Running", () => <Text>Loading...</Text>),
|
||||
Match.tag("Success", result => <>
|
||||
<Heading>{result.value.title}</Heading>
|
||||
<Text>{result.value.body}</Text>
|
||||
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
|
||||
</>),
|
||||
Match.tag("Failure", result =>
|
||||
<Text>An error has occured: {result.cause.toString()}</Text>
|
||||
),
|
||||
Match.orElse(() => <></>),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Flex direction="row" justify="center" align="center" gap="1">
|
||||
<Button onClick={() => runPromise(Effect.andThen(Lens.get(idLens), id => mutation.mutate([id])))}>Mutate</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Container>
|
||||
)
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/query")({
|
||||
component: Component.withRuntime(ResultView, runtime.context)
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { HttpClient, type HttpClientError } from "@effect/platform"
|
||||
import { Container, Heading, Text } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream } from "effect"
|
||||
import { Component, ErrorObserver, Result, Subscribable } from "effect-fc"
|
||||
import { runtime } from "@/runtime"
|
||||
|
||||
|
||||
const Post = Schema.Struct({
|
||||
userId: Schema.Int,
|
||||
id: Schema.Int,
|
||||
title: Schema.String,
|
||||
body: Schema.String,
|
||||
})
|
||||
|
||||
const ResultView = Component.makeUntraced("Result")(function*() {
|
||||
const [resultSubscribable] = yield* Component.useOnMount(() => HttpClient.HttpClient.pipe(
|
||||
Effect.andThen(client => client.get("https://jsonplaceholder.typicode.com/posts/1")),
|
||||
Effect.andThen(response => response.json),
|
||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
||||
Effect.tap(Effect.sleep("250 millis")),
|
||||
Result.forkEffect,
|
||||
))
|
||||
const [result] = yield* Subscribable.useAll([resultSubscribable])
|
||||
|
||||
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
||||
Effect.andThen(observer => observer.subscribe),
|
||||
Effect.andThen(Stream.fromQueue),
|
||||
Stream.unwrapScoped,
|
||||
Stream.runForEach(flow(
|
||||
Cause.failures,
|
||||
Chunk.findFirst(e => e._tag === "RequestError" || e._tag === "ResponseError"),
|
||||
Option.match({
|
||||
onSome: e => Console.log("ResultView HttpClient error", e),
|
||||
onNone: () => Effect.void,
|
||||
}),
|
||||
)),
|
||||
Effect.forkScoped,
|
||||
))
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{Match.value(result).pipe(
|
||||
Match.tag("Running", () => <Text>Loading...</Text>),
|
||||
Match.tag("Success", result => <>
|
||||
<Heading>{result.value.title}</Heading>
|
||||
<Text>{result.value.body}</Text>
|
||||
</>),
|
||||
Match.tag("Failure", result =>
|
||||
<Text>An error has occured: {result.cause.toString()}</Text>
|
||||
),
|
||||
Match.orElse(() => <></>),
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/result")({
|
||||
component: Component.withRuntime(ResultView, runtime.context)
|
||||
})
|
||||
Reference in New Issue
Block a user