Adding storage to an elm-pages app

Adding storage to an elm-pages app

In this guide, we will show how to use Fission webnative storage to save user data. Our goal will be to add annotations to blog posts in the elm-pages-starter.
All of the code in this guide is available in the fission-elm-pages-starter repository on the storage branch.
When a user has signed in with Fission, an annotation button is displayed at the bottom right corner of the page.
Clicking the button opens a text input box where the user can write their notes.
If a user has not signed in, they do not see the annotation button or text input box.
Annotations are saved to the user's webnative filesystem and will be there when they return or use the app on another device.

Write an annotation module

We will start off by writing an annotation module. Annotation.elm is built around an Annotation type and has a way to create, encode, and view annotations.
An Annotation can be editable or not. If an annotation is editable, it has a title and notes.
1
type Annotation
2
= Editable Internals
3
| NotEditing
4
​
5
​
6
type alias Internals =
7
{ title : String
8
, notes : String
9
}
Copied!
We have two ways of creating an annotation. A NotEditing annotation can be created directly. An Editable annotation must be created from a Value that will come over a port.
1
notEditing : Annotation
2
notEditing =
3
NotEditing
4
​
5
​
6
fromValue : Value -> Annotation
7
fromValue val =
8
case Json.Decode.decodeValue internalsDecoder val of
9
Ok internals ->
10
Editable internals
11
​
12
Err _ ->
13
NotEditing
14
​
15
internalsDecoder : Decoder Internals
16
internalsDecoder =
17
Json.Decode.map2 Internals
18
(Json.Decode.field "title" Json.Decode.string)
19
(Json.Decode.field "notes" Json.Decode.string)
Copied!
Our code that interacts with the webnative filesystem will use blog post titles as keys to lookup and save annotations.
Add encode for storing annotations and encodeTitle for loading them.
1
encode : Annotation -> Json.Encode.Value
2
encode annotation =
3
case annotation of
4
Editable internals ->
5
Json.Encode.object
6
[ ( "title", Json.Encode.string internals.title )
7
, ( "notes", Json.Encode.string internals.notes )
8
]
9
​
10
NotEditing ->
11
Json.Encode.null
12
​
13
​
14
encodeTitle : String -> Json.Encode.Value
15
encodeTitle title =
16
Json.Encode.object
17
[ ( "title", Json.Encode.string title ) ]
Copied!
Lastly, we need a way to view annotations. When a user is editing an annotation, we show their notes or the blog post title as a placeholder. When an annotation is not being edited, we show a button with a pencil icon.
Add a view function with a case for Editable and NotEditing annotations.
1
view :
2
{ annotation : Annotation
3
, title : String
4
, onLoadAnnotation : String -> msg
5
, onUpdateAnnotation : Annotation -> msg
6
}
7
-> Element msg
8
view options =
9
case options.annotation of
10
Editable annotation ->
11
Element.row
12
[ Element.alignBottom
13
, Element.alignRight
14
, Element.padding 30
15
]
16
[ Element.Input.multiline
17
[ Element.height (Element.px 150)
18
, Element.width (Element.px 200)
19
, Border.width 2
20
, Border.roundEach { topLeft = 15, topRight = 15, bottomLeft = 15, bottomRight = 0 }
21
, Border.color (Element.rgb 0.5 0.5 0.5)
22
, Element.focused
23
[ Border.color (Element.rgb 0.3 0.3 0.3)
24
, Border.shadow
25
{ offset = ( 1, 1 )
26
, blur = 1
27
, color = Element.rgb 0.85 0.85 0.85
28
, size = 1
29
}
30
]
31
, Font.size 16
32
]
33
{ onChange =
34
\newText ->
35
options.onUpdateAnnotation
36
(Editable
37
{ title = options.title, notes = newText }
38
)
39
, text = annotation.notes
40
, placeholder =
41
Just
42
(Element.Input.placeholder []
43
(Element.text ("Write some notes on " ++ options.title))
44
)
45
, label = Element.Input.labelHidden ("Notes on " ++ options.title)
46
, spellcheck = False
47
}
48
]
49
​
50
NotEditing ->
51
Element.row
52
[ Element.alignBottom
53
, Element.alignRight
54
, Element.padding 30
55
]
56
[ Element.Input.button []
57
{ onPress = Just (options.onLoadAnnotation options.title)
58
, label =
59
Element.row
60
[ Element.padding 8
61
, Border.width 2
62
, Border.rounded 50
63
, Border.color (Element.rgb 0.3 0.3 0.3)
64
]
65
[ Element.image
66
[ Element.width (Element.px 30)
67
]
68
{ src = ImagePath.toString Pages.images.annotation
69
, description = "Annotate with Fission"
70
}
71
]
72
}
73
]
Copied!

Add annotations

Now that our annotation module is ready, we can add annotations to the elm-pages-starter.
We want the user to see the annotation button when they navigate to a page with a blog post. When they click on the annotation button, we load the annotation from their webnative filesystem or an empty annotation if they are starting a new annotation. We replace the button with the notes in a text input box.
We save the annotation to webnative with each keystroke the user makes in the text input box.
Import the annotation module in Main.elm.
1
import Annotation exposing (Annotation)
Copied!
Add an annotation to the Model.
1
type alias Model =
2
{ username : Maybe String
3
, annotation : Annotation
4
}
Copied!
We only display one annotation at a time, which means we can replace annotation when we navigate to a new page or load an annotation.
The annotation starts off as NotEditing in init.
1
init : ( Model, Cmd Msg )
2
init =
3
( { username = Nothing
4
, annotation = Annotation.notEditing
5
}
6
, Cmd.none
7
)
Copied!
Our update will handle four messages that initialize, load, or store annotations. OnPageChange sets the annotation to NotEditing when a user navigates to a new page. LoadAnnotation calls over a port to load an annotation. UpdateAnnotation updates the displayed annotation in the input box and stores the annotation. GotAnnotatation subscribes to annotations that come in over a port after a load.
1
type Msg
2
= SubmittedLogin
3
| GotAuth (Maybe String)
4
| OnPageChange
5
{ path : PagePath Pages.PathKey
6
, query : Maybe String
7
, fragment : Maybe String
8
}
9
| LoadAnnotation String
10
| UpdateAnnotation Annotation
11
| GotAnnotation Annotation
12
​
13
​
14
update : Msg -> Model -> ( Model, Cmd Msg )
15
update msg model =
16
case msg of
17
SubmittedLogin ->
18
( model
19
, login ()
20
)
21
​
22
GotAuth maybeUsername ->
23
( { model | username = maybeUsername }
24
, Cmd.none
25
)
26
​
27
OnPageChange _ ->
28
( { model | annotation = Annotation.notEditing }
29
, Cmd.none
30
)
31
​
32
LoadAnnotation title ->
33
( model
34
, loadAnnotation (Annotation.encodeTitle title)
35
)
36
​
37
UpdateAnnotation annotation ->
38
( { model | annotation = annotation }
39
, storeAnnotation (Annotation.encode annotation)
40
)
41
​
42
GotAnnotation annotation ->
43
( { model | annotation = annotation }
44
, Cmd.none
45
)
Copied!
Our main function needs a small change to configure the OnPageChange message. Set the onPageChange field in the record passed to Pages.Platform.init to Just OnPageChange.
Add loadAnnotation, onFissionAnnotation, and storeAnnotation ports.
1
port loadAnnotation : Json.Decode.Value -> Cmd msg
2
​
3
​
4
port onFissionAnnotation : (Json.Decode.Value -> msg) -> Sub msg
5
​
6
​
7
port storeAnnotation : Json.Decode.Value -> Cmd msg
Copied!
Add a subscription to the onFissionAnnotation port.
1
subscriptions : Model -> Sub Msg
2
subscriptions _ =
3
Sub.batch
4
[ onFissionAuth
5
(\val ->
6
Json.Decode.decodeValue authDecoder val
7
|> Result.toMaybe
8
|> GotAuth
9
)
10
, onFissionAnnotation
11
(\val ->
12
Annotation.fromValue val
13
|> GotAnnotation
14
)
15
]
Copied!
We create an annotation from the Value received over onFissionAnnotation and use GotAnnotation to update our model with it.
Pass the annotation and two messages to tell it how to load and update annotations to Layout.view.
1
Layout.view
2
(pageView model siteMetadata page viewForPage)
3
page
4
{ loginMsg = SubmittedLogin, username = model.username }
5
{ annotation = model.annotation
6
, onLoadAnnotation = LoadAnnotation
7
, onUpdateAnnotation = UpdateAnnotation
8
}
Copied!
In Layout.elm, the view displays the annotation at the bottom right corner and in front of the rest of the page. We check if the user is logged in and viewing a blog post. If both are true, we show them the annotation.
1
view :
2
{ title : String, body : List (Element msg) }
3
->
4
{ path : PagePath Pages.PathKey
5
, frontmatter : Metadata
6
}
7
->
8
{ loginMsg : msg
9
, username : Maybe String
10
}
11
->
12
{ annotation : Annotation
13
, onLoadAnnotation : String -> msg
14
, onUpdateAnnotation : Annotation -> msg
15
}
16
-> { title : String, body : Html msg }
17
view document page fissionAuth annotationOptions =
18
{ title = document.title
19
, body =
20
Element.column
21
[ Element.width Element.fill
22
, Element.height Element.fill
23
​
24
-- Maybe show the annotation
25
, Element.inFront <|
26
case fissionAuth.username of
27
Just username ->
28
case page.frontmatter of
29
Metadata.Article metadata ->
30
Annotation.view
31
{ annotation = annotationOptions.annotation
32
, title = metadata.title
33
, onLoadAnnotation = annotationOptions.onLoadAnnotation
34
, onUpdateAnnotation = annotationOptions.onUpdateAnnotation
35
}
36
​
37
_ ->
38
Element.none
39
​
40
Nothing ->
41
Element.none
42
]
43
[ header page.path fissionAuth
44
, Element.column
45
​
46
[ Element.padding 30
47
, Element.spacing 40
48
, Element.Region.mainContent
49
, Element.width (Element.fill |> Element.maximum 800)
50
, Element.centerX
51
]
52
document.body
53
]
54
|> Element.layout
55
[ Element.width Element.fill
56
, Font.size 20
57
, Font.family [ Font.typeface "Roboto" ]
58
, Font.color (Element.rgba255 0 0 0 0.8)
59
]
60
}
Copied!

Add webnative storage

Our app is ready for annotations and the last step is adding webnative storage.
Each Fission user has a filesystem that is stored locally and distributed across the web. An app that uses Fission storage asks the user for permission to use their filesystem -- similar to how a native mobile app asks for permission. After the app has been granted permission, it can store user data to their local filesystem and publish it to the distributed filesystem.
Let's start by installing the webnative package from npm.
1
npm install webnative
Copied!
Import it in index.js.
1
import * as webnative from 'webnative';
Copied!
We initialize webnative with a list of permissions stating what our app would like to use. In our case, we only need to request permission to use the storage associated with our app.
Shared storage: The webnative filesystem also has public and private shared storage that can be accessed across apps. See the webnative paths documentation for more details.
Declare an fs variable that will be used to access the user's filesystem. Add fissionInit with a request for permissions to use app storage by app name and your Fission username.
1
let fs;
2
​
3
const fissionInit = {
4
permissions: {
5
app: {
6
name: 'fission-elm-pages-starter',
7
creator: '<username>'
8
}
9
}
10
};
Copied!
Replace <username> with your Fission username.
Next, we initialize webnative. In the AuthSucceeded and Continuation cases, the user has authenticated and granted permission to use the filesystem. We can now set up the filesystem and a way to load and store annotations.
1
pagesInit({
2
mainElmModule: Elm.Main
3
}).then(app => {
4
webnative.initialize(fissionInit).then(async state => {
5
switch (state.scenario) {
6
case webnative.Scenario.AuthSucceeded:
7
case webnative.Scenario.Continuation:
8
app.ports.onFissionAuth.send({ username: state.username });
9
​
10
// [1] Alias the filesystem from state
11
fs = state.fs;
12
​
13
// [2] Create the app directory if it does not exist
14
const appPath = fs.appPath();
15
const appDirectoryExists = await fs.exists(appPath);
16
​
17
if (!appDirectoryExists) {
18
await fs.mkdir(appPath);
19
await fs.publish();
20
}
21
​
22
// [3] Load an annotation or send an empty one
23
app.ports.loadAnnotation.subscribe(async ({ title }) => {
24
const path = fs.appPath(['annotations', `${title}.json`]);
25
if (await fs.exists(path)) {
26
const annotation = JSON.parse(await fs.read(path));
27
app.ports.onFissionAnnotation.send({
28
title: annotation.title,
29
notes: annotation.notes
30
});
31
} else {
32
app.ports.onFissionAnnotation.send({
33
title,
34
notes: ''
35
});
36
}
37
});
38
​
39
// [4] Store and publish an annotation to the filesystem
40
app.ports.storeAnnotation.subscribe(async annotation => {
41
if (annotation !== null) {
42
const path = fs.appPath([
43
'annotations',
44
`${annotation.title}.json`
45
]);
46
await transaction(fs.write, path, JSON.stringify(annotation));
47
}
48
});
49
break;
50
​
51
case webnative.Scenario.NotAuthorised:
52
case webnative.Scenario.AuthCancelled:
53
break;
54
}
55
​
56
app.ports.login.subscribe(() => {
57
webnative.redirectToLobby(state.permissions);
58
});
59
});
60
});
Copied!
Annotations are stored in app storage in an annotations subdirectory as JSON. Each annotation is stored as a file using the blog post title and a .json suffix. For example, an annotation on the blog post "Hello Galaxy 🌠" would be stored on the path annotations/Hello Galaxy 🌠.json. Paths are case sensitive, spaces are fine, and emoji are welcomed!
Paths are built using fs.appPath which takes an array of strings that are parts of a path separated by forward slashes.
The numbered references in the code go as follows:
    1.
    Alias the filesystem from state. We alias state.fs as fs to put it onto the global scope.
    2.
    Create the app directory if it does not exist. A new user will not have a directory for our app. Check if the directory exists and make one if not with fs.mkdir. Each time we make a change to the local filesystem, we fs.publish to synchronize with the distributed filesystem.
    3.
    Load an annotation or send an empty one. When the Elm app sends a message over the loadAnnotation port, we make check if a file for the annotation exists. If it does, we fs.read and send it over the onFissionAuth port into the Elm app. If not, we send an empty annotation value.
    4.
    Store and publish an annotation to the filesystem. When the Elm app sends a message over the storeAnnotation port, webnative saves the annotation to the filesystem with fs.write. The current implementation uses a transaction queue to handle writes that come in quick succession. See the transaction queue implementation to examine how this works now. An upcoming version of webnative will handle writes in parallel, and the transaction queue will not be needed.

Write some notes! πŸ“οΈ

Everything is in place and users can write their notes about blog posts! Annotations persist across visits to the blog and are available on any device where the user has logged in with Fission.
We haven't covered shared storage in this guide, but you can do much more when user data is not restricted to a single app. With shared private storage, you could write an app that shows users their annotations from multiple apps across the web. With shared public storage, you could convert annotations into a publicly visible comments!
For more information on how Fission storage works, take a look at:
Last modified 2mo ago