🛹

Swapyでdashboardを作ろう

2024/08/06に公開

Swapyとは

Swapyとは下記に示した通り、ドラックでレイアウトを変更できるツールです。

英語

A simple JavaScript tool for converting any layout you have to drag-to-swap layout

日本語

あらゆるレイアウトをドラッグ・トゥ・スワップ・レイアウトに変換するシンプルなJavaScriptツール

https://swapy.tahazsh.com/

今回作成するもの

下記のGIF通りにコンテンツを自分で選択でき、尚且つ並べ替えができるレイアウトのダッシュボードを作成します。
また並べ替えた結果をlocal storageに保存することによって、次にページに訪れた際も自分が並べ替えた状態で描画されるようにします。

使用技術

  • "vue": "latest" (Nuxt3)
  • "@nuxtjs/tailwindcss": "^6.12.1"
  • "swapy": "^0.0.6"
  • "flowbite": "^2.4.1"
  • "vue3-apexcharts": "^1.5.3"
  • "@vueuse/core": "^10.11.0"
  • "@vueuse/nuxt": "^10.11.0"

環境構築

まずはNuxtのプロジェクトを作ります。
プロジェクトを作りたいディレクトリに移動して、下記コマンドを実行します。

pnpm dlx nuxi@latest init swapy-project

swapy-projectの部分は自分の好きなプロジェクト名でOKです

続いて、ライブラリをインストールしていきます。

tailwind

pnpm i -D @nuxtjs/tailwindcss

また、nuxt.config.tsのmodulesに追加します。

nuxt.config.ts
export default defineNuxtConfig({
  ...,
  modules: [
+    "@nuxtjs/tailwindcss",
  ],

+  tailwindcss: {
+    cssPath: ["~/assets/css/tailwind.css", { injectPosition: "first" }],
+    configPath: "tailwind.config",
+    exposeConfig: {
+      level: 2,
+    },
+    config: {},
+    viewer: true,
+  },
  ...
});

https://tailwindcss.nuxtjs.org/getting-started/installation

VueUse

pnpm i -D @vueuse/nuxt @vueuse/core

また、nuxt.config.tsのmodulesに追加します。

nuxt.config.ts
export default defineNuxtConfig({
  ...,
  modules: [
    "@nuxtjs/tailwindcss",
+   "@vueuse/nuxt"
  ],
  ...
});

https://vueuse.org/guide/#nuxt

swapy

pnpm install swapy

https://swapy.tahazsh.com/

apexcharts

pnpm i -D apexcharts vue3-apexcharts

またplugins/apexchart.client.tsファイルを作り、下記を記載します。

plugins/apexchart.client.ts
import VueApexCharts from "vue3-apexcharts";
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.use(VueApexCharts);
});

https://flowbite.com/docs/plugins/charts/#getting-started

flowbite

pnpm install flowbite

またcomposables/useFlowbite.tsファイルを作り、下記を記載します。

composables/useFlowbite.ts
export function useFlowbite(callback: any) {
  if (process.client) {
    import("flowbite").then((flowbite) => {
      callback(flowbite);
    });
  }
}

https://flowbite.com/

こちらで準備が整いましたので、実装に移っていきます。

実装

実装はサイドバーとヘッダーを除いたdashboard部分です。

pages/index.vue

コード詳細
index.vue
<script setup lang="ts">
import Card from "@/components/dashboard/Card.vue";
import { createSwapy } from "swapy";

definePageMeta({
  title: "HOME",
});

useHead({
  title: "HOME",
});

const isReady = ref(false);

type CardDataType = {
  title: string;
  content: string;
  dataSwapyItem: string;
};

// card data
const cardData = ref<Record<string, CardDataType>>({
  a: { title: "Card A", content: "A", dataSwapyItem: "a" },
  b: { title: "Card B", content: "B", dataSwapyItem: "b" },
  c: { title: "Card C", content: "C", dataSwapyItem: "c" },
  d: { title: "Card D", content: "D", dataSwapyItem: "d" },
  e: { title: "Card E", content: "E", dataSwapyItem: "e" },
  f: { title: "Card F", content: "F", dataSwapyItem: "f" },
  g: { title: "Card E", content: "G", dataSwapyItem: "g" },
  h: { title: "Card F", content: "H", dataSwapyItem: "h" },
});

type SlotItemType = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | null;

// default slot items
const DEFAULT: Record<string, SlotItemType> = {
  "1": "a",
  "2": "b",
  "3": "c",
  "4": "d",
  "5": "e",
  "6": "f",
  "7": "g",
  "8": "h",
};

// slot items
const slotItems = ref<Record<string, SlotItemType>>(DEFAULT);

// emit updateCardData event
const updateCardData = (dataSwapyItem: string, newContent: string) => {
  if (dataSwapyItem in cardData.value) {
    cardData.value[dataSwapyItem].content = newContent;
    localStorage.setItem("cardDataItems", JSON.stringify(cardData.value));
  }
};

// initialize slot items
onMounted(async () => {
  const slotItem = localStorage.getItem("slotItem");
  const cardDataItems = localStorage.getItem("cardDataItems");

  if (slotItem) {
    slotItems.value = JSON.parse(slotItem);
  }

  if (cardDataItems) {
    cardData.value = JSON.parse(cardDataItems);
  }

  isReady.value = true;

  await nextTick();

  const container = document.querySelector(".container");

  if (container) {
    try {
      const swapy = createSwapy(container, {
        animation: "dynamic",
      });

      if (!swapy) {
        throw new Error("Swapy instance is undefined");
      }

      swapy.onSwap(({ data }) => {
        if (data?.object) {
          localStorage.setItem("slotItem", JSON.stringify(data.object));
        }
      });
    } catch (error) {
      console.error("Error creating Swapy:", error);
    }
  } else {
    console.error("Container element not found");
  }
});

function getItemById(itemId: SlotItemType) {
  if (itemId && itemId in cardData.value) {
    return { component: Card, props: cardData.value[itemId] };
  }
  return { component: null, props: {} };
}

const slotClasses = {
  "1": "col-start-1 col-end-2 bg-gray-100",
  "2": "col-start-2 col-end-4 bg-slate-400",
  "3": "col-start-1 col-end-3 bg-slate-400",
  "4": "col-start-3 col-end-4 bg-gray-100",
  "5": "col-start-1 col-end-2 bg-gray-100",
  "6": "col-start-2 col-end-4 bg-slate-400",
  "7": "col-start-1 col-end-3 bg-slate-400",
  "8": "col-start-3 col-end-4 bg-gray-100",
};
</script>

<template>
  <div
    v-if="isReady"
    class="container relative z-10 grid h-full max-w-none grid-cols-3 grid-rows-[1fr_1fr_1fr] gap-5"
  >
    <div
      v-for="slotId in Object.keys(slotItems)"
      :key="slotId"
      :class="[
        'slot min-h-64 rounded-md border border-gray-200 bg-gray-300 dark:border-gray-700 dark:bg-gray-800',
        slotClasses[slotId as keyof typeof slotClasses],
      ]"
      :data-swapy-slot="slotId"
    >
      <component
        :is="getItemById(slotItems[slotId]).component"
        v-bind="getItemById(slotItems[slotId]).props"
        @updateCardData="updateCardData"
      />
    </div>
  </div>
</template>

components/dashboard/Card.vue

コード詳細
components/dashboard/Card.vue
<script setup lang="ts">
import { ref, watch } from "vue";
import Dropdown from "@/components/dashboard/Dropdown.vue";
import ColumnChart from "@/components/dashboard/ColumnChart.vue";
import News from "@/components/dashboard/News.vue";

const props = defineProps<{
  title: string;
  content: string;
  dataSwapyItem: string;
}>();

const emit = defineEmits<{
  (e: "updateCardData", dataSwapyItem: string, content: string): void;
}>();

const setContent = ref(props.content);

const updateContent = (content: string) => {
  setContent.value = content;
  emit("updateCardData", props.dataSwapyItem, content);
};

// watch for content changes
watch(
  () => props.content,
  (newContent) => {
    setContent.value = newContent;
  },
);
</script>

<template>
  <div
    class="item relative flex h-full w-full select-none items-center justify-center rounded-md bg-white text-4xl text-gray-700 dark:bg-slate-500 dark:text-gray-300"
    :data-swapy-item="props.dataSwapyItem"
  >
    <div
      class="handle absolute right-4 top-4 z-10 h-6 w-6 cursor-pointer"
      data-swapy-handle
    >
      <svg
        xmlns="http://www.w3.org/2000/svg"
        width="100%"
        height="100%"
        viewBox="0 0 24 24"
      >
        <path
          fill="none"
          stroke="currentColor"
          stroke-linecap="round"
          stroke-linejoin="round"
          stroke-width="1.5"
          d="M12 3v6m-9 3h6m12 0h-6m-3 9v-6.5M9 6l1.705-1.952C11.315 3.35 11.621 3 12 3c.38 0 .684.35 1.295 1.048L15 6m0 12l-1.705 1.952C12.685 20.65 12.379 21 12 21c-.38 0-.684-.35-1.295-1.048L9 18m9-9l1.952 1.705C20.65 11.315 21 11.621 21 12c0 .38-.35.684-1.048 1.295L18 15M6 15l-1.952-1.705C3.35 12.685 3 12.379 3 12c0-.38.35-.684 1.048-1.295L6 9"
          color="currentColor"
        />
      </svg>
    </div>
    <Dropdown @updateContent="updateContent" />
    <div
      v-if="setContent === 'API Usage'"
      class="item-card started relative h-full w-full px-1 pt-8"
    >
      <ColumnChart />
    </div>
    <div
      v-if="setContent === 'News'"
      class="relative h-full max-h-64 w-full overflow-scroll px-6 pb-6 pt-16"
    >
      <News />
    </div>
    <div v-if="setContent === 'Profile'">
      <div class="text-4xl">Profile</div>
    </div>
    <div
      v-if="
        setContent !== 'API Usage' &&
        setContent !== 'News' &&
        setContent !== 'Profile'
      "
      class="relative flex h-full min-h-52 w-full items-center justify-center overflow-hidden px-6 py-6"
    >
      <div class="text-4xl">{{ setContent }}</div>
    </div>
  </div>
</template>

components/dashboard/Dropdown.vue

コード詳細
components/dashboard/Dropdown.vue
<script setup lang="ts">
import { onClickOutside } from "@vueuse/core";

// open/close dropdown
const isOpen = ref(false);
const emit = defineEmits(["updateContent"]);
const toggleDropdown = () => {
  isOpen.value = !isOpen.value;
};

// emit event to parent for updating content
const updateContent = (content: string) => {
  emit("updateContent", content);
};

// outside click to close dropdown
const target = ref(null);
onClickOutside(target, (event) => (isOpen.value = false));

// select items
const selectItems = ref([
  { id: 1, name: "News" },
  { id: 2, name: "API Usage" },
  { id: 3, name: "Profile" },
]);
</script>

<template>
  <button
    id="multiLevelDropdownButton"
    data-dropdown-toggle="multi-dropdown"
    class="absolute right-12 top-4 z-10 rounded-lg border border-gray-200 bg-white px-2 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:outline-none focus:ring-4 focus:ring-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
    type="button"
    @click="toggleDropdown"
    ref="target"
  >
    <svg
      class="h-2 w-2"
      aria-hidden="true"
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 10 6"
    >
      <path
        stroke="currentColor"
        stroke-linecap="round"
        stroke-linejoin="round"
        stroke-width="2"
        d="m1 1 4 4 4-4"
      />
    </svg>
  </button>

  <!-- Dropdown menu -->
  <div
    id="multi-dropdown"
    class="absolute right-12 top-14 z-10 w-44 divide-y divide-gray-100 rounded-lg bg-white shadow dark:bg-gray-700"
    :class="isOpen ? 'block' : 'hidden'"
  >
    <ul
      class="py-2 text-sm text-gray-700 dark:text-gray-200"
      aria-labelledby="multiLevelDropdownButton"
    >
      <li
        v-for="items in selectItems"
        @click="updateContent(`${items.name}`)"
        :key="items.id"
      >
        <span
          class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
        >
          {{ items.name }}
        </span>
      </li>
    </ul>
  </div>
</template>

components/dashboard/ColumnChart.vue

コード詳細
components/dashboard/ColumnChart.vue
<script setup lang="ts">
// ref: https://stackblitz.com/edit/nuxt-starter-mmn2ed?file=nuxt.config.ts,app.vue,plugins%2Fapexchart.client.ts

type ChartOptions = {
  chart: {
    type: string;
  };
  plotOptions: {
    bar: {
      borderRadius: number;
      borderRadiusApplication: string;
    };
  };
  xaxis?: {
    categories: (string | number)[];
  };
};

type SeriesData = {
  name: string;
  data: number[];
};

const options = ref<ChartOptions>({
  chart: {
    type: "bar",
  },
  plotOptions: {
    bar: {
      borderRadius: 5,
      borderRadiusApplication: "around",
    },
  },
});

const series = ref<SeriesData[]>([
  {
    name: "Score",
    data: [],
  },
]);

const updateChart = () => {
  //generate array of random numbers of length 10
  const data = Array.from({ length: 10 }, () =>
    Math.floor(Math.random() * 100),
  );
  options.value = {
    ...options.value,
    xaxis: {
      categories: Array.from(
        { length: 10 },
        (_, i) => new Date().getFullYear() - i,
      ),
    },
  };
  series.value = [
    {
      name: "Score",
      data: data,
    },
  ];
};

onMounted(() => {
  //generate array of random numbers of length 10
  setTimeout(() => {
    updateChart();
  }, 500);
});
</script>

<template>
  <h3 class="absolute left-4 top-3 text-2xl">API Usage</h3>
  <apexchart
    :key="series"
    height="100%"
    width="100%"
    :options="options"
    :series="series"
  ></apexchart>
</template>

<style>
.apexcharts-toolbar {
  display: none !important;
}
</style>

components/dashboard/News.vue

コード詳細
components/dashboard/News.vue
<script setup lang="ts"></script>

<template>
  <h3 class="absolute top-4 mb-4 text-2xl">News</h3>
  <p class="text-base text-gray-500 dark:text-white">
    Track work across the enterprise through an open, collaborative platform.
    Link issues across Jira and ingest data from other software development
    tools, so your IT support and operations teams have richer contextual
    information to rapidly respond to requests, incidents, and changes.
  </p>
  <hr class="my-4 h-px border-0 bg-gray-200 dark:bg-white" />
  <p class="text-base text-gray-500 dark:text-white">
    Deliver great service experiences fast - without the complexity of
    traditional ITSM solutions.Accelerate critical development work, eliminate
    toil, and deploy changes with ease, with a complete audit trail for every
    change.
  </p>
</template>

最後に

今回はコードの説明などは省きますが、基本的に環境構築のところで触れたURLを見ながら作成しました。
またlocalstorageへの保存のタイミングなどは下記GitHubを参考に作成しました。
https://github.com/TahaSh/swapy/tree/main/examples/vue

module系は少し変な設定をしていますが、調べた限りだとそのように設定するしかありませんでした。
(apexchartsとか)

このウィジェットのような管理画面は、エンドユーザーが自分の欲しい情報を自分の見たいところに表示できるというのがいい点だと思います。
また、今後はrowやcolumnもエンドユーザーで変更できるようにすれば、また自由度が上がっていいのかなとも思ってます。

進化させていきます〜!

Discussion