How we created and abstracted a social feature with Vulcan.js

Mariequittelier
Live for good
Published in
14 min readFeb 19, 2020

At Live for Good, we support social and positive entrepreneurs to develop their social business project through training and a digital platform. Some time ago, we launched a new feature allowing entrepreneurs to express their needs inside the community. The purpose of this feature was:

  • to know what our entrepreneur need
  • to allow the community (team members, corporates and coaches) to answer those needs individually
  • to help Live for Good plan well-suited workshops

The module was really well received by the our users. Even so, our amazing pool of sponsor users told us that the module lacked some interactivity. Thanks to a brainstorm with them, we decided to add a social feature to the module. This new feature would let the entrepreneurs like the “needs” of other. This like function is a way to “gather around” a need. At the end, it will be looking like this:

Our module finalized with the social button.
Our module finalized with the social button

STEP#1: make the functionality working

The Live for Good platform was developed using Vulcan.js:

Vulcan is a framework that gives you a set of tools for quickly building React & GraphQL-based web applications. Out of the box, it can handle data loading, automatically generate forms, handle email notifications, and much more.

Source: Vulcan.js

Vulcan.js uses a logic of packages. We decided, as a first draft of this new feature, to insert it inside a first pre-existing package.

Vulcan.js

1. Create the field in the schema

We started by adding an array to our schema to store the data. So, in each document representing the need of an entrepreneur, we were going to write a list of the other entrepreneurs who “gather around” this same need.

Vulcan.js requires you to specify what he is supposed to expect inside this array. As we were going to add users’ Ids, we specified that he was supposed to expect strings.

Now that we have a list of Ids, we wanted to link to the users collection to access this info. So to create a connexion between the two collection, we decided to use a field resolver. That would give the client access to an object with all the user informations linked with the id:

//schema.jsentrepreneursInNeedIdList: {
type: Array,
optional: true,
canRead: ["members"],
canCreate: ["members"],
canUpdate: ["members"],
hidden: true,
resolveAs: {
fieldName: "entrepreneursInNeedList",
type: "[User]",
resolver: async (entrepreneurneed, args, { currentUser, Users }) => {
if (entrepreneurneed.entrepreneursInNeedIdList) {
const entrepreneursInNeedList = await Users.loader.loadMany(
entrepreneurneed.entrepreneursInNeedIdList
);
return Users.restrictViewableFields(
currentUser,
Users,
entrepreneursInNeedList
);
} else {
return [];
}
},
addOriginalField: true,
},
"entrepreneursInNeedIdList.$": {
type: String,
}, },

You may have notice that when loading our users, we are using aloader property. This is to improve performances when calling our database. To learn more, you can checkout this video.

2. Create the front-end button

Now, we need to create a button that will modify our array in the database. In plain English, when the user is going to click on the button, he will like or unlike the need. Meaning, the button is going to change the array of ids in our database every time it’s clicked.

At first, we created a button that was changing according to a status. The status indicates if an entrepreneur already liked a need or not. First, we created a hook to do so. The initial state of the hook will vary if the id of the entrepreneur is included or not in the array we set earlier.

//EntrepreneurNeedsEntrepreneursCard.jsx (i.e. our front end component)const AddAnEntrepreneurInNeed = props => {
const { document, currentUser, refetch } = props;
const currentUserId = _get(currentUser, "_id");
const ownDocument = currentUserId === _get(document, "userId");
const [isAddingUp, setIsAddingUp] = useState(
document && document.entrepreneursInNeedIdList
? !document.entrepreneursInNeedIdList.includes(currentUserId)
: null
)
return (<Fab>
{!ownDocument ?
{isAddingUp ?
<ThumbUpAltOutlinedIcon/> :
<ThumbUpAltRoundedIcon/>}
: null}
</Fab>
);
};

For now, our button exists, but it’s not clickable.

3. Create the resolver to mutate the database

Making it clickable means we creating a GraphQL query that will do a mutation of the database. That’s why we are going to use a resolver inside the handleClick:

A resolver is the function on the server that receives a GraphQL query, decides what to do with it (how to resolve it), and then returns some data.

Source: Vulcan.js

Basically, we will do this:

Basic schema of a query in Vulcan.js

In the server folder, we are going to create a file called resolvers.js and specify our mutation:

//resolvers.jsimport {addGraphQLMutation, } from "meteor/vulcan:core";addGraphQLMutation(
"addOrDeleteParticipantToSOS(documentId: String, currentUserId: String, isAddingUp:Boolean) : JSON"
);
const addOrDeleteParticipantResolver = {
Mutation: {
addOrDeleteParticipantToSOS(
root,
{ documentId, currentUserId, isAddingUp },
context
) {
return isAddingUp
? context.EntrepreneurNeeds.update(
{ _id: documentId },
{
$push: {
entrepreneursInNeedIdList: currentUserId,
},
}
)
: context.EntrepreneurNeeds.update(
{ _id: documentId },
{
$pull: {
entrepreneursInNeedIdList: currentUserId,
},
}
);
},
},
};
addGraphQLResolvers(addOrDeleteParticipantResolver);

As you can see the resolver doing the mutation of the database is called addOrDeleteParticipantToSOS. However, our button is not calling the mutation yet:

//EntrepreneurNeedsEntrepreneursCard.jsx (i.e. our front end component)const AddAnEntrepreneurInNeed = props => {
const { document, currentUser, refetch } = props;
const currentUserId = _get(currentUser, "_id");
const documentId = _get(document, "_id");
const [isAddingUp, setIsAddingUp] = useState(
document && document.entrepreneursInNeedIdList
? !document.entrepreneursInNeedIdList.includes(currentUserId)
: null
);
const handleClick = () => () => {
props
.addOrDeleteParticipantToSOS({
documentId,
currentUserId,
isAddingUp
})
.then(() => {
setIsAddingUp(!isAddingUp);
refetch();
})
.catch(err => {
alert(err);
let error = new Error(["The SOS wasn't sent"]);
error.break = true;
throw error;
});
};
return ( <Fab onClick ={handleClick()}>(...) </Fab>);
};

Two things that worth notice:

  • the handleClick function is like this: const handleClick = () = () => {}. This way it will not be triggered every time our component renders but only when it’s called. Also, it allows you to pass props to the function.
  • the parameters of the mutation (inside the resolvers.js) and of the function handleClick have to be the same.

Now, our button can be clicked but the mutation will not be triggered. For that to be work, we need to call the mutation and give it the arguments it’s supposed to take. To do so, we are going to wrap the button in a HOC given by Vulcan.js:

The withMutation HoC provides an easy way to call a specific mutation on the server by letting you create ad-hoc mutation containers. It takes two options:

1/ name: the name of the mutation to call on the server (will also be the name of the prop passed to the component).

2/ args: (optional) an object containing the mutation’s arguments and types.

Source: Vulcan.js

//EntrepreneurNeedsEntrepreneursCard.jsx (i.e. our front end component)const addOrDeleteParticipantToSOSOptions = {
name: "addOrDeleteParticipantToSOS",
args: {
documentId: "String",
currentUserId: "String",
isAddingUp: "Boolean"
}
};
const EntrepreneurNeedsEntrepreneursCard = ({
element,
refetch,
currentUser
}) => {
const WrappedWithMutationButton = withMutation(
addOrDeleteParticipantToSOSOptions
)(AddAnEntrepreneurInNeed);
return (
<WrappedWithMutationButton
currentUser={currentUser}
document={element}
refetch={refetch} />
);
};

As you can see our mutation is taking 3 arguments:

  • documentId: to know which document to mutate.
  • currentUserId: to know which participant it concerns.
  • isAddingUp: to know if it’s pulling an Id from the array or pushing it.

Now, our button is working 🎉. The mutation is pulling or pushing the user Id inside the array of entrepreneurs in need according depending on whether they are already in the list or not.

STEP#2: abstract the functionality

The second step for us was to abstract the functionality for two main reasons:

  • As the platform was expanding, we already had found a second feature that could be improved by this new functionality
  • At Live for Good, we have at heart to make technology accessible for all. So, as users of different open source features, it was important for us to also contribute to it

Abstracting means trying to think about all the usage our functionality could have. But our strategy was to make it in an outside package that can be called with properties first.

1.Create a new Vulcan.js package

The first step will be to create a Vulcan.js package. Really basically, a vulcan package is going to be 4 folders (client, server, components, modules) inside a lib folder and a package.js.

The minimal interactions inside your package are going to be like in the following graph. In purple, you have the folders and in pink, the files.

Schema of a minimal Vulcan.js package
The minimal interactions inside a Vulcan.js package

Also, don’t forget to add your package in the packages file located inside the.meteor folder. If you want a better understanding of Vulcan.js, checkout their website and the starter.

2. Create the field in the schema

Now that our package is done, we have to make it do exactly what it was doing while it was in another package. The two tricky parts are:

  1. the collection is going to change every-time we call the package.
  2. a new field needs to be created every time the package is called inside a collection.

To solve our first problem, we need to create a function that will say which collection is concerned. We are going to call itsetUpSocialHandlerand create it inside the modules folder. Our function is going to get either a collection name or the collection itself and extract the information related from the database thanks to Vulcan.js’s extractCollectionInfo.

The second problem meant extending the schema of a given collection. And, lucky us, Vulcan.js has a function just for that: addField. In a schema field, there is a certain amount of parameters that you can customize. For the developper to be able to customize them, we passed the prop fieldSchema to our function. But, we also had to give the function a default value for all the props for the developer that doesn’t need anything custom.

As earlier, Vulcan.js is going to require us to specify which type of data is expected in the field. Also, as the fieldName can be custom or not, we will need to make sure we are giving the right one

//setUpSocialHandler.jsimport { extractCollectionInfo } from "meteor/vulcan:lib";
export const setUpSocialHandler = ({
collection: _collection,
collectionName: _collectionName,
fieldName = "socialUserIdList",
canUpdate = ["members"],
canRead = ["members"],
resolverName,
fieldSchema,
buttonName }) => {
const { collection, collectionName } = extractCollectionInfo({
collection: _collection,
collectionName: _collectionName,
});
const definitionArrayItem = fieldName + ".$";collection.addField([
{ fieldName: fieldName,
fieldSchema: {
type: Array,
optional: true,
defaultValue: [],
canRead: canRead,
canUpdate: canUpdate,
...fieldSchema,
},
},
{ fieldName: definitionArrayItem,
fieldSchema: { type: String },
},
]);
};

Also, if you remember doing the schema earlier, you know that we are missing access to the object behind the id. So, let’s do the resolveAs. A comparison of our two resolvers will look like this:

Code comparaison of the resolveAs

Now, let’s do something new. When we create a collection, it has a default fragment. As our new field is not in the default fragment, we need to include the new field inside. For that, we need to register a fragment:

A fragment is a piece of schema, usually used to define what data you want to query for.

source: Vulcan.js

// setUpSocialHandler.js(...) 
import { extendFragment } from "meteor/vulcan:core";
export const setUpSocialHandler = ({
(...)
}) => {
(...)
extendFragment(
getDefaultFragmentName(collection),
`
${fieldName}
`
);
};

Now that we are done, let’s compare the code on a more global level. On the left side, you can see the schema as it used to be inside another package and on the right side, you can see our function that is abstracting the schema.

Code comparaison of the schema pre abstraction and the setUpSocialHandler.js function

To explain it, and show similarities, we have drown lines of the same colors on the sides of each schemas:

  • blue lines: the fieldName.
  • pink lines: the fieldSchema. In other words, it’s going to be all the fields that will define your schema.
  • purple lines: the resolveAs.
  • orange line: the type of data expected in our array.

If I want to use my new feature in a new package, I will need to call setUpSocialHandler from the collection of the new package. In our case, my entrepreneur needs package (i.e. my old package) is going to call the function like this:

//entrepreneurNeedsCollection.jssetUpSocialHandler({
collection: EntrepreneurNeeds,
fieldName: "entrepreneursInNeedIdList",
resolveAsFieldName: "entrepreneursInNeedList",
});

3. Abstract the Resolver doing the GraphQL Mutation

Now, let’s create our resolver. Each time we are going to call our function setUpSocialHandler we are going to create a field. For each of this field, we will need a resolver. This is the objective of the function we are going to do here.

We needed to call our function with the properties needed to define our mutation. The objective is for our mutation to know what data has to be expected from the back-end.

//resolvers.jsexport const addSocialMutation = ({
fieldName,
collectionName,
resolverName,
}) => {

};

But, it currently gets no data. The documentId, fieldName and collectionName were defined in the setUpSocialHandler. That’s why we are going to pass them as properties to the mutation inside thesetUpSocialHandler.

// setUpSocialHandler.jsaddSocialMutation({
fieldName,
collectionName,
resolverName: _resolverName,
});

Then, we will create a custom mutation resolver using addGraphQLMutation and addGraphQLResolvers. The mutation uses the resolverName that was received in the parameters earlier. Then, you have to tell the mutation what’s the name and type of data it’s going to receive from the front-end.

//resolvers.jsimport { addGraphQLMutation, addGraphQLResolvers } from "meteor/vulcan:core";export const addSocialMutation = ({
fieldName,
collectionName,
resolverName,
}) => {
addGraphQLMutation(
`${resolverName}(documentId: String, currentUserId: String, isAddingUp:Boolean ) : JSON`
)
};

Our next step was to register the resolver and decide what it would do. As a quick reminder:

A resolver is the function on the server that receives a GraphQL query, decides what to do with it (how to resolve it), and then returns some data.

source: Vulcan.js

First, we are going to define the resolver and state what’s the data that this expected. Then, we can tell the resolver what it’s supposed to do with the back-end and the front-end data:

//resolvers.jsexport const addSocialMutation = ({
fieldName,
collectionName,
resolverName,
}) => {
(...)
const addOrDeleteParticipantResolver = {
Mutation: {
[resolverName](root, { documentId, currentUserId, isAddingUp }, context) {
return isAddingUp
? context[collectionName].update(
{ _id: documentId },
{
$push: {
[fieldName]: currentUserId,
},
}
)
: context[collectionName].update(
{ _id: documentId },
{
$pull: {
[fieldName]: currentUserId,
},
}
);
},
},
};
};

To sum up, our resolver will call our mutation named after the resolverName and receive our three properties from the front-end. Then, according to a boolean (isAddingUp), it will update the field of the document of the collection we choose with the documentId, fieldName and collectionName.

Now that our resolver know what to do, we just have to add it:

addGraphQLResolvers(addOrDeleteParticipantResolver)

4. Create the front-end button

To abstract the button, we started from our first button. From that first piece of code, the only part to modify was our entrepreneursInNeedIdList. Now, in our abstracted schema, the user can customize that fied by passing a fieldName. So our hook (isAdding) could not use it anymore, but needed to use the fieldName.

We had to add extra safety to the hook. Indeed, when the array was undefined, the browser was throwing an error. So, we had to check if the array was existing. Our hook became:

const [isAddingUp, setIsAddingUp] = useState(
document && document[fieldName] !== undefined
? !(document[fieldName] && document[fieldName].includes(currentUserId))
: null
);

The next step we did when we first made our button was to make it clickable. In other words, it was calling our resolver by its name in the handleClick. To make sure, the resolver will work, we need to make sure there is an existing currentUserId and documentId.

const handleClick = () => () => {
if (currentUserId && documentId) {
args[resolverName]({
documentId: documentId,
currentUserId: currentUserId,
isAddingUp: isAddingUp,
})
.then(() => {
setIsAddingUp(!isAddingUp);
})

.then(() => successCallback && successCallback())
.catch(err => {
alert(err);
let error = new Error(["The SOS wasn't sent"]);
error.break = true;
throw error;
});
};

On our MVP button, we used a refetch to update our data once the mutation was successful. On the abstracted button, we choose to pass two functions calledsuccessCallback and an errorCallback to give the user the opportunity to customize what he wants to be done according to the success or the failure of the mutation. Here is the comparative of our handleClick:

Code comparatif of the handleClick before and after abstraction

Now, the return of the Button itself will mostly stay the same as earlier. The only changes so far was that we put the inside the ternary and giving the opportunity to the user to pass it’s own button. So, if a ButtonAdd andButtonRemove props is passed, it will render the component passed. If there are no props, it’s the that will be passed.

return (
!ownDocument &&
(isAddingUp ? (
ButtonAdd ? (
<ButtonAdd handleClick()} />
) : ( <Fab onClick={() => handleClick()}>
<ThumbUpAltOutlinedIcon /> </Fab>)
) : ButtonRemove ? (
<ButtonRemove handleClick()} />
) : ( <Fab onClick={() => handleClick()}> <ThumbUpAltOutlinedIcon /> </Fab>
))
);

Here is the comparative of our button:

Code comparatif of the button before and after abstraction

Now, we are back at the step where the button exists, but it’s not working. We already created the mutation, we just have to wrap our button in it and specify the options of the mutations. The three changes compared to earlier is that:

  • we are going to register the component that is already access the mutation SocialButtonWithMutation.
  • the name of the component that is registered is a props that will be received from the back-end. That gives the opportunity to the user to customize the name if he wants. To do so, we registered the buttonName as a parameter received by our setUpSocialHandler function. If no parameters are passed, the _buttonName used to register the button is defined according to thefieldName. Just to be sure everyone is finding the button name, we added an extra console.log in the setUpSocialHandler.js.
//setUpSocialHandler.jsexport const setUpSocialHandler = ({
(...)
buttonName,
}) => {
const _buttonName = buttonName ? buttonName : `${fieldName}Button`;
(...)registerSocialButtonToDisplay({
resolverName: _resolverName,
_buttonName: _buttonName,
fieldName: fieldName,
});
}};
  • we need to get from the back-end the resolverName, thefieldName and _buttonName. The resolverName, will be used in the options given to the mutation to replace addOrDeleteParticipantToSOS.
// SocialButton.jsxexport const registerSocialButtonToDisplay = ({
resolverName,
_buttonName,
fieldName,
}) => {
export const SocialButton = ({
(...)
};
const mutationOptions = {
name: resolverName,
args: {
documentId: "String",
currentUserId: "String",
isAddingUp: "Boolean",
},
};
const SocialButtonWithMutation = withMutation(mutationOptions)(SocialButton);registerComponent({
name: _buttonName,
component: SocialButtonWithMutation,
});
};

Here is the code comparative:

Code comparatif of the mutation before and after abstraction

To finish, you just have to call the setUpSocialHandler function and give it either the collection name or the collection itself. To call your button in your front end component, you just have to use Component.YourButtonName and pass it as props the documentand the currentUserId. Now, our button is working 🎉

Another example of how it work can be:

//Collection.jssetUpSocialHandler({
collection: Meetings,
fieldName: "participantsIdList",
resolveAsFieldName: "participantsList",
});
You can double check in your console how your button is called
// Agenda.jsx (i.e. our front end component)   

<Components.participantsIdListButton
currentUserId={participantId}
document={document}
successCallback={_successCallback}
ButtonRemove={ButtonUnSignUp}
ButtonAdd={ButtonSignUp}
/>

As a quick recap, to use this button, you will need to:

  • call the setUpSocialHandler function in your collection. The only prop required to pass to this function is either the collection name or the collection itself. Once this is done, in your console, you will see the name of the button you have to call in your component,
  • call the button like this: Components.YourButtonName. The only required props are the documentand the currentUserId. Then, it’s your choice to customize the rest of the props or not. If you want to refetch your data, do not forget to include it inside your successCallback.

If you want to check the package more deeply, we have made it available here.

Our 44 entrepreneurs during the program
Our 43 entrepreneurs

Founded by Jean-Philippe Courtois and his family, Live for Good is an organization dedicated to unleash the capability of young people thanks to social entrepreneurship and to accelerate positive innovation. The organization supports young and positive entrepreneurs towards the development of their impacting project. The 43 entrepreneurs followed daily since August 2019, are offered a collaborative platform developed in the open source framework Vulcan.js.

--

--

Mariequittelier
Live for good

I’m a junior web developer 💻 using Javascript, React, Gatsbyjs & ReactNative. I also have interests in photography 📷 , travelling 🗺️ and the environment 🌎