เฮ้เพื่อนๆ หากคุณยังไม่ได้อ่านบทความก่อนหน้าของฉันเกี่ยวกับ "การเขียนโค้ดแอป Todo อย่างง่ายด้วย React Server" อย่าลืมลองดูในขณะที่เราสร้างโค้ดส่วนใหญ่รอบๆ ตัวมัน

หากคุณยังใหม่กับ React Server โปรดดูหน้าแรกที่ https://state-less.cloud/

เริ่มต้นด้วยความคิด แอปรายการที่เรียบง่ายซึ่งช่วยให้คุณสร้างรายการได้มากเท่าที่คุณต้องการและเพิ่มรายการที่ตรวจสอบได้

โดยทั่วไปเราจำเป็นต้องขยายแอปรายการสิ่งที่ต้องทำเพื่อให้สามารถสร้างรายการได้ทันที แทนที่จะมีรายการสิ่งที่ต้องทำแบบคงที่เพียงรายการเดียว

สิ่งนี้มีประโยชน์หากคุณต้องการติดตามหลายรายการในรายการที่แตกต่างกัน เช่นเป้าหมาย รายการซื้อของ อะไรก็ตามที่คุณสามารถใส่ลงในรายการได้

ฉันมักจะต้องการรายการ แต่ไม่มีเครื่องมือที่ฉันใช้เป็นประจำทุกวันเพื่อติดตามลิงก์ของฉัน โชคดีที่การทำเช่นนั้นด้วย React Server นั้นง่ายมาก ดังนั้นฉันจึงไม่เห็นเหตุผลที่จะไม่เขียนแอปรายการพื้นฐานสำหรับพวกคุณ

ข้อกำหนดในด้านของฉันนั้นเรียบง่าย:
* อินเทอร์เฟซที่ใช้งานง่ายน้อยที่สุด
* Google OAuth เพื่อให้สามารถใช้งานได้บนอุปกรณ์หลายเครื่อง
* สถานะคงอยู่ตลอดการโหลดไซต์ซ้ำ

ฉันมีเซิร์ฟเวอร์ตอบสนองที่ทำงานเพื่อโฮสต์เว็บไซต์สาธิตอยู่แล้ว ดังนั้นจึงสามารถวางลงในแบ็กเอนด์รายการและใช้งานได้ เซิร์ฟเวอร์ React ยังอยู่ในช่วงอัลฟ่า ดังนั้นจึงยังไม่มีการเชื่อมต่อฐานข้อมูล ซึ่งหมายความว่าข้อมูลจะหายไปหลังจากที่เซิร์ฟเวอร์รีสตาร์ท แต่สำหรับตอนนี้ ทำหน้าที่เป็น POC ที่น่าสนใจของการเข้ารหัสแบบโต้ตอบบนแบ็กเอนด์โดยใช้ TSX และ hooks

หากคุณสนใจที่จะสร้างแอป Lists ด้วยตัวเอง โปรดทำตามบทช่วยสอน Todo App เลย ฉันจะให้ลิงก์ไปยัง repo Github แก่คุณสำหรับโพสต์บนบล็อกนี้และตอนท้าย ฉันจะอธิบายการเปลี่ยนแปลงที่สำคัญสำหรับผู้อ่านโดยย่อ คุณสามารถโคลนโค้ดทั้งหมดได้จาก GitHub ซึ่งมากเกินไปสำหรับโพสต์บนบล็อกนี้

แบ็กเอนด์

ก่อนอื่นเราต้องเพิ่มการรองรับสำหรับการสร้างรายการแบบไดนามิก เพื่อทำเช่นนั้น เราจึงปรับเปลี่ยนส่วนประกอบ Todos เล็กน้อย เริ่มต้นด้วยการตั้งชื่อทั่วไปให้มากขึ้นว่า "รายการ" นอกจากนี้เรายังจำเป็นต้องเพิ่มส่วนประกอบฝั่งเซิร์ฟเวอร์อื่นที่จัดการรายการของเรา

import {
    Scopes,
    authenticate,
    isClientContext,
    useState,
} from '@state-less/react-server';
import { v4 } from 'uuid';
import { ServerSideProps } from './ServerSideProps';
import { JWT_SECRET } from '../config';
type TodoObject = {
    id: string | null;
    title: string;
    completed: boolean;
};

export const Todo = ({ id, completed, title }: TodoObject) => {
    const [todo, setTodo] = useState<TodoObject>(
        {
            id,
            completed,
            title,
        },
        {
            key: `page${id}`,
            scope: Scopes.Client,
        }
    );

   const toggle = () => {
        setTodo({ ...todo, completed: !todo.completed });
    };

   return <ServerSideProps key={`${id}-todo`} {...todo} toggle={toggle} />;
};

export const List = (_, { key }) => {
    const [todos, setTodos] = useState<TodoObject[]>([], {
        key: 'todos',
        scope: `${key}.${Scopes.Client}`,
    });
    const [title, setTitle] = useState('My List', {
        key: 'title',
        scope: `${key}.${Scopes.Client}`,
    });
    const addEntry = (todo: TodoObject) => {
        const id = v4();
        const newTodo = { ...todo, id };

        if (!isValidTodo(newTodo)) {
            throw new Error('Invalid todo');
        }
        setTodos([...todos, newTodo]);
        return newTodo;
    };
    const removeEntry = (id: string) => {
        setTodos(todos.filter((todo) => todo.id !== id));
    };
    return (
        <ServerSideProps
            key={`${key}-props`}
            add={addEntry}
            remove={removeEntry}
            title={title}
            setTitle={setTitle}
        >
            {todos.map((todo) => (
                <Todo {...todo} />
            ))}
        </ServerSideProps>
    );
};

export const MyLists = (_: { key?: string }, { context, key }) => {
    let user = null;
    if (isClientContext(context))
        try {
            user = authenticate(context.headers, JWT_SECRET);
        } catch (e) {}

    const [lists, setLists] = useState([], {
        key: 'lists',
        scope: `${key}.${user?.id || Scopes.Client}`,
    });
    const addEntry = (todo: TodoObject) => {
        const id = v4();
        const newList = { ...todo, id };
        setLists([...lists, newList]);
    };
    const removeEntry = (id: string) => {
        setLists(lists.filter((list) => list.id !== id));
    };
    return (
        <ServerSideProps
            key="my-lists-props"
            add={addEntry}
            remove={removeEntry}
        >
            {lists.map((list) => (
                <List key={`list-${list.id}`} {...list} />
            ))}
        </ServerSideProps>
    );
};
const isValidTodo = (todo): todo is TodoObject => {
    return todo.id && todo.title && 'completed' in todo;
};

อย่างที่คุณเห็นเราได้เพิ่มส่วนประกอบ MyLists ซึ่งมีหน้าที่ในการสร้างรายการใหม่และส่งไปยังไคลเอนต์ โดยพื้นฐานแล้วตรรกะสำหรับส่วนประกอบรายการจะเหมือนกับเมื่อก่อน เราสามารถเรนเดอร์องค์ประกอบ Lists ได้หลายครั้งเพื่อนำตรรกะทางธุรกิจกลับมาใช้ใหม่ ซึ่งแสดงให้เห็นว่าแนวทางที่ขับเคลื่อนด้วยส่วนประกอบมีความยืดหยุ่นสูงและช่วยให้คุณสามารถสรุปโค้ดและพฤติกรรมของคุณโดยนำชิ้นส่วนโมดูลาร์ส่วนใหญ่กลับมาใช้ใหม่ได้อย่างง่ายดาย

นั่นคือทั้งหมดที่เราต้องจัดการกับแบ็กเอนด์ในตอนนี้ ตรงไปที่ส่วนหน้าและปรับโค้ดส่วนหน้า

ส่วนหน้า

เราไม่จำเป็นต้องทำการเปลี่ยนแปลงมากมายเพื่อแสดงรายการสิ่งที่ต้องทำหลายรายการ React บนส่วนหน้านั้นมีความยืดหยุ่นและเป็นโมดูลพอๆ กับ React Server บนแบ็กเอนด์ ดังนั้นเราจึงสามารถเรนเดอร์รายการได้หลายครั้ง

/* eslint-disable @typescript-eslint/no-misused-promises */
import {
  Box,
  Button,
  Card,
  CardHeader,
  Checkbox,
  IconButton,
  List as MUIList,
  ListItem,
  ListItemIcon,
  ListItemSecondaryAction,
  ListItemText,
  TextField,
  CardContent,
  CardMedia,
  CardActions,
  Alert,
  Grid,
  InputAdornment,
  Typography,
} from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import RemoveCircleIcon from '@mui/icons-material/RemoveCircle';
import { useComponent } from '@state-less/react-client';
import { useContext, useEffect, useRef, useState } from 'react';
import IconMore from '@mui/icons-material/Add';
import IconClear from '@mui/icons-material/Clear';
import { Actions, stateContext } from '../provider/StateProvider';
export const MyLists = (props) => {
  const [component, { loading, error }] = useComponent('my-lists', {});
  const { state, dispatch } = useContext(stateContext);
  useEffect(() => {
    const onKeyUp = (e) => {
      console.log('KEY UP', e);
      if (e.key === 'z' && e.ctrlKey) {
        const lastAction = state.history.at(-1);
        console.log('LAST ACTION', lastAction);
        lastAction?.reverse();
        dispatch({ type: Actions.REVERT_CHANGE });
      }
    };
    window.addEventListener('keyup', onKeyUp);
    return () => {
      window.removeEventListener('keyup', onKeyUp);
    };
  }, [state]);

  return (
    <div>
      {error && <Alert severity="error">{error.message}</Alert>}
      <Grid container spacing={1}>
        {component?.children?.map((list, i) => {
          return (
            <Grid item sm={12} md={4}>
              <List key={list.key} list={`${list.key}`}>
                {' '}
              </List>
            </Grid>
          );
        })}
        <Grid item sm={12} md={4}>
          <NewListSkeleton onAdd={() => component?.props?.add()} />
        </Grid>
      </Grid>
    </div>
  );
};

export const NewListSkeleton = ({ onAdd }) => {
  return (
    <Card sx={{ height: '100%' }}>
      <Box sx={{ display: 'flex', justifyContent: 'center', height: '100%' }}>
        <Box sx={{ my: 'auto' }}>
          <Button onClick={onAdd}>
            <IconMore />
          </Button>
        </Box>
      </Box>
    </Card>
  );
};

export const List = ({ data, list }) => {
  const { dispatch, state } = useContext(stateContext);
  const [component, { loading, error }] = useComponent(list, {});
  const [title, setTitle] = useState('');
  const [edit, setEdit] = useState(false);
  const inputRef = useRef<HTMLInputElement | null>(null);
  if (loading) {
    return null;
  }


  const addEntry = async (e) => {
    setTitle('');
    const res = await component.props.add({
      title,
      completed: false,
    });
    const id = res.id;
    dispatch({
      type: Actions.SHOW_MESSAGE,
      value: `Added ${title}. Undo? (Ctrl+Z)`,
    });
    dispatch({
      type: Actions.RECORD_CHANGE,
      message: `Added ${title}. Undo?`,
      value: {
        reverse: () => {
          component.props.remove(id);
        },
      },
    });
  };
  return (
    <Card sx={{ height: '100%' }}>
      {error && <Alert severity="error">{error.message}</Alert>}
      <CardHeader
        title={
          <>
            {!edit && (
              <Typography variant="h6">{component?.props?.title}</Typography>
            )}
            <Box sx={{ display: 'flex', alignItems: 'center' }}>
              <TextField
                fullWidth
                inputRef={inputRef}
                value={edit ? component?.props?.title : title}
                label={edit ? 'Title' : 'Add Entry'}
                onChange={(e) =>
                  edit
                    ? component?.props?.setTitle(e.target.value)
                    : setTitle(e.target.value)
                }
                onKeyDown={(e) => {
                  if (!edit && e.key === 'Enter') {
                    addEntry(e);
                  }
                }}
                InputProps={{
                  endAdornment: (
                    <InputAdornment position="end">
                      <IconButton
                        onClick={() => {
                          setTitle('');
                          setTimeout(() => inputRef.current?.focus(), 0);
                        }}
                        disabled={!title}
                      >
                        <IconClear />
                      </IconButton>
                    </InputAdornment>
                  ),
                }}
              />
              <IconButton disabled={!title} onClick={addEntry}>
                <IconMore />
              </IconButton>
            </Box>
          </>
        }
      ></CardHeader>
      <MUIList>
        {component?.children.map((todo, i) => (
          <TodoItem
            key={i}
            todo={todo.key}
            edit={edit}
            remove={component?.props?.remove}
          />
        ))}
      </MUIList>
      <CardActions>
        <Button onClick={() => setTitle('') || setEdit(!edit)}>
          <EditIcon />
        </Button>
      </CardActions>
    </Card>
  );
};

const TodoItem = (props) => {
  const { dispatch, state } = useContext(stateContext);
  const { todo, edit, remove } = props;
  const [component, { loading }] = useComponent(todo, {});
TS
  if (loading) return null;

  return (
    <ListItem>
      {edit && (
        <ListItemIcon>
          <IconButton onClick={() => remove(component.props.id)}>
            <RemoveCircleIcon />
          </IconButton>
        </ListItemIcon>
      )}
      <ListItemText primary={component.props.title} />
      <ListItemSecondaryAction>
        <Checkbox
          checked={component?.props.completed}
          onClick={() => {
            dispatch({
              type: Actions.SHOW_MESSAGE,
              value: `Marked ${component.props.title}. Undo? (Ctrl+Z)`,
            });
            dispatch({
              type: Actions.RECORD_CHANGE,
              value: {
                reverse: () => {
                  console.log('Reversing');
                  component?.props.toggle();
                },
              },
            });
            component?.props.toggle();
          }}
        />
      </ListItemSecondaryAction>
    </ListItem>
  );
};

อย่ากลัวกำแพงโค้ดอันใหญ่โต มีมากกว่า 220 บรรทัด แต่โค้ดส่วนหน้ามีแนวโน้มที่จะละเอียดกว่านี้เล็กน้อย หากคุณต้องการโครงสร้างและสไตล์ของโค้ดที่เหมาะสม

ส่วนที่เหลือ

แน่นอนว่านั่นไม่ใช่ทุกสิ่งที่จำเป็นในการรันแอปพลิเคชัน อย่างที่คุณเห็น มีตรรกะการจัดการสถานะบางอย่างที่ให้คุณกด Ctrl+Z เพื่อยกเลิกคำสั่งสุดท้ายของคุณ รหัสนี้รวมอยู่ใน repo Github ซึ่งฉันจะอยู่ที่ด้านล่างของโพสต์ คุณสามารถโคลนและรันเซิร์ฟเวอร์และส่วนหน้าในเครื่องได้อย่างอิสระ

เข้าสู่ระบบ

ข้อกำหนดประการหนึ่งคือต้องสามารถใช้บัญชี Google ของคุณเพื่อลงชื่อเข้าใช้เพื่อให้สามารถใช้แอปบนสมาร์ทโฟนและเดสก์ท็อปได้ในเวลาเดียวกัน

เห็นได้ชัดว่ามีความมหัศจรรย์ในการรับรองความถูกต้องเกิดขึ้นในโค้ดส่วนหน้าซึ่งไม่ได้แสดงไว้ที่นี่ หากคุณสนใจ คุณสามารถ "อ่านเพิ่มเติมเกี่ยวกับการรับรองความถูกต้อง" ได้ที่เอกสาร

เสร็จแล้ว

แน่นอนว่าแค่ไม่กี่ชั่วโมงนั้นไม่เพียงพอที่จะสร้างแอปเวอร์ชันที่ใช้งานจริงที่ทำงานได้เต็มรูปแบบโดยมีผู้ใช้นับพันราย แต่ฉันคิดว่ามันแสดงให้เห็นว่าคุณสามารถสร้างต้นแบบแอปพลิเคชัน fullstack ด้วยเซิร์ฟเวอร์ตอบสนองได้อย่างรวดเร็ว

หากคุณมีไอเดียเกี่ยวกับฟีเจอร์ที่มีประโยชน์ แสดงความคิดเห็นหรือ ทวีตถึงเรา (@statelesscloud)

Github Repo

แบ็กเอนด์: https://github.com/C5H8NNaO4/lists-app-backend

ส่วนหน้า:https://github.com/C5H8NNaO4/lists-app-frontend

นี่คือเวอร์ชันสดที่ https://state-less.cloud/lists

เนื้อหาเพิ่มเติมได้ที่ PlainEnglish.io.

ลงทะเบียนเพื่อรับ จดหมายข่าวรายสัปดาห์ฟรี ของเรา ติดตามเราบน Twitter, LinkedIn, YouTube และ Discord .