DEV Community

Cover image for How to build a Trello Clone App with Vue.js [ Series - Portfolio Apps ]
Sith Norvang
Sith Norvang

Posted on

12 1 1 1 1

How to build a Trello Clone App with Vue.js [ Series - Portfolio Apps ]

This third episode of "Portfolio Apps" series is dedicated to build a Trello Clone. Classic right ? I propose one softer tutorial than you can find on Vue Mastery. I hope you will enjoy it !

1.0 / Setup

2.0 / Components & Router

[ 1.1 ] Install Vue 3

# Install latest stable of Vue

yarn global add @vue/cli
Enter fullscreen mode Exit fullscreen mode

[ 1.2 ] Creating a new project

This time, let's manually select features for this new Vue application.

# run this command

vue create trello-clone
Enter fullscreen mode Exit fullscreen mode

Do you remember previous tutorial about "Shopping Cart App" ? We saved a preset configuration. Let's use it !

I named it "config-portfolio".

Config with preset

Config done

[ 1.3 ] Vuex Configuration

As each tutorial, we are going to use Vuex. Let's dive in.

# ../store/index.js

import { createStore } from "vuex";

import rootMutations from "./mutations.js";
import rootActions from "./actions.js";
import rootGetters from "./getters.js";

const store = createStore({
  state() {
    return {
      overlay: false,
      lastListId: 3,
      lastCardId: 5,
      currentData: null,
      lists: [
        {
          id: 1,
          name: "list #1",
        },
        {
          id: 2,
          name: "list #2",
        },
        {
          id: 3,
          name: "list #3",
        },
      ],
      cards: [
        {
          listId: 1,
          id: 1,
          name: "card 1",
        },
        {
          listId: 2,
          id: 2,
          name: "card 2",
        },
        {
          listId: 3,
          id: 3,
          name: "card 3",
        },
      ],
    };
  },
  mutations: rootMutations,
  actions: rootActions,
  getters: rootGetters,
});

export default store;

Enter fullscreen mode Exit fullscreen mode

We need creating 6 actions & mutations.

# ../store/actions.js

export default {
  createList(context, payload) {
    context.commit("createNewList", payload);
  },
  createCard(context, payload) {
    context.commit("createNewCard", payload);
  },
  toggleOverlay(context) {
    context.commit("toggleOverlay");
  },
  openForm(context, payload) {
    context.commit("openForm", payload);
  },
  saveCard(context, payload) {
    context.commit("saveCard", payload);
  },
  deleteCard(context, payload) {
    context.commit("deleteCard", payload);
  },
};

Enter fullscreen mode Exit fullscreen mode
# ../store/mutations.js

export default {
  createNewList(state, payload) {
    state.lastListId++;
    const list = {
      id: state.lastListId,
      name: payload,
    };
    state.lists.push(list);
  },
  createNewCard(state, payload) {
    state.lastCardId++;
    const card = {
      listId: payload.listId,
      id: this.lastCardId,
      name: payload.name,
    };
    state.cards.push(card);
  },
  toggleOverlay(state) {
    state.overlay = !state.overlay;
  },
  openForm(state, payload) {
    state.currentData = payload;
  },
  saveCard(state, payload) {
    state.cards = state.cards.map((card) => {
      if (card.id === payload.id) {
        return Object.assign({}, card, payload);
      }
      return card;
    });
  },
  deleteCard(state, payload) {
    const indexToDelete = state.cards
      .map((card) => card.id)
      .indexOf(payload.id);
    state.cards.splice(indexToDelete, 1);
  },
};

Enter fullscreen mode Exit fullscreen mode

To complete our store configuration, let's initialize 6 getters.

export default {
  lastListId(state) {
    return state.lastListId;
  },
  lastCardId(state) {
    return state.lastCardId;
  },
  lists(state) {
    return state.lists;
  },
  cards(state) {
    return state.cards;
  },
  overlay(state) {
    return state.overlay;
  },
  currentData(state) {
    return state.currentData;
  },
};

Enter fullscreen mode Exit fullscreen mode

Great ! Our Vuex configuration is over now 👍

[ 1.4 ] App.vue & Main.js

Before create our components, we need to change some detail in App.vue and main.js files :

# ../App.vue

<template>
  <router-view />
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

body {
  margin: 0;
  overflow: hidden;
}

input {
  border: none;
  font-size: 15px;
  outline: none;
}
</style>

Enter fullscreen mode Exit fullscreen mode
# ../main.js

import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store/index.js";

const app = createApp(App);

app.use(router);
app.use(store);
app.mount("#app");

Enter fullscreen mode Exit fullscreen mode

Well configuration files is over ! Next step ? Creating all components.

[ 2.1 ] Components

This Trello Clone needs four components :

components
|-- Card.vue
|-- CardList.vue
|-- Overlay.vue
|-- Popup.vue

Enter fullscreen mode Exit fullscreen mode
# ../components/Card.vue

<template>
  <input
    class="input-card"
    type="text"
    placeholder="Create a Card"
    v-model="cardName"
    @keyup.enter="createCard"
  />
</template>

<script>
export default {
  props: ["listId"],
  methods: {
    createCard() {
      if (this.cardName !== "") {
        const card = {
          listId: this.listId,
          name: this.cardName,
        };
        this.$store.dispatch("createCard", card);
        this.cardName = "";
      }
    },
  },
};
</script>

<style>
.input-card {
  position: relative;
  background-color: white;
  min-height: 30px;
  width: 100%;
  display: flex;
  align-items: center;
  border-radius: 5px;
  padding: 10px;
  word-break: break-all;
  font-size: 16px;
}
</style>

Enter fullscreen mode Exit fullscreen mode

About CardsList.vue component, we need installing a new dependency which allow us using "drag and drop" easily.

https://github.com/anish2690/vue-draggable-next

npm install vue-draggable-next

# or

yarn add vue-draggable-next
Enter fullscreen mode Exit fullscreen mode
# ../components/CardsList.vue

<template>
  <draggable :options="{ group: 'cards' }" group="cards" ghostClass="ghost">
    <span
      class="element-card"
      v-for="(card, index) in cards"
      :key="index"
      @click="togglePopup(card)"
    >
      {{ card.name }}
    </span>
  </draggable>
</template>

<script>
import { VueDraggableNext } from "vue-draggable-next";

export default {
  props: ["listId", "listName"],
  components: {
    draggable: VueDraggableNext,
  },
  methods: {
    togglePopup(data) {
      const currentData = {
        listId: this.listId,
        listName: this.listName,
        id: data.id,
        name: data.name,
      };
      this.$store.dispatch("toggleOverlay");
      this.$store.dispatch("openForm", currentData);
    },
  },
  computed: {
    cards() {
      const cardFilteredByListId = this.$store.getters["cards"];
      return cardFilteredByListId.filter((card) => {
        if (card.listId === this.listId) {
          return true;
        } else {
          return false;
        }
      });
    },
    overlayIsActive() {
      return this.$store.getters["overlay"];
    },
  },
};
</script>

<style>
.element-card {
  position: relative;
  background-color: white;
  height: auto;
  display: flex;
  align-items: center;
  padding: 10px;
  border-radius: 5px;
  min-height: 30px;
  margin-bottom: 10px;
  word-break: break-all;
  text-align: left;
}
</style>

Enter fullscreen mode Exit fullscreen mode
# ../components/Overlay.vue

<template>
  <transition>
    <div v-if="overlayIsActive" class="overlay" @click="closeOverlay"></div>
  </transition>
</template>

<script>
export default {
  methods: {
    closeOverlay() {
      this.$store.dispatch("toggleOverlay");
    },
  },
  computed: {
    overlayIsActive() {
      return this.$store.getters["overlay"];
    },
  },
};
</script>

<style>
.overlay {
  background-color: rgba(0, 0, 0, 0.5);
  position: absolute;
  height: 100%;
  width: 100%;
  z-index: 500;
}

.v-enter-from {
  opacity: 0;
}

.v-enter-active {
  transition: all 0.3s ease-out;
}

.v-enter-to {
  opacity: 1;
}

.v-leave-from {
  opacity: 1;
}

.v-leave-active {
  transition: all 0.3s ease-in;
}

.v-leave-to {
  opacity: 0;
}
</style>

Enter fullscreen mode Exit fullscreen mode
# ../components/Popup.vue

<template>
  <transition>
    <div v-if="overlay" class="modal">
      <h1>List Name : {{ currentData.listName }}</h1>
      <input :placeholder="currentData.name" v-model="cardName" />
      <div class="container-button">
        <button class="blue" @click="saveElement">save</button>
        <button class="red" @click="deleteElement">delete</button>
      </div>
    </div>
  </transition>
</template>

<script>
import { mapGetters } from "vuex";

export default {
  data() {
    return {
      cardName: null,
    };
  },
  computed: {
    ...mapGetters(["overlay", "currentData"]),
  },
  methods: {
    saveElement() {
      if (this.cardName === null) {
        this.cardName = this.currentData.name;
      }
      const card = {
        listId: this.currentData.listId,
        id: this.currentData.id,
        name: this.cardName,
      };
      this.$store.dispatch("saveCard", card);
      this.cardName = null;
      this.$store.dispatch("toggleOverlay");
    },
    deleteElement() {
      this.$store.dispatch("deleteCard", this.currentData);
      this.$store.dispatch("toggleOverlay");
    },
  },
};
</script>

<style scoped>
.v-enter-from {
  opacity: 0;
}

.v-enter-active {
  transition: all 0.3s ease-out;
}

.v-enter-to {
  opacity: 1;
}

.v-leave-from {
  opacity: 1;
}

.v-leave-active {
  transition: all 0.3s ease-in;
}

.v-leave-to {
  opacity: 0;
}

.modal {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 20px;
  position: absolute;
  height: 500px;
  width: 500px;
  border-radius: 10px;
  background-color: rgba(235, 236, 240, 1);
  z-index: 550;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

input {
  width: 250px;
  height: 50px;
  padding: 10px 20px 10px 20px;
  border: 1px solid rgba(60, 60, 60, 0.2);
  border-radius: 15px;
}

button {
  display: flex;
  border: none;
  color: white;
  padding: 15px 32px;
  text-align: center;
  text-decoration: none;
  display: inline-block;
  font-size: 16px;
  border-radius: 15px;
  cursor: pointer;
  transition-duration: 0.4s;
}

button:hover {
  color: white;
}

.blue {
  background-color: rgba(1, 100, 255, 1);
}

.blue:hover {
  background-color: rgba(1, 100, 255, 0.8);
}

.red {
  background-color: rgba(250, 52, 75, 1);
}
.red:hover {
  background-color: rgba(250, 52, 75, 0.8);
}

.container-button {
  display: flex;
  flex-direction: row;
  gap: 30px;
}
</style>

```



Perfect ! Only one last step and we will be able to use this Trello Clone.

## [ 2.2 ] View & Router <a name="view-router"></a>

Let's import all components needs in "Board.vue" view.



```js
# ../views/Board.vue

<template>
  <main class="list-container">
    <Overlay />
    <Popup />
    <section class="list-wrapper">
      <draggable
        :options="{ group: 'lists' }"
        group="lists"
        ghostClass="ghost"
        class="list-draggable"
      >
        <div class="list-card" v-for="(list, index) in lists" :key="index">
          <label class="list-header">{{ list.name }}</label>
          <div class="list-content">
            <CardsList :listId="list.id" :listName="list.name" />
          </div>
          <div class="list-footer">
            <Card :listId="list.id" />
          </div>
        </div>
      </draggable>
      <input
        type="text"
        class="input-new-list"
        placeholder="Create a List"
        v-model="listName"
        @keyup.enter="createList"
      />
    </section>
  </main>
</template>

<script>
import { VueDraggableNext } from "vue-draggable-next";
import CardsList from "@/components/CardsList";
import Card from "@/components/Card.vue";
import Overlay from "@/components/Overlay";
import Popup from "@/components/Popup";

export default {
  components: {
    draggable: VueDraggableNext,
    CardsList,
    Card,
    Overlay,
    Popup,
  },
  data() {
    return {
      listName: "",
    };
  },
  methods: {
    createList() {
      if (this.listName !== "") {
        this.$store.dispatch("createList", this.listName);
        this.listName = "";
      }
    },
  },
  computed: {
    lists() {
      return this.$store.getters["lists"];
    },
  },
};
</script>

<style>
.list-container {
  position: relative;
  display: flex;
  width: 100vw;
  height: 100vh;
  border: 1px;
  z-index: 10;
}

.list-wrapper {
  position: relative;
  display: flex;
  flex-direction: row;
  box-sizing: border-box;
  min-width: 100vw;
  height: 100vh;
  padding: 20px;
  background-repeat: no-repeat;
  background-attachment: fixed;
  background-position: center;
  background-size: cover;
  background-image: url("../assets/background-image.jpg");
  gap: 20px;
  overflow-x: scroll;
  overflow-y: hidden;
}

.ghost {
  opacity: 0.5;
}

.list-draggable {
  display: flex;
  gap: 20px;
}

.input-new-list {
  display: flex;
  height: 30px;
  padding: 10px;
  border-radius: 5px;
  background-color: rgba(235, 236, 240, 0.5);
  min-width: 260px;
}

.input-new-list::placeholder {
  color: white;
}

.list-card {
  position: relative;
  display: flex;
  flex-direction: column;
  min-width: 300px;
  height: auto;
}

.list-header {
  position: relative;
  display: flex;
  justify-content: center;
  word-break: break-all;
  align-items: center;
  min-width: 280px;
  max-width: 280px;
  line-height: 50px;
  padding: 0px 10px 0px 10px;
  background-color: rgba(235, 236, 240, 1);
  border-radius: 10px 10px 0px 0px;
  color: rgba(24, 43, 77, 1);
  user-select: none;
}

.list-content {
  overflow-y: scroll;
  position: relative;
  display: flex;
  flex-direction: column;
  min-width: 280px;
  max-width: 280px;
  height: auto;
  background-color: rgba(235, 236, 240, 1);
  padding: 0px 10px 0px 10px;
  box-shadow: 1.5px 1.5px 1.5px 0.1px rgba(255, 255, 255, 0.1);
  color: rgba(24, 43, 77, 1);
}

.list-footer {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 280px;
  background-color: rgba(235, 236, 240, 1);
  border-radius: 0px 0px 10px 10px;
  color: white;
  border-top: 0.5px solid rgba(255, 255, 255, 0.25);
  padding: 0px 10px 10px 10px;
}
</style>

```



About the background, you just download a image on https://unsplash.com/ and import your file and rename it as following :



Enter fullscreen mode Exit fullscreen mode

assets
|-- background-image.jpg






```js
# ../router/index.js

import { createRouter, createWebHistory } from "vue-router";
import Board from "../views/Board.vue";

const routes = [
  {
    path: "/",
    name: "Board",
    component: Board,
  },
];

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes,
});

export default router;

Enter fullscreen mode Exit fullscreen mode

It's done ! Want to check result ? You can run "yarn serve / npm run serve" in your terminal or just click on link below.

https://trello-clone-sith.netlify.app/

See you in the next episode 😉

Trello Clone

AWS Security LIVE!

Tune in for AWS Security LIVE!

Join AWS Security LIVE! for expert insights and actionable tips to protect your organization and keep security teams prepared.

Learn More

Top comments (1)

Collapse
 
matiusnugroho profile image
Math

How to update the listId of card when dragged to another list?

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs