epic-stack
epic-stack copied to clipboard
Update status-button.tsx
updated status-button
component to be conditionally enabled
Test Plan
Checklist
- [ ] Tests updated
- [ ] Docs updated
Screenshots
Thanks. Can you give an example of why this is necessary?
I think this is necessary for any route with multiple forms
I can think of some use-cases like a create-update form, or an editable table
const schema = {
delete: z.object({ id: z.string().nonempty() }),
create: z.object({ name: z.string().nonempty() }),
update: z.object({ id: z.string().nonempty(), name: z.string().nonempty() }),
}
const ActionForm = ({
formValue,
defaultValue,
}: {
formValue: 'create' | 'update'
defaultValue?: any
}) => {
const actionData = useActionData<typeof action>()
const isCurrent = actionData?.form === formValue
const isSubmitting = useSubmitting({
enabled: isCurrent,
})
const [form, fields] = useForm({
constraint: getFieldsetConstraint(schema[formValue]),
lastSubmission: isCurrent ? actionData.submission : undefined,
defaultValue,
})
return (
<Form method="POST" {...form.props}>
<input hidden readOnly name="form" value={formValue} />
<input {...conform.input(fields.id, { hidden: true })} />
<Field
inputProps={{
...conform.input(fields.name),
placeholder: 'Display name',
}}
errors={fields.name.errors}
/>
<StatusButton
status={isSubmitting ? 'pending' : actionData?.status ?? 'idle'}
enabled={isCurrent}
>
Continue
</StatusButton>
</Form>
)
}
const Delete = ({ id }: { id: string }) => {
const actionData = useActionData<typeof action>()
const formValue = 'delete'
const isCurrent = actionData?.form === formValue
const isSubmitting = useSubmitting({
enabled: isCurrent,
})
const [form, fields] = useForm({
constraint: getFieldsetConstraint(schema[formValue]),
lastSubmission: isCurrent ? actionData.submission : undefined,
defaultValue: { id },
})
return (
<Form method="POST" {...form.props}>
<input hidden readOnly name="form" value={formValue} />
<input {...conform.input(fields.id, { hidden: true })} />
<StatusButton
status={isSubmitting ? 'pending' : actionData?.status ?? 'idle'}
enabled={isCurrent}
>
Delete
</StatusButton>
</Form>
)
}
Thanks for explaining that. The problem with this approach is actionData?.form
won't be defined until the action data is returned from the server. You would probably want to use useNavigation().formData?.get('form')
instead.
In this case, I think it would be better to accept a form
prop to the status button and the useIsPending hook and those can handle the pending state.
@kentcdodds there is an issue with using useNavigation
, the success
and error
states flickers for a second then go back to idle
, the problem is navigation.formData
goes back to null after the navigation completed
I've chose enabled
because it gives the user control over what hidden input name they want to set, for me I always go with actionId
like you did in your personal website
I tweaked this component in my project
type UseStatusBaseArgs = {
state?: 'submitting' | 'loading' | 'non-idle'
formAction?: string
formMethod?: 'POST' | 'GET' | 'PUT' | 'PATCH' | 'DELETE'
}
type UseStatusWithCurrent = UseStatusBaseArgs & {
currentValue?: string
currentKey?: string
}
type UseStatusWithoutCurrent = UseStatusBaseArgs & {
currentValue?: never
currentKey?: never
}
export function useStatus<T>(args: UseStatusWithCurrent): {
navigating: boolean
pending: boolean
current: boolean
actionData: SerializeFrom<T>
}
export function useStatus(args: UseStatusWithoutCurrent): boolean
export function useStatus<T extends Record<string, unknown>>(
args: Prettify<UseStatusWithCurrent> | Prettify<UseStatusWithoutCurrent>,
) {
const {
state = 'non-idle',
formAction,
formMethod = 'POST',
currentValue,
currentKey = 'actionId',
} = args ?? {}
// navigating
const navigation = useNavigation()
const navigating =
state === 'non-idle'
? navigation.state !== 'idle'
: navigation.state === state
// pending
const ctx = useFormAction()
const pending =
navigation.formAction === (formAction ?? ctx) &&
navigation.formMethod === formMethod &&
navigating
// current
const actionDataBase = useActionData<T>()
const current =
currentKey && currentValue
? actionDataBase?.[currentKey] === currentValue
: true
const actionData = currentValue
? current
? actionDataBase
: undefined
: actionDataBase
return currentValue
? ({ actionData, navigating, pending, current } as const)
: pending
}
export function useActionStatus<
T extends {
status: 'pending' | 'success' | 'error' | 'idle'
},
>() {
const actionData = useActionData<T>()
return actionData?.status ?? 'idle'
}
export const StatusButton = forwardRef<
HTMLButtonElement,
ButtonProps & {
message?: string | null
spinDelay?: Parameters<typeof useSpinDelay>[1]
currentValue?: string
currentKey?: string
}
>(
(
{
message,
className,
currentKey,
currentValue,
children,
spinDelay,
...props
},
ref,
) => {
const { pending, current } = useStatus({
currentValue: currentValue,
currentKey: currentKey || undefined,
})
const status = useActionStatus()
const delayedPending = useSpinDelay(
currentValue ? pending && current : pending,
{
delay: 400,
minDuration: 300,
...spinDelay,
},
)
const companion = {
pending: delayedPending ? (
<div className="inline-flex h-6 w-6 items-center justify-center">
<Icon name="update" className="animate-spin" />
</div>
) : null,
success: (
<div className="inline-flex h-6 w-6 items-center justify-center">
<Icon name="check" />
</div>
),
error: (
<div className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-destructive">
<Icon name="cross-1" className="text-destructive-foreground" />
</div>
),
idle: null,
}[currentValue ? (current ? status : 'idle') : status]
const type = currentValue
? current && status.match(/success|error/g)
? 'reset'
: 'submit'
: status.match(/success|error/g)
? 'reset'
: 'submit'
return (
<Button
ref={ref}
className={cn('flex justify-center gap-4', className)}
type={type}
disabled={currentValue ? delayedPending && current : delayedPending}
{...props}
>
<div>{children}</div>
{message ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>{companion}</TooltipTrigger>
<TooltipContent>{message}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
companion
)}
</Button>
)
},
)
StatusButton.displayName = 'StatusButton'
and then I use it like this
const { actionData } = useStatus<typeof action>({
currentKey: 'actionId',
currentValue: 'create',
})
const [form, fields] = useForm({
constraint: getFieldsetConstraint(schema.update),
lastSubmission: actionData?.submission,
})
const Continue = () => (
<StatusButton
currentKey="actionId"
currentValue="create"
>
Continue
</StatusButton>
)
Hmmm, I wonder if we could contribute this to remix-utils by @sergiodxa. That seems like a better place for this sort of utility.
Would you mind if I make a package out of your code?
Of course you could do that yourself if you prefer
Sorry I was out for a while
Of course that's an honor, you can do whatever you want
I'm not certain I'll have time to do it, but possibly in the future I will.
There's an update if you're interested
interface UsePendingArgs extends UseSimplePendingArgs {
currentKey?: string
currentValue?: string
}
export function usePending<T>(args?: UsePendingArgs): {
actionData: SerializeFrom<T> | undefined
navigating: boolean
pending: boolean
current: boolean
} {
const {
state = 'non-idle',
formAction,
formMethod = 'POST',
currentKey,
currentValue,
} = args || {}
const navigation = useNavigation()
const navigating =
state === 'non-idle'
? navigation.state !== 'idle'
: navigation.state === state
// pending
const ctx = useFormAction()
const pending =
navigation.formAction === (formAction ?? ctx) &&
navigation.formMethod === formMethod &&
navigating
// current
const actionDataBase = useActionData<T>()
const current =
currentKey && currentValue
? // @ts-ignore
actionDataBase?.[currentKey] === currentValue ||
navigation.formData?.get(currentKey) === currentValue
: true
const actionData =
currentKey && currentValue
? current
? actionDataBase
: undefined
: actionDataBase
return { actionData, navigating, pending, current } as const
}
export const StatusButton = forwardRef<
HTMLButtonElement,
ButtonProps & {
message?: string | null
spinDelay?: Parameters<typeof useSpinDelay>[1]
currentValue?: string
currentKey?: string
}
>(
(
{
message,
className,
currentKey,
currentValue,
children,
spinDelay,
...props
},
ref,
) => {
const { pending, current, actionData } = usePending<{
status: 'pending' | 'success' | 'error' | 'idle'
}>({
currentValue: currentValue,
currentKey: currentKey,
})
const status = currentValue
? pending && current
? 'pending'
: actionData?.status ?? 'idle'
: pending
? 'pending'
: actionData?.status ?? 'idle'
const delayedPending = useSpinDelay(
currentValue ? pending && current : pending,
{
delay: 400,
minDuration: 300,
...spinDelay,
},
)
const companion = {
pending: delayedPending ? (
<div className="inline-flex h-6 w-6 items-center justify-center">
<Icon name="update" className="animate-spin" />
</div>
) : null,
success: (
<div className="inline-flex h-6 w-6 items-center justify-center">
<Icon name="check" />
</div>
),
error: (
<div className="inline-flex h-6 w-6 items-center justify-center">
<Icon name="cross-1" className="text-destructive" />
</div>
),
idle: null,
}[currentValue ? (current ? status : 'idle') : status]
const type = 'submit'
const child = (
<>
{companion ? (
message ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>{companion}</TooltipTrigger>
<TooltipContent>{message}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
companion
)
) : (
children
)}
</>
)
return (
<Button
ref={ref}
className={cn('flex justify-center gap-4', className)}
type={type}
disabled={currentValue ? delayedPending && current : delayedPending}
{...props}
>
{child}
</Button>
)
},
)
StatusButton.displayName = 'StatusButton'
remix-utils
didn't update to v2
yet, I'm still waiting