epic-stack icon indicating copy to clipboard operation
epic-stack copied to clipboard

Update status-button.tsx

Open SomiDivian opened this issue 1 year ago • 11 comments

updated status-button component to be conditionally enabled

Test Plan

Checklist

  • [ ] Tests updated
  • [ ] Docs updated

Screenshots

SomiDivian avatar Aug 18 '23 20:08 SomiDivian

Thanks. Can you give an example of why this is necessary?

kentcdodds avatar Aug 18 '23 22:08 kentcdodds

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

SomiDivian avatar Aug 19 '23 14:08 SomiDivian

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 avatar Aug 21 '23 15:08 kentcdodds

@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>
	)

SomiDivian avatar Aug 22 '23 10:08 SomiDivian

Hmmm, I wonder if we could contribute this to remix-utils by @sergiodxa. That seems like a better place for this sort of utility.

kentcdodds avatar Aug 22 '23 22:08 kentcdodds

Would you mind if I make a package out of your code?

kentcdodds avatar Sep 29 '23 03:09 kentcdodds

Of course you could do that yourself if you prefer

kentcdodds avatar Sep 29 '23 03:09 kentcdodds

Sorry I was out for a while

Of course that's an honor, you can do whatever you want

SomiDivian avatar Oct 02 '23 18:10 SomiDivian

I'm not certain I'll have time to do it, but possibly in the future I will.

kentcdodds avatar Oct 02 '23 18:10 kentcdodds

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'

SomiDivian avatar Oct 02 '23 18:10 SomiDivian

remix-utils didn't update to v2 yet, I'm still waiting

SomiDivian avatar Oct 02 '23 18:10 SomiDivian