Привет, ребята, если вы не читали мою предыдущую статью о программировании простого приложения Todo с помощью React Server, обязательно ознакомьтесь с ней, так как мы строим большую часть кода вокруг нее.

Если вы новичок в React Server, посетите домашнюю страницу по адресу https://state-less.cloud/.

Начнем с идеи. Простое приложение для работы со списками, которое позволяет создавать столько списков, сколько вы хотите, и добавлять в них проверяемые элементы.

В основном нам нужно расширить приложение списка задач, чтобы иметь возможность создавать списки на лету вместо одного статического списка задач.

Это может быть полезно, если вы хотите отслеживать несколько вещей в разных списках. Например, цели, списки покупок, все, что вы можете поместить в список.

Мне часто нужны списки, но у меня нет инструмента, который бы я использовал ежедневно для отслеживания своих ссылок. К счастью, это очень легко сделать с React Server, поэтому я не видел причин не писать для вас базовое приложение для работы со списками.

Требования с моей стороны просты:
* Минимальный интуитивно понятный интерфейс
* Google OAuth для возможности использования на нескольких устройствах.
* Постоянные состояния при перезагрузке сайта.

У меня уже есть работающий сервер для размещения демонстрационного веб-сайта, поэтому он может просто подключиться к бэкэнду Lists и использовать его. React Server все еще находится на стадии альфа-тестирования, поэтому у него еще нет подключения к базе данных, что означает, что данные исчезают после перезапуска сервера, но на данный момент он служит интересным POC реактивного кодирования на бэкэнде с использованием TSX и хуков.

Если вы заинтересованы в создании приложения «Списки» самостоятельно, следуйте руководству по приложению Todo. Я дам вам ссылку на репозиторий 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 в основном такая же, как и раньше. Мы можем просто визуализировать компонент Lists несколько раз, чтобы повторно использовать его бизнес-логику, которая показывает, что подход, основанный на компонентах, очень гибкий и легко позволяет вам обобщать ваш код и поведение, повторно используя большинство его модульных частей.

Это все, что нам нужно обработать на бэкэнде на данный момент. Давайте перейдем к интерфейсу и адаптируем код интерфейса.

Внешний интерфейс

На самом деле нам не нужно делать много изменений, чтобы отобразить несколько списков задач. React на внешнем интерфейсе такой же гибкий и модульный, как и сервер React на бэкэнде, поэтому мы можем просто отображать списки несколько раз.

/* 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, чтобы отменить последнюю команду. Этот код включен в репозиторий Github, который я покажу внизу поста. Не стесняйтесь клонировать и запускать сервер и интерфейс локально.

Авторизоваться

Одно из требований заключалось в том, чтобы иметь возможность использовать свою учетную запись Google для входа в систему, чтобы иметь возможность одновременно использовать приложение на смартфоне и компьютере.

Таким образом, очевидно, что во внешнем коде также происходит какая-то магия аутентификации, которая здесь не показана. Если вам интересно, вы можете узнать больше об аутентификации в документации.

Сделанный

Вот и все, конечно, нескольких часов недостаточно, чтобы создать полностью работающее производственное приложение с тысячами пользователей, но я думаю, что это показывает, что вы можете довольно быстро создавать прототипы приложений полного стека с сервером реагирования.

Если у вас есть идеи для полезных функций, оставьте комментарий или твитните нас(@statelesscloud).

Репозиторий Github

Бэкенд: 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.