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

Add Hydrogen Form component for easier mutations #1070

Closed
wants to merge 12 commits into from
Closed

Conversation

blittle
Copy link
Contributor

@blittle blittle commented Apr 12, 2022

Experimental <Form> component for easier mutations. Example usage:

import {Form, RequestServerComponents, useSession} from '@shopify/hydrogen';

const users = [
  {
    name: 'Abraham Lincoln',
    username: '[email protected]',
    password: 'somepass',
  },
];

const INVALID_USER = 'INVALID_USER';
const INVALID_USERNAME = 'INVALID_USERNAME';
const INVALID_PASSWORD = 'INVALID_PASSWORD';

export default function FormServer({error}) {
  const {user} = useSession();

  if (user)
    return (
      <div className="flex justify-center mt-24">
        <div className="w-full max-w-xs">
          <div className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 text-center">
            <div className="font-bold text-green-800 ">Welcome {user}!</div>
            <div className="mt-4">
              <Form action="/login" method="POST">
                <input type="hidden" name="action" value="logout" />
                <button type="submit">Log out</button>
              </Form>
            </div>
          </div>
        </div>
      </div>
    );

  return (
    <Form action="/login" method="POST">
      <input type="hidden" name="action" value="login" />
      <div className="flex justify-center mt-24">
        <div className="w-full max-w-xs">
          <div className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
            <div className="mb-4">
              <label
                className="block text-gray-700 text-sm font-bold mb-2"
                htmlFor="username"
              >
                Username
              </label>
              <input
                className={`shadow appearance-none border ${
                  error === INVALID_USERNAME || error === INVALID_USER
                    ? 'border-red-500'
                    : ''
                } rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline`}
                id="username"
                name="username"
                type="text"
                placeholder="Username"
              />
            </div>
            <div className="mb-6">
              <label
                className="block text-gray-700 text-sm font-bold mb-2"
                htmlFor="password"
              >
                Password
              </label>
              <input
                className={`shadow appearance-none border ${
                  error === INVALID_PASSWORD || error === INVALID_USER
                    ? 'border-red-500'
                    : ''
                } rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline`}
                id="password"
                name="password"
                type="password"
                placeholder="******************"
              />
              <ErrorMessage error={error} />
            </div>
            <div className="flex items-center justify-between">
              <button
                className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
                type="submit"
              >
                Sign In
              </button>
            </div>
          </div>
          <p className="text-center text-gray-500 text-xs">
            &copy;2020 Acme Corp. All rights reserved.
          </p>
        </div>
      </div>
    </Form>
  );
}

function ErrorMessage({error}) {
  if (!error) return;

  return (
    <p className="text-red-500 text-xs italic">
      {error === 'INVALID_USERNAME'
        ? 'Please choose a username'
        : error === 'INVALID_PASSWORD'
        ? 'Please choose a password'
        : 'Invalid username or password'}
    </p>
  );
}

export async function api(request, {session}) {
  const data = await request.formData();
  const action = data.get('action');

  if (action === 'logout') {
    await session.destroy();
    return new RequestServerComponents();
  }

  const username = data.get('username');
  const password = data.get('password');

  // Note, you can throw or return a vanilla `Request` or `Response` object.
  // RequestServerComponents is just syntactic sugar, the user could manually create a
  // Response object instead.
  if (!username)
    throw new RequestServerComponents('/login', {error: INVALID_USERNAME});
  if (!password)
    throw new RequestServerComponents('/login', {error: INVALID_PASSWORD});

  const user = users.find(
    (user) => username === user.username && user.password === password,
  );

  if (!user) {
    throw new RequestServerComponents('/login', {error: INVALID_USER});
  }

  await session.set('user', user.username);

  return new RequestServerComponents();
}

@blittle
Copy link
Contributor Author

blittle commented Apr 12, 2022

I'm pretty sure to handle forms properly, this will need Shopify/hydrogen#1067

I'll test with and without node 16 and undici. If it works, I'll change the upstream branch to be v1.x.

method,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Hydrogen-Client': 'Form-Action',
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Unsure about this header name

Comment on lines +133 to +181
if (!Array.from((apiResponse.headers as any).keys()).length) {
request.headers.forEach((value, key) => {
apiResponse.headers.set(key, value);
});
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

If the dev doesn't explicitly define headers, then use the headers on the original request.

`?state=${encodeURIComponent(
JSON.stringify({
pathname: newUrl.pathname,
search: '',
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Probably should put the search parameters on?

Copy link
Contributor

@frandiox frandiox Apr 13, 2022

Choose a reason for hiding this comment

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

I thin it's better with the search as empty string, yes. Recently we had issues with prefetching because of this (#1059).
Or do you mean passing search parameters from newUrl?

headers,
headers: {
'X-Shopify-Storefront-Access-Token': storefrontToken,
'Accept-Language': (locale as string) ?? defaultLocale,
Copy link
Contributor

Choose a reason for hiding this comment

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

we should no longer use this header. Sorry I should had remove this as part of Shopify/hydrogen#1028

@@ -0,0 +1,57 @@
import React, {useCallback, useRef} from 'react';
// @ts-ignore
import {createFromFetch} from '@shopify/hydrogen/vendor/react-server-dom-vite';
Copy link
Contributor

Choose a reason for hiding this comment

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

nice trick!

Copy link
Contributor

Choose a reason for hiding this comment

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

Actually, perhaps we could re-export this from rsc.ts to centralize where we use the vendor dir?

Comment on lines 7 to 10
<Form action="/form" method="POST">
<b>Count: {count}</b>
<button type="submit">Increment</button>
</Form>
Copy link
Contributor

Choose a reason for hiding this comment

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

can you show an example on how to response to the submitted form?
ie. show different alert message base on if the submission is success or failure.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@michenly updated

Comment on lines 87 to 89
useEffect(() => {
setApiResponse(null);
}, [serverState]);
Copy link
Contributor

Choose a reason for hiding this comment

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

This will clear the API response if we continue navigating by changing serverState, right?
Maybe we could add some comments around this area for those who are not very familiar with RSC.

Comment on lines 97 to 98
setServerState={setServerState}
setApiResponse={setApiResponse}
Copy link
Contributor

Choose a reason for hiding this comment

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

Will we need a more generic setResponse at some point? Or do we think we only want to "overwrite" server state with responses from API? 🤔

Comment on lines 160 to 214
// JavaScript is disabled on the client, handle the response without streaming
return handleRequest(apiResponse, {
...options,
streamableResponse: undefined,
});
Copy link
Contributor

@frandiox frandiox Apr 13, 2022

Choose a reason for hiding this comment

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

Interesting, but wouldn't this also disable streaming for raw HTML forms? I guess it's a minor drawback.

Also, I don't think streamableResponse: undefined works in Node. Currently we are writing to this response in the Node branch. We would need a new parameter like doStream: boolean or anything like that.

Edit: it actually works but only when using our custom hydrogenMiddleware. Our current render function is kind of weird, it always returns a Response even in Node, so it needs to be transformed later to response.write 🙈 -- perhaps we should always do this once we only support Node 16+...

Comment on lines -132 to +167
return apiResponse instanceof Request
? handleRequest(apiResponse, options)
: apiResponse;
if (apiResponse instanceof Request) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we move most of this logic to renderApiRoute itself? If await route.resource returns a Request, we can set headers and change RSC url within renderApiRoute. Or move it to a different function if you prefer but at least make the logic in the handleRequest a bit easier to scan 🤔

@blittle blittle force-pushed the jl-node-16 branch 2 times, most recently from e015f1e to 9832f3a Compare April 18, 2022 15:11
@blittle blittle force-pushed the bl-form branch 4 times, most recently from d02aab9 to a6a4966 Compare April 25, 2022 15:56
package.json Outdated Show resolved Hide resolved
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.

4 participants