Часть 1.
Всем привет! Продолжаем серию туториалов по разработке веб-приложения на Reflex.
В этой части мы добавим возможность выполнять различные манипуляции со списком задач.
Действия над Todo
Добавим возможность отмечать задания выполненными, а также редактировать и удалять их.
В первую очередь для этого расширим тип Todo
, добавив состояние. В случае, если задание не выполнено, то его можно редактировать.
data TodoState
= TodoDone
| TodoActive { stateEdit :: Bool }
deriving (Generic, Eq, Show)
data Todo = Todo
{ todoText :: Text
, todoState :: TodoState }
deriving (Generic, Eq, Show)
newTodo :: Text -> Todo
newTodo todoText = Todo { todoState = TodoActive False, .. }
Далее определим события, происходящие в системе. В рабочих проектах мы использовали два подхода для этого. Первый — это перечислить все возможные события как отдельные конструкторы и реализовать функцию-обработчик, которая будет обновлять состояние в зависимости от произошедшего события.
data TodoEvent
= NewTodo Todo
| ToggleTodo Int
| StartEditTodo Int
| FinishEditTodo (Text,Int)
| DeleteTodo Int
deriving (Generic, Eq, Show)
Плюсами такого подхода является возможность увидеть, какое конкретное событие происходит в системе, и обновление, которое оно несет в себе (все это позволяет сделать функция traceEvent
). Но эти преимущества не всегда получается использовать, особенно когда событие несет в себе очень много данных, которые, в итоге, сложно проанализировать. Если все же необходимо увидеть изменение значений, то события, в любом случае, изменяют Dynamic
, значение которого также можно отследить при помощи функции traceDyn
.
Другой подход — использование функций обновления, которые представляются в виде моноида Endo
(грубо говоря, это абстрактное название функции, у которой совпадает тип аргумента и результата). Суть этого подхода в том, что значением, которое несёт событие обновления, является функция, которая и задаёт саму логику обновления. В данном случае, мы теряем возможность вывести значение события (которая, как выяснилось, не всегда полезна), но очевидным плюсом является то, что нам нет необходимости иметь доступ к текущему состоянию, создавать тип со всеми возможными событиями (которых может быть очень много), а также определять отдельный обработчик, который будет обновлять состояние в соответствии с полученным событием.
В данном туториале мы будем использовать второй подход.
Изменим структуру корневого виджета:
rootWidget :: MonadWidget t m => m ()
rootWidget =
divClass "container" $ do
elClass "h2" "text-center mt-3" $ text "Todos"
newTodoEv <- newTodoForm
rec
todosDyn <- foldDyn appEndo mempty $ leftmost [newTodoEv, todoEv]
delimiter
todoEv <- todoListWidget todosDyn
blank
Первое, что мы тут видим — это использование расширения RecursiveDo
(его, соответственно надо включить). Это один из распространенных приёмов в разработке reflex
приложений, т.к. очень часто возникают ситуации, когда событие, происходящее в нижней части страницы, влияет на элементы на верху страницы. В данном случае событие todoEv
используется в определении todosDyn
, а todosDyn
в свою очередь является аргументом для виджета, из которого приходит событие todoEv
.
Далее мы видим обновление параметров функции foldDyn
. Здесь есть использование новой функции leftmost
. Она принимает список событий и возвращает событие, которое срабатывает в тот момент, когда сработает любое из событий в списке. Если в данный момент срабатывает два события из списка, то будет возвращено то, что стоит левее в списке (отсюда и название). Также список заданий теперь не список, а IntMap
(для упрощения будем использовать type Todos = IntMap Todo
). В первую очередь это сделано для того, чтобы мы могли напрямую обращаться к какому-либо элементу по идентификатору. Для обновления списка используется appEndo
. В случае, если бы мы определили каждое событие как отдельный конструктор, то нам бы пришлось также определить функцию-обработчик, которая выглядела бы примерно так:
updateTodo :: TodoEvent -> Todos -> Todos
updateTodo ev todos = case ev of
NewTodo todo -> nextKey todos =: todo <> todos
ToggleTodo ix -> update (Just . toggleTodo) ix todos
StartEditTodo ix -> update (Just . startEdit) ix todos
FinishEditTodo (v, ix) -> update (Just . finishEdit v) ix todos
DeleteTodo ix -> delete ix todos
Несмотря на отсутствие необходимости определять эту функцию, тут используются несколько других вспомогательных функций, которые все равно понадобятся нам в будущем.
startEdit :: Todo -> Todo
startEdit todo = todo { todoState = TodoActive True }
finishEdit :: Text -> Todo -> Todo
finishEdit val todo = todo
{ todoState = TodoActive False, todoText = val }
toggleTodo :: Todo -> Todo
toggleTodo Todo{..} = Todo {todoState = toggleState todoState,..}
where
toggleState = \case
TodoDone -> TodoActive False
TodoActive _ -> TodoDone
nextKey :: IntMap Todo -> Int
nextKey = maybe 0 (succ . fst . fst) . maxViewWithKey
Изменилась и функция добавления нового элемента, теперь она возвращает событие, а не само задание. Также добавим очистку поля после добавления нового задания.
newTodoForm :: MonadWidget t m => m (Event t (Endo Todos))
newTodoForm = rowWrapper $ el "form" $ divClass "input-group" $ mdo
iEl <- inputElement $ def
& initialAttributes .~
( "type" =: "text"
<> "class" =: "form-control"
<> "placeholder" =: "Todo" )
& inputElementConfig_setValue .~ ("" <$ btnEv)
let
addNewTodo = \todo -> Endo $ \todos ->
insert (nextKey todos) (newTodo todo) todos
newTodoDyn = addNewTodo <$> value iEl
btnAttr = "class" =: "btn btn-outline-secondary"
<> "type" =: "button"
(btnEl, _) <- divClass "input-group-append" $
elAttr' "button" btnAttr $ text "Add new entry"
let btnEv = domEvent Click btnEl
pure $ tagPromptlyDyn newTodoDyn $ domEvent Click btnEl
Функция todoListWidget
теперь возвращает изменение списка, и она так же подверглась небольшим изменениям:
todoListWidget :: MonadWidget t m => Dynamic t Todos -> m (Event t (Endo Todos))
todoListWidget todosDyn = rowWrapper $ do
evs <- listWithKey
(M.fromAscList . IM.toAscList <$> todosDyn) todoWidget
pure $ switchDyn $ leftmost . M.elems <$> evs
Первое, что замечаем, это замена функции simpleList
функцией listWithKey
. Отличаются они типом первого параметра — первая функция принимает список []
, вторая — Map
. Список будет выведен в отсортированным по ключу порядке. Здесь важно возвращаемое значение. Каждое задание возвращает событие (удаление, изменение). В конкретно нашем случае функция listWithKey
будет иметь следующий тип:
listWithKey
:: MonadWidget t m
=> Dynamic t (Map Int Todo)
-> (Int -> Dynamic t Todo -> m (Event t TodoEvent))
-> m (Dynamic t (Map Int TodoEvent))
Примечание: эта функция является частью пакета reflex
и имеет более сложный тип. Здесь уже показан специализированный тип.
Здесь используется знакомая уже функция leftmost
для всех значений из Map
. Выражение leftmost . elems <$> evs
имеет следующий тип: Dynamic t (Event t TodoEvent)
. Для того чтобы извлечь Event
из Dynamic
, используется функция switchDyn
. Она работает следующим образом: возвращает событие, которое срабатывает тогда, когда срабатывает внутреннее событие. В том случае, если Dynamic
и Event
срабатывают одновременно, будет возвращен старый Event
, до момента обновления события в Dynamic
. Существует функция switchPromtlyDyn
, которая работает иначе: если одновременно происходит обновление Dynamic
, срабатывает событие, которое было до обновления Dynamic
, и "стреляет" событие, которые теперь содержит Dynamic
, то будет возвращено именно новое событие, которое теперь содержит Dynamic
. Если такая ситуация является невозможной, то всегда лучше предпочитать использовать switchDyn
, т.к. функция switchPromtlyDyn
является более сложной и выполняет дополнительные действий, и, более того, может привести к циклам.
У задания появились разные состояния, поэтому функция отображения одного задания также претерпела изменения:
todoWidget :: MonadWidget t m => Int -> Dynamic t Todo -> m (Event t (Endo Todos))
todoWidget ix todoDyn = do
todoEvEv <- dyn $ ffor todoDyn $ \td@Todo{..} -> case todoState of
TodoDone -> todoDone ix todoText
TodoActive False -> todoActive ix todoText
TodoActive True -> todoEditable ix todoText
switchHold never todoEvEv
Здесь мы используем новую функцию dyn
. Она имеет следующий тип:
dyn
:: (Adjustable t m, NotReady t m, PostBuild t m)
=> Dynamic t (m a)
-> m (Event t a)
В качестве входного параметра она получает виджет, завернутый Dynamic
. Это означает, что при каждом обновлении Dynamic
, будет также обновляться DOM
. Выходным значением является событие, которое несет значение, возвращаемое виджетом. Специализированный тип для нашего случая будет выглядеть следующим образом:
dyn
:: MonadWidget t m
=> Dynamic t (m (Event t (Endo Todos)))
-> m (Event t (Event t (Endo Todos)))
Тут мы встречаемся с событием, вложенным в другое событие. Есть две функции из пакета reflex
, которые могут работать с таким типом: coincedence
и switchHold
. Первая функция возвращает событие, которое будет срабатывать только тогда, когда срабатывают одновременно внешнее и внутреннее события. Это не наш случай. Функция switchHold
имеет следующий тип:
switchHold :: (Reflex t, MonadHold t m) => Event t a -> Event t (Event t a) -> m (Event t)
Эта функция переключается на новое событие каждый раз, когда срабатывает внешнее событие. До первого срабатывания внешнего события, будет стрелять событие, переданное первым параметром. Именно так мы и используем эту функцию в нашем случае. До какого-либо первого изменения списка, оттуда не может прийти никакое событие, и мы используем событие never
. Это специальное событие, которое, в соответствии с названием, не срабатывает никогда.
Функция todoWidget
использует разные виджеты для разных состояний.
todoActive :: MonadWidget t m => Int -> Text -> m (Event t (Endo Todos))
todoActive ix todoText = divClass "d-flex border-bottom" $ do
divClass "p-2 flex-grow-1 my-auto" $
text todoText
divClass "p-2 btn-group" $ do
(doneEl, _) <- elAttr' "button"
( "class" =: "btn btn-outline-secondary"
<> "type" =: "button" ) $ text "Done"
(editEl, _) <- elAttr' "button"
( "class" =: "btn btn-outline-secondary"
<> "type" =: "button" ) $ text "Edit"
(delEl, _) <- elAttr' "button"
( "class" =: "btn btn-outline-secondary"
<> "type" =: "button" ) $ text "Drop"
pure $ Endo <$> leftmost
[ update (Just . toggleTodo) ix <$ domEvent Click doneEl
, update (Just . startEdit) ix <$ domEvent Click editEl
, delete ix <$ domEvent Click delEl
]
todoDone :: MonadWidget t m => Int -> Text -> m (Event t (Endo Todos))
todoDone ix todoText = divClass "d-flex border-bottom" $ do
divClass "p-2 flex-grow-1 my-auto" $
el "del" $ text todoText
divClass "p-2 btn-group" $ do
(doneEl, _) <- elAttr' "button"
( "class" =: "btn btn-outline-secondary"
<> "type" =: "button" ) $ text "Undo"
(delEl, _) <- elAttr' "button"
( "class" =: "btn btn-outline-secondary"
<> "type" =: "button" ) $ text "Drop"
pure $ Endo <$> leftmost
[ update (Just . toggleTodo) ix <$ domEvent Click doneEl
, delete ix <$ domEvent Click delEl
]
todoEditable :: MonadWidget t m => Int -> Text -> m (Event t (Endo Todos))
todoEditable ix todoText = divClass "d-flex border-bottom" $ do
updTodoDyn <- divClass "p-2 flex-grow-1 my-auto" $
editTodoForm todoText
divClass "p-2 btn-group" $ do
(doneEl, _) <- elAttr' "button"
( "class" =: "btn btn-outline-secondary"
<> "type" =: "button" ) $ text "Finish edit"
let updTodos = \todo -> Endo $ update (Just . finishEdit todo) ix
pure $
tagPromptlyDyn (updTodos <$> updTodoDyn) (domEvent Click doneEl)
editTodoForm :: MonadWidget t m => Text -> m (Dynamic t Text)
editTodoForm todo = do
editIEl <- inputElement $ def
& initialAttributes .~
( "type" =: "text"
<> "class" =: "form-control"
<> "placeholder" =: "Todo")
& inputElementConfig_initialValue .~ todo
pure $ value editIEl
Все использованные тут функции были рассмотрены раньше, поэтому не будем углубляться в объяснение каждой в отдельности.
Оптимизация
Вернемся к функции listWithKey
:
listWithKey
:: MonadWidget t m
=> Dynamic t (Map Int Todo)
-> (Int -> Dynamic t Todo -> m (Event t TodoEvent))
-> m (Dynamic t (Map Int TodoEvent))
Функция работает таким образом, что любое обновления переданного Dynamic
спровоцирует обновление каждого отдельного элемента. Даже если мы, например, изменили один элемент, это обновление будет передано в каждый элемент, хоть он и не изменит значения. Теперь вернемся к функции todoWidget
.
todoWidget :: MonadWidget t m => Int -> Dynamic t Todo -> m (Event t (Endo Todos))
todoWidget ix todoDyn = do
todoEvEv <- dyn $ ffor todoDyn $ \td@Todo{..} -> case todoState of
TodoDone -> todoDone ix todoText
TodoActive False -> todoActive ix todoText
TodoActive True -> todoEditable ix todoText
switchHold never todoEvEv
Если вспомним, как работает функция dyn
, то она обновляет DOM
каждый раз, когда происходит обновление todoDyn
. Учитывая, что при изменении одного элемента из списка, это обновление передается на каждый элемент в отдельности, получаем, что весь участок DOM
, который выводит наши задания, будет перестраиваться (проверить это можно при помощи панели разработчика в браузере). Это явно не то, что мы хотим. Тут на помощь приходит функция holdUniqDyn
.
todoWidget :: MonadWidget t m => Int -> Dynamic t Todo -> m (Event t TodoEvent)
todoWidget ix todoDyn' = do
todoDyn <- holdUniqDyn todoDyn'
todoEvEv <- dyn $ ffor todoDyn $ \td@Todo{..} -> case todoState of
TodoDone -> todoDone ix td
TodoActive False -> todoActive ix td
TodoActive True -> todoEditable ix td
switchHold never todoEvEv
Мы добавили строку todoDyn <- holdUniqDyn todoDyn'
. Что тут происходит? Дело в том, что хоть Dynamic
и "выстреливает", значение, которое он содержит, не меняется. Функция holdUniqDyn
работает именно так, что если переданный ей Dynamic
"выстрелил" и не изменил свое значение, то выходной Dynamic
не будет "выстреливать", и, соответственно, в нашем случае не будет лишних перестроек DOM
.
Полученный результат можно посмотреть в нашем репозитории.
В следующей части рассмотрим другой способ управления событиями и использование библиотеки GHCJS-DOM.