Swapyでdashboardを作ろう

# 記事を書いた人
株式会社マーズフラッグの小池です。
現在はフロントエンドエンジニアとして自社製品の開発に携わっています。
最近では個人開発などもはじめ、その際に面白いなと思ったSwapyというライブラリをご紹介します。

https://www.marsflag.com/ja/services/marsplatform/

# 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に保存することによって、次にページに訪れた際も自分が並べ替えた状態で描画されるようにします。

![chrome-capture-2024-11-24.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583896/f36e48a2-f34c-4bbf-e686-baea038a1d2e.gif)

### 使用技術
- "nuxt": "^3.12.4"
- "@nuxtjs/tailwindcss": "^6.12.1"
- "shadcn-nuxt": "^0.10.4"
- "swapy": "^0.0.6"
- "@nuxt/icon": "^1.7.5"

## 環境構築

まずはNuxtのプロジェクトを作ります。
プロジェクトを作りたいディレクトリに移動して、下記コマンドを実行します。
```sh
pnpm dlx nuxi@latest init swapy-project
```
※swapy-projectの部分は自分の好きなプロジェクト名でOKです。
※package managerの選択ではpnpmを選んでいます。
※gitリポジトリの初期化はスキップしてます。


### tailwind
```sh
npx nuxi@latest module add @nuxtjs/tailwindcss
```
nuxt.config.tsもmodulesに@nuxtjs/tailwindcssが追加されます。
```diff_typescript:nuxt.config.ts
export default defineNuxtConfig({
  compatibilityDate: '2024-04-03',
  devtools: { enabled: true },
+ modules: ['@nuxtjs/tailwindcss']
})
```

###  shadcn (for Nuxt) 
```sh
npx nuxi@latest module add shadcn-nuxt
```

:::note warn
Nuxt module should be a function: @nuxtjs/color-modeのエラーが出たら、下記を実行してから再度installしてみてください。
```sh
pnpm i -D @nuxtjs/color-mode
```
:::

nuxt.config.tsを下記のように編集します。

```diff_typescript:nuxt.config.ts
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  compatibilityDate: '2024-04-03',
  devtools: { enabled: true },
  modules: ['@nuxtjs/tailwindcss', 'shadcn-nuxt'],
+ shadcn: {
+  /**
+   * Prefix for all the imported component
+   */
+  prefix: '',
+  /**
+   * Directory that the component lives in.
+   * @default "./components/ui"
+   */
+   componentDir: './components/ui'
+ }
})
```

上記ができたら初期化を行います。

```sh
npx shadcn-vue@latest init
```

いくつか質問がきますが、下記の通りに進んでいただければOKです！

```
Would you like to use TypeScript? (recommended)? … no / yes
✔ Which framework are you using? › Nuxt
✔ Which style would you like to use? › Default
✔ Which color would you like to use as base color? › Slate
✔ Where is your tsconfig.json file? … .nuxt/tsconfig.json
✔ Where is your global CSS file? (this file will be overwritten) … assets/css/tailwind.css
✔ Would you like to use CSS variables for colors? … no / yes
✔ Are you using a custom tailwind prefix eg. tw-? (Leave blank if not) … 
✔ Where is your tailwind.config located? (this file will be overwritten) … tailwind.config.js
✔ Configure the import alias for components: … @/components
✔ Configure the import alias for utils: … @/lib/utils
✔ Write configuration to components.json. Proceed? … yes
```

これでshadcnのセットアップはOKです！


また、今回必要なコンポネントをinstallしておく必要もあるので、下記コマンドを実行します。
```sh
npx shadcn-vue@latest add  // addの後ろには半角スペースがあります。
```
このようにすると必要なコンポネントをコマンドライン上で選ぶことができます。
今回は下記コンポネントを選択してください。

- **button**
- **card**
- **chart**
- **chart-bar**
- **dropdown-menu**

### nuxt/icon

下記コマンドでセットアップします。
```sh
npx nuxi module add icon
```


### swapy
今回ご紹介するライブラリですのswapyをinstallします。
```sh
pnpm i swapy
```
https://swapy.tahazsh.com/


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


## 実装

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

:::note warn
注意
コードが縦に長いので、アコーディオンにしてます。
開いて確認ください。
:::

### app.vue

<details><summary>コード詳細</summary>

```vue:app.vue
<template>
  <div>
    <NuxtPage />
  </div>
</template>
```
</details>

### pages/dashboard.vue(ページ)

<details><summary>コード詳細</summary>

```vue:dashboard.vue
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { createSwapy } from "swapy";
import DashboardCard from "@/components/dashboard/DashboardCard.vue";

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: DashboardCard, 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 class="relative z-10 h-full max-w-none gap-5 p-0">
    <!-- header -->
    <PageHeader
      title="Dashboard"
      href="https://zenn.dev/tatausuru/articles/a1b8e016686b04"
      target="_blank"
      label="Swapyでdashboardを作ろう"
    />

    <div
      class="container relative z-10 mt-4 grid h-full max-w-none grid-cols-3 grid-rows-[1fr_1fr_1fr] gap-5 p-0"
    >
      <div
        v-for="slotId in Object.keys(slotItems)"
        :key="slotId"
        :class="[
					'h-72 rounded-md border border-[#eeeeee] bg-[#171717] dark:border-[#171717] dark:bg-[#1E1E1E]',
					slotClasses[slotId as unknown as keyof typeof slotClasses],
				]"
        :data-swapy-slot="slotId"
      >
        <DashboardCard
          :is="getItemById(slotItems[slotId]).component"
          v-bind="getItemById(slotItems[slotId]).props"
          @update-card-data="updateCardData"
        />
      </div>
    </div>
  </div>
</template>
```
</details>


### components/dashboard/DashboardCard.vue
<details><summary>コード詳細</summary>

```vue:components/dashboard/DashboardCard.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";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";

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

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>
  <Card
    class="relative grid size-full select-none grid-rows-[80px,1fr] overflow-hidden rounded-md border border-gray-200 bg-white text-gray-700 shadow-md dark:border-[#4c4c4c] dark:bg-[#1f1f1f] dark:text-gray-300 dark:shadow-lg"
    :data-swapy-item="props.dataSwapyItem"
  >
    <CardHeader
      class="relative z-40 flex flex-row items-center justify-between"
    >
      <CardTitle>{{ setContent }}</CardTitle>
      <div class="flex items-center gap-2">
        <div class="handle size-6 cursor-pointer" data-swapy-handle>
          <Icon name="hugeicons:move" class="size-6" />
        </div>
        <Dropdown @update-content="updateContent" />
      </div>
    </CardHeader>

    <CardContent class="relative z-10 overflow-auto">
      <ColumnChart v-if="setContent === 'API Usage'" />
      <News v-else-if="setContent === 'News'" />
      <div v-else-if="setContent === 'Profile'" />
      <div v-else>コンテンツが選択されてません。</div>
    </CardContent>
  </Card>
</template>
```
</details>

### components/dashboard/Dropdown.vue

<details><summary>コード詳細</summary>

```vue:components/dashboard/Dropdown.vue
<script setup lang="ts">
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

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

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

<template>
  <!-- Dropdown menu -->
  <DropdownMenu>
    <DropdownMenuTrigger as-child>
      <Button
        variant="outline"
        class="h-fit p-2 dark:border-[#2e2e2e] dark:bg-[#1e1e1e] dark:hover:bg-[#2e2e2e]"
      >
        <Icon name="mingcute:down-fill" class="size-4" />
      </Button>
    </DropdownMenuTrigger>
    <DropdownMenuContent class="w-56 dark:border-[#2e2e2e] dark:bg-[#171717]">
      <DropdownMenuLabel>コンテンツ</DropdownMenuLabel>
      <DropdownMenuSeparator />
      <DropdownMenuCheckboxItem
        v-for="items in selectItems"
        :key="items.id"
        @click="updateContent(`${items.name}`)"
      >
        {{ items.name }}
      </DropdownMenuCheckboxItem>
    </DropdownMenuContent>
  </DropdownMenu>
</template>
```
</details>


### components/dashboard/ColumnChart.vue

<details><summary>コード詳細</summary>

```vue:components/dashboard/ColumnChart.vue
<script setup lang="ts">
const data = [
  {
    name: "Jan",
    total: Math.floor(Math.random() * 2000) + 500,
    predicted: Math.floor(Math.random() * 2000) + 500,
  },
  {
    name: "Feb",
    total: Math.floor(Math.random() * 2000) + 500,
    predicted: Math.floor(Math.random() * 2000) + 500,
  },
  {
    name: "Mar",
    total: Math.floor(Math.random() * 2000) + 500,
    predicted: Math.floor(Math.random() * 2000) + 500,
  },
  {
    name: "Apr",
    total: Math.floor(Math.random() * 2000) + 500,
    predicted: Math.floor(Math.random() * 2000) + 500,
  },
  {
    name: "May",
    total: Math.floor(Math.random() * 2000) + 500,
    predicted: Math.floor(Math.random() * 2000) + 500,
  },
  {
    name: "Jun",
    total: Math.floor(Math.random() * 2000) + 500,
    predicted: Math.floor(Math.random() * 2000) + 500,
  },
  {
    name: "Jul",
    total: Math.floor(Math.random() * 2000) + 500,
    predicted: Math.floor(Math.random() * 2000) + 500,
  },
];
</script>

<template>
  <BarChart
    index="name"
    :data="data"
    :categories="['total', 'predicted']"
    :y-formatter="
      (tick, i) => {
        return typeof tick === 'number'
          ? `$ ${new Intl.NumberFormat('us').format(tick).toString()}`
          : '';
      }
    "
    :type="'grouped'"
    class="size-full pb-6"
    :colors="['#1C7C54', '#10B981']"
  />
</template>
```
</details>


### components/dashboard/News.vue

<details><summary>コード詳細</summary>

```vue:components/dashboard/News.vue
<script setup lang="ts"></script>

<template>
	<div class="">
		<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>
	</div>
</template>
```
</details>

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

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

進化させていきます〜！