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

Update status-button.tsx #394

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open

Conversation

SomiDivian
Copy link

updated status-button component to be conditionally enabled

Test Plan

Checklist

  • Tests updated
  • Docs updated

Screenshots

updated `status-button` component to be conditionally enabled
@kentcdodds
Copy link
Member

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

@SomiDivian
Copy link
Author

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 SomiDivian mentioned this pull request Aug 19, 2023
2 tasks
@kentcdodds
Copy link
Member

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.

@SomiDivian
Copy link
Author

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

@kentcdodds
Copy link
Member

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

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

@kentcdodds
Copy link
Member

Of course you could do that yourself if you prefer

@SomiDivian
Copy link
Author

Sorry I was out for a while

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

@kentcdodds
Copy link
Member

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

@SomiDivian
Copy link
Author

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
Copy link
Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants