Добавление манипуляции с Neo4j GraphDB через React Beautiful dnd

В этом руководстве предполагается, что вы знакомы с React, Apollo и Neo4j на базовом уровне.

Планируя свой последний побочный проект, я решил поиграть с функцией, с которой всегда хотел поработать, в интерфейсе, функции перетаскивания. Вскоре я узнал, что существует ряд высоко оцененных библиотек перетаскивания для React, но после прочтения документации и обзоров я решил, что React-beautiful-dnd подойдет моему варианту использования. Вдобавок к нему прилагается очень красивый бесплатный учебный курс, который вы можете найти здесь. Ни один из кодов, относящихся к функциям перетаскивания, не принадлежит мне, я адаптировал его из учебника, и мой единственный вклад состоял в том, что я создал его с помощью крючков и компонентов класса. Перед тем, как перейти к следующему шагу, вам необходимо изучить руководство по React-beautiful-dnd.

Приступим!

После того, как вы завершили обучение перетаскиванию из Egghead, все, что вам нужно сделать здесь, - это выбрать стартовый проект GRANDstack, клонировать его и запустить в нужной среде IDE. После того, как вы запустите проект, нам нужно будет добавить эти типы в ваш файл schema.graphql:

type Task {
 id: ID!
 content: String!
 column: Column @relation(name: “BELONGS_TO”, direction: “OUT”)
}
type Column {
 id: ID!
 title: String!
 tasks: [Task] @relation(name: “BELONGS_TO”, direction: “IN”)
 table: Table @relation(name: “BELONGS_TO”, direction: “OUT”)
 taskIds: [ID]
}
type Table {
 id: ID!
 title: String!
 columns: [Column] @relation(name: “BELONGS_TO”, direction: “IN”)
 columnOrder: [ID]
}

Когда наши данные будут добавлены, наш график будет выглядеть примерно так.

Давайте продолжим и добавим данные в наш график, откройте рабочий стол Neo4j, скопируйте и вставьте этот код Cypher:

CREATE(t1:Table {id: “t1”, title: “Test Table”, columnOrder: []}),
(c1:Column {id: “c1”, title: “New Test Column”, taskIds: []}),
(c2:Column {id: “c2”, title: “New Test Column 2”, taskIds: []}),
(c3:Column {id: “c3”, title: “New Test Column 3”, taskIds: []}),
(tk1:Task {id: “tk1”, content: “Task 1”}),
(tk2:Task {id: “tk2”, content: “Task 2”}),
(tk3:Task {id: “tk3”, content: “Task 3”})
with t1, c1, c2, c3, tk1, tk2, tk3
CREATE (t1)<-[:BELONGS_TO]-(c1)
CREATE (t1)<-[:BELONGS_TO]-(c2)
CREATE (t1)<-[:BELONGS_TO]-(c3)
CREATE (c1)<-[:BELONGS_TO]-(tk1)
CREATE (c1)<-[:BELONGS_TO]-(tk2)
CREATE (c1)<-[:BELONGS_TO]-(tk3)

Это создаст структуру графика, которая нам нужна. Затем запустите эти две команды Cypher:

match(t:Table)
match(c:Column)
with t, collect(c.id) as ids
set t.columnOrder = ids

и

match(c:Column {id: “c1”})
match(t:Task)
with c, collect(t.id) as ids
set c.taskIds = ids

Это устанавливает начальные идентификаторы и гарантирует, что наши столбцы начинаются правильно. После этого мы сможем приступить к работе.

Вот ссылка на репозиторий GitHub для завершенного проекта. Вы начнете с того момента, когда у вас будет несколько столбцов и вы сможете менять порядок задач, а также менять их между столбцами. До этого момента для проекта не было серверной части, поэтому любые внесенные вами изменения будут отменены, когда вы обновите браузер или покинете его. Кроме того, мы получаем состояние нашего приложения из объекта, который был создан, а не вызывая API, и это то, что мы добавим и исправим дальше.

Если вы не клонировали репозиторий и вместо этого следовали руководству Egghead.io, добавить Apollo в наш проект будет несложно. Просто установите его с помощью пряжи или npm, в зависимости от того, какой метод вы предпочитаете для меня, это пряжа:

yarn add @apollo/client

В предыдущих версиях Apollo вам нужно было установить довольно много других пакетов, но в V3 все они собраны вместе. После того, как мы установили Apollo, нам нужно создать нового клиента в корне нашего приложения:

index.js
import React from ‘react’;
import ReactDOM from ‘react-dom’;
import ‘./index.css’;
import ‘@atlaskit/css-reset’;
import App from ‘./App’;
import {ApolloClient, ApolloProvider, InMemoryCache} from “@apollo/client”;
const client = new ApolloClient({
 uri: process.env.REACT_APP_GRAPHQL_URI || ‘http://localhost:4001/graphql',
 cache: new InMemoryCache(),
})
ReactDOM.render(
 <React.StrictMode>
 <ApolloProvider client={client}>
 <App />
 </ApolloProvider>
 </React.StrictMode>,
 document.getElementById(‘root’)
);

И это все, что нам нужно для начала работы с Apollo Client, убедитесь, что вы изменили соответствующие переменные среды или указали клиенту на правильный локально работающий GraphQL API. После этого мы можем продолжить и начать опрашивать наш экземпляр Neo4j, а также обновлять приложение и поддерживать наши данные в режиме реального времени. В наш файл App.js мы собираемся добавить запрос GraphQL и некоторые мутации, которые позволят нам фиксировать состояние нашего приложения. Во-первых, нам нужно импортировать необходимые инструменты из @ apollo / client:

import { gql, useMutation, useQuery } from “@apollo/client”;

Затем мы можем создать наш запрос, для краткости я включаю его в файл App.js, но по мере увеличения размера вашего приложения вы можете подумать о том, чтобы разбивать запросы и мутации в их собственные файлы. Во-первых, нам нужно получить нашу таблицу или страницу и связанные с ней столбцы и задачи из нашего экземпляра Neo4j.
В этом случае я вызываю таблицу по имени:

const GET_TABLE = gql`
    query GetTables($title: String){
        Table(title: $title){
            id
            title
            columnOrder
            columns{
                id
                title
                taskIds
                tasks{
                    id
                    content
                }
            }
        }
    }
`

Этот запрос позволяет нам получить конкретную таблицу, которую мы ищем. Он вытягивает столбцы и выполняет задачи вместе с ними. Чтобы использовать запрос, нам нужно добавить его в наш компонент:

const {loading, error, data} = useQuery(GET_TABLE, {variables: ‘Test Table’});

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

Объект данных из учебника Egghead
В текущем состоянии приложения вы должны использовать этот initialData объект для установки своего состояния. Однако теперь, когда мы собираемся получать данные через наш API, необходимо изменить это следующим образом:

const initialData = {
  tasks: {
    'task-1': {id: 'task-1', content: 'Take out the garbage'},
    'task-2': {id: 'task-2', content: 'Watch my favorite show'},
    'task-3': {id: 'task-3', content: 'Charge my phone'},
    'task-4': {id: 'task-4', content: 'Cook dinner'},
  },
  columns: {
    'column-1': {
      id: 'column-1',
      title: 'To do',
      taskIds: ['task-1', 'task-2', 'task-3', 'task-4'],
    },
    'column-2': {
      id: 'column-2',
      title: 'In Progress',
      taskIds: [],
    },
    'column-3': {
      id: 'column-3',
      title: 'Done',
      taskIds: [],
    }
  },
  columnOrder: ['column-1', 'column-2', 'column-3'],
};

к этому:

const initialData = {
  tasks: {

  },
  columns: {

  },
  columnOrder: []
}

export default initialData;

Это дает нам структуру данных, которые мы ожидаем, прежде чем приложение действительно сможет их загрузить, не позволяя нам получать ошибки рендеринга и нулевые ошибки. Чтобы гарантировать, что мы правильно получаем наши данные из API и не сталкиваемся с асинхронными ошибками, мы собираемся добавить useEffect и использовать состояния загрузки и ошибок Apollo.

useEffect(() => {

  if (data) {
    setTable(data)
  }
}, [data])

if (loading) {
  return <div>...Loading</div>
}

if (error) {
  console.warn(error)
}

Эти действия выполняются до того, как компонент будет отрисован, что позволяет получить данные и, что более важно, преобразовать полученные данные в форму, которую ожидает наше приложение. Мы делаем это в нашей setTable функции, которая вызывается в useEffect после проверки наличия данных.

const setTable = (data) => {
  const {Table} = data;
  const tasks = {};
  const columns = {};
  const columnOrder = Table[0].columnOrder;
  // Pull all tasks out into their own object
  Table[0].columns.forEach((col) => {
    col.tasks.forEach((task) => {
      tasks[task.id] = {id: task.id, content: task.content}
    })
  });
  // Pull out all columns and their associated task ids
  Table[0].columns.forEach((col) => {
    columns[col.id] = {id: col.id, title: col.title, taskIds: col.taskIds}
  })

  const table = {
    tasks,
    columns,
    columnOrder
  }

  setState(table)
}

Этот шаг важен, потому что наши данные, возвращаемые из нашего GraphQL API, имеют форму, которую мы запросили в нашем запросе GET_TABLE, и их необходимо изменить, чтобы они соответствовали нашему приложению. Как бы то ни было, это дает нам базовую структуру, чтобы начать сохранять изменения состояния наших данных в нашей базе данных.

Сохранение порядка столбцов
Первое, что мы собираемся добавить в приложение, - это возможность приложения сохранять изменения в порядке задач в определенном столбце. Для этого мы добавим мутацию, чтобы обновить состояние столбца, эта мутация автоматически создается для нас с помощью расширенной функциональности схемы GRANDstack. В нашем приложении нам нужно отправить мутацию со всей информацией, которая есть в столбце, и в этом случае мы заинтересованы в возврате идентификатора столбца.

const COL_UPDATE = gql`
    mutation UpdateColumn($id: ID!, $title: String, $taskIds: [ID]){
        UpdateColumn(id: $id, title: $title, taskIds: $taskIds){
            id
        }
    }
`

Затем мы добавим в наше приложение ловушку useMutation:
const [colUpdate] = useMutation(COL_UPDATE)
Я пропустил необязательные свойства error и data, и я буду обрабатывать это очень простым способом в нашей функции onDragEnd. Там, где есть обновление столбца, мы добавим функцию обновления, простите за стену следующего текста:

const onDragEnd = (result) => {
  const {destination, source, draggableId} = result;

  if (!destination) {
    return;
  }

  if (
    destination.droppableId === source &&
    destination.index === source.index
  ) {
    return;
  }

  const start = state.columns[source.droppableId];
  const finish = state.columns[destination.droppableId]
  if (start === finish) {
    const newTaskIds = [...start.taskIds]
    newTaskIds.splice(source.index, 1);
    newTaskIds.splice(destination.index, 0, draggableId);

    const newColumn = {
      ...start,
      taskIds: newTaskIds
    };

    const newState = {
      ...state,
      columns: {
        ...state.columns,
        [newColumn.id]: newColumn
      }
    };

    setState(newState);
    colUpdate({
      variables: {
        ...newColumn
      }
    })
      .catch(error => console.log(error))
    return;
  }

Вы увидите, что после обновления состояния нового столбца мы делаем то же самое с нашей мутацией UpdateColumn, изменяя порядок массива taskIds и сохраняя порядок задач. На этом этапе наше приложение будет сохранять порядок задач независимо от того, в какой столбец они были перемещены, но оно также будет дублировать задачи, потому что мы не удаляем их из старых столбцов. Кроме того, поскольку эти данные хранятся в GraphDB, нам также необходимо поменять местами отношения. Это означает, что когда задача перемещается из одного столбца, мы должны разорвать связь с этим столбцом и создать новую [:BELONGS_TO] связь с новым столбцом. Мы достигаем этого с помощью другого набора автоматически сгенерированных мутаций:

const REMOVE_TASK = gql`
    mutation RemoveTaskColumn($from: _TaskInput!, $to_ColumnInput!){
        RemoveTaskColumn(from: $from, to: $to){
            to {
                id
            }
        }
    }
`

const ADD_TASK = gql`
    mutation AddTaskColumn($from: _TaskInput!, $to: _ColumnInput!){
        AddTaskColumn(from: $from, to: $to){
            to {
                id
            }
        }
    }
`

Эти мутации позволяют нам удалить связь между задачей и столбцом, а затем также создать новую связь между той же задачей и новым столбцом. Мы вводим эти хуки useMutation как:
const [addTask] = useMutation(ADD_TASK);
const [removeTask] = useMutation(REMOVE_TASK);

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

colUpdate({
  variables: {
    ...newStart
  }
})
  .then((data) => {
    const {data: {UpdateColumn: {id}}} = data;
    removeTask({
      variables: {
        from: {id: taskId},
        to: {id}
      }
    })
      .catch(error => console.log(error))
  })
  .catch(error => console.log(error))

colUpdate({
  variables: {
    ...newFinish
  }
})
  .then((data) => {
    const {data: {UpdateColumn: {id}}} = data;
    addTask({
      variables: {
        from: {id: taskId},
        to: {id}
      }
    })
      .catch(error => console.log(error))
  })
  .catch(error => console.log(error))

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

И теперь мы можем видеть наши изменения, если вы переместите «Задачу 1» в «Столбец проверки 2», и вы получите следующий результат на своем графике:

И, наконец, переместите «Task 3» в «Test Column 3», и вы получите:

И теперь у нас есть функция перетаскивания, включенная в нашем приложении GRANDstack. Вы можете видеть, что это немного сложнее, чем это могло бы быть с базой данных SQL, потому что вам нужно работать над отношениями, но, к счастью, автоматически сгенерированные мутации и Apollo делают работу с ней очень простой. Так что вперед и перетащите все вещи!

(15.09.2020): Обновите, я недавно добавил код для добавления задач в наши столбцы. Вы можете найти его во включенном репозитории GitHub.

(26.10.2020): в приложении появилась возможность удалять задачи и исходные функции для редактирования содержимого задач.