DELOGs
[管理画面フォーマット制作編 #7] サイドバーメニュー管理UI ─ 3層・並び順・priority可視制御まで

管理画面フォーマット制作編 #7
サイドバーメニュー管理UI ─ 3層・並び順・priority可視制御まで

サイドバーに表示するメニューをUIから登録・編集・削除できる管理画面を作成

初回公開日

最終更新日

0. はじめに

今回の記事では、管理画面のサイドバーに表示するメニューを、UIから「登録・参照・編集・削除」できるように整備します。
ツリーは親→子→孫の3層に対応し、兄弟間の並び順(↑↓)をUIで変更可能にします。可視性は「未選択=全員に表示」を既定としつつ、ロールの priority をしきい値(minPriority)として指定できる設計にまとめ、既存の use-active.ts(URL一致と開閉判定)のロジックは変更せずに利用します。本記事は「UIのみ」を対象とし、Zodによるバリデーションまでをカバーします。
あわせて、UIの唯一のデータ源は MenuRecord[](将来APIが返す形) とし、src/lib/sidebar/menu.mock.ts にモックデータを保持します。ランタイムでサイドバー描画に使う MenuTree は、後半で 変換レイヤ(MenuRecord[] → MenuTree によって生成してから use-active に渡します。

本記事で実現すること

本記事で実現する到達点を手短に整理します。後続の章で、モックストア → 新規 → 編集 → 一覧 → 変換 の順に構築します。
項目内容
階層3層(親→子→孫)。深さ3を超える登録は不可
並び順兄弟間を↑↓操作で入れ替え、保存時に正規化(欠番防止)
可視制御既定は「未選択=全員に表示」。必要に応じて minPriority を指定
URL一致exact / prefix / regex を選択可(regex は例外用途)
セクション見出し専用(リンクなし)をサポート(isSection
互換性use-active.ts を改変しない(変換レイヤで MenuTree を供給)
バリデーションZodで必須/整形/相関(regex検証・深さ超過検知など)

仕様のキモ(今回の前提)

可視制御とロール優先度の前提を明確にします。priority方式のみで要件を満たし、ピンポイントのロール指定は扱いません。
  • 未選択=全員に表示(minPriority 未設定)
  • しきい値指定(minPriority)=「この数値以上のロールに表示」
  • ユーザーが複数ロールを持つ場合は 最大priority を採用
  • minPriority を子に継承(上書き不可):親に 100 を設定したら子も管理者(ADMIN)専用になる
ロールpriority想定
ADMIN100最高権限
EDITOR50編集者
ANALYST20分析担当
VIEWER10閲覧者
例:minPriority: 20 を指定したメニューは ANALYST / EDITOR / ADMIN に表示され、VIEWERには表示されません。
例:親カテゴリ「設定」に minPriority: 100 を設定した場合、配下のメニューも管理者専用になります(継承・上書き不可)。

技術スタックと前提環境

前提とする技術スタックとコードスタイルを確認します。既存の書き方を踏襲します。
Tool / LibVersionPurpose
React19.xUIの土台。コンポーネント/フックで状態と表示を組み立てる
Next.js15.xApp Router/SSR/SSG、動的ルーティング、メタデータ管理
TypeScript5.x型安全・補完・リファクタリング
shadcn/uilatestRadixベースのUIキット
Tailwind CSS4.xユーティリティファーストCSS
  • Next.js 15(App Router)/TypeScript
  • UIは shadcn/ui を用い、フォームは react-hook-form + zodResolver

画面構成と読み方

本記事は、 管理画面フォーマット作成編 #6 マスタ管理-ロール管理(UIのみ) の続きとなります。この過去記事をご確認いただくか、Githubからソースを取得してから本記事を参照いただくことをオススメします。
  • 既存ファイルとの整合:src/lib/sidebar/menu.schema.ts(型・Zod・※MENUはランタイム用に温存)と src/lib/sidebar/use-active.ts はそのまま活かす
  • 本稿で新設/使用するのは、UI専用データ MenuRecord[]src/lib/sidebar/menu.mock.ts と、後半の 変換レイヤ(MenuRecord[] → MenuTree
本記事の進行順はつぎのとおりです。
  1. モックストア(menu.mock.ts)INITIAL_MENU_RECORDS(UI唯一ソース)と CRUD/並び替え
  2. 新規ページ(/masters/menus/new):親・見出し/リンク・一致方法・アイコン・minPriority 入力(pattern は詳細設定でデフォルト非表示)
  3. 編集ページ(/masters/menus/[displayId]):参照・更新・削除(子がいる場合の削除ガード)
  4. 一覧ページ(/masters/menus):Data Table で表示(フィルタ/検索/ソート/↑↓)
  5. 変換レイヤMenuRecord[] → MenuTree 生成(icons.map.ts で iconName を LucideIcon に解決)
  6. use-active との連携:生成した MenuTree をそのまま渡してアクティブ判定を流用
データは UI編集用の構造(MenuRecord[] を唯一ソースとして保持し、後半で MenuTree に組み立ててから、既存の use-active に渡します。

1. 現行構成の復習(単一出所とactive判定)

これまでのサイドバーメニューは、コード内に「単一出所」として集約されていました。src/lib/sidebar/menu.schema.ts に型とランタイム用のメニュー定義(MenuTree)を一元管理し、src/lib/sidebar/use-active.ts で現在のURLと突き合わせて「どのメニューをアクティブにするか」を判定しています。この方式はシンプルで保守しやすく、現状のUIでも問題なく利用できています。
なお、本記事では UIの唯一ソースを MenuRecord[](将来API形)に揃える 方針に変更します。ランタイムで使う MenuTree は後半の「変換レイヤ」で MenuRecord[] から生成して use-active に渡します。つまり、既存の MenuTree はランタイム専用の“参照モデル”、UIは MenuRecord[] を編集するという役割分担に整理します。

menu.schema.ts ― 型とメニュー定義の一元化(ランタイム用)

ts
1// src/lib/sidebar/menu.schema.ts(抜粋:型とランタイム用 MenuTree) 2 3/** どのルールでURL一致を判定するか */ 4export type MatchMode = "exact" | "prefix" | "regex"; 5 6export type MenuNode = { 7 id: string; // 安定キー 8 title: string; // サイドバー表示ラベル 9 href: string; // 絶対パス(末尾スラッシュなし) 10 icon?: LucideIcon; // 任意 11 match?: MatchMode; // 既定は "prefix" 12 pattern?: RegExp; // match === "regex" のときのみ 13 children?: MenuNode[]; // 子ノード 14}; 15 16export type MenuTree = MenuNode[]; 17 18// ランタイム用の MENU(サイドバー描画・use-active で参照) 19export const MENU: MenuTree = [/* …省略… */];
MENU には実際のツリー構造が格納されており、3層構造にも対応できます(例:「設定 → マスタ管理 → ロール管理」)。この「ランタイム用の単一出所」は従来どおり温存し、後半で MenuRecord[] → MenuTree 変換を行ってから use-active に渡します。
ts
1// src/lib/sidebar/use-active.ts(抜粋:URL一致・アクティブ判定の要点) 2 3export function useActive(menu: MenuTree) { 4 const pathname = usePathname(); 5 6 // 1) メニューをフラット化(親子関係と深さを保持) 7 // 2) exact/prefix/regex でスコアリング 8 // - exact は強く、prefix は長いほど強い、regex はマッチ長に応じて中間 9 // 3) 最高スコアを active として選定 10 // 4) active から親を遡って ancestors を決定(開閉制御に利用) 11 // 5) aria-current は代表リンク(pageId)にのみ付与(A11y配慮) 12}

なぜ「単一出所」と「スコアリング」が重要か

  • 単一出所(menu.schema.ts, ランタイム用) 型と定義を1箇所に集めることで、表示や判定ロジックとの乖離を防ぎます。今回の方針では、UI編集は MenuRecord[] を唯一ソースにしつつ、公開時は変換レイヤで MenuTree を生成するため、最終的にランタイム側の単一出所も維持できます。
  • スコアリング(use-active.ts) exact/prefix/regex を一律に比較可能にし、URLが長く具体的なものほど優先される自然な挙動を実現しています。既存ロジックは変更せず、そのまま活用します。
この土台の上に、次章以降で UI 専用データ(MenuRecord[])のモックストアを用意し、新規・編集・一覧の各ページを実装していきます。公開時は MenuRecord[] → MenuTree へ変換して、既存の use-active に渡す流れに統一します。

2. 実装ファイル構成と責務分離

UIで編集する唯一のデータ源を MenuRecord[](将来API形) に統一し、ランタイムのサイドバー描画に使う MenuTree は変換レイヤで生成する方針に合わせて、責務を次のように分離します。
  • 定義(型+Zod)src/lib/sidebar/menu.schema.ts に集約
    • MenuRecord(UI用)/MenuNode, MenuTree(ランタイム用)/menuRecordSchema(Zod)
  • UI唯一ソース(モック)src/lib/sidebar/menu.mock.ts
    • INITIAL_MENU_RECORDS: MenuRecord[] を保持し、get/add/update/delete/swapOrder を提供
  • 変換レイヤsrc/lib/sidebar/menu.transform.ts
    • MenuRecord[] → MenuTreeiconName → LucideIconpattern → RegExpminPriority の親継承適用)
  • アイコン辞書src/lib/sidebar/icons.map.ts
    • "Settings2" → Settings2 の双方向解決
ページ構成は、一覧は SSR の page.tsx から data-table.tsx を直呼び、新規/編集は page.tsx(SSR)+ client.tsx('use client') の2段構成を踏襲します。動的セグメント名は既存どおり [displayId]

ページ構成(/masters/menus 配下)

パス役割レンダリング主な要素
/masters/menus一覧・並び順(↑↓)・編集/削除・子の追加SSR: page.tsx直呼び: data-table.tsxData Table、インデント表示、兄弟内 order スワップ、パンくず+SidebarTrigger
/masters/menus/new新規作成SSR: page.tsxCSR: client.tsx親選択、セクション/リンク、hrefmatch・(詳細設定で)patterniconNameminPriority
/masters/menus/[displayId]参照・更新・削除SSR: page.tsxCSR: client.tsxnew と同等+削除ガード(子がいる場合は不可)
仕様の確定事項:minPriority は子に継承(上書き不可)。表示判定は変換レイヤ(MenuRecord[] → MenuTree)で親のしきい値を子へ伝搬してから use-active に渡します。
txt
1src/app/(protected)/masters/menus/ 2├─ page.tsx // SSR: 一覧データ取得 → <DataTable rows=... /> を直接描画(パンくず+SidebarTrigger 付) 3├─ data-table.tsx // 'use client': Data Table 本体(検索/フィルタ/ソート/ページング/↑↓/削除ダイアログ) 4├─ columns.tsx // 列定義(Name / Path / Visibility(minPriority) / Order / Status / Actions) 56├─ new/ 7│ ├─ page.tsx // SSR: 初期値・選択肢(親/アイコン等)取得 → <Client /> 8│ └─ client.tsx // 'use client': <MenuForm /> を描画し送信 910└─ [displayId]/ 11 ├─ page.tsx // SSR: displayId で対象取得(404判定)→ <Client /> 12 └─ client.tsx // 'use client': <MenuForm />(保存/削除を発火) 13 14src/components/masters/menus/ 15└─ menu-form.tsx // 'use client': RHF + zodResolver(新規・編集で共通) 16 17src/lib/sidebar/ 18├─ menu.schema.ts // MatchMode / MenuRecord(+Zod) / MenuNode / MenuTree(定義集約) 19├─ menu.mock.ts // INITIAL_MENU_RECORDS: MenuRecord[](UI唯一ソース)+ CRUD/並び替え 20├─ menu.transform.ts // MenuRecord[] → MenuTree(icon/regex/priority継承の解決) 21└─ icons.map.ts // "Settings2" ⇔ Settings2 の辞書(双方向)

ポイント

  • UI唯一ソースの確立:一覧・新規・編集はすべて menu.mock.tsMenuRecord[] を読み書き(将来は関数の中身だけAPI呼びに差し替え)。
  • 一覧は直呼びpage.tsx(SSR)から data-table.tsx を直接利用。client.tsx を挟まないのは既存流儀の踏襲。
  • 動的セグメント名[displayId] を厳守(既存のロール管理と同一パターン)。
  • pattern の扱い:データ構造には保持するが、UIでは「詳細設定」でデフォルト非表示(match: "regex" のときのみ入力可能)。
  • 親しきい値の継承minPriority は親→子へ強制継承(上書き不可)。変換レイヤで伝搬させてから MenuTree を生成。
  • アクセシビリティ:パンくず+SidebarTrigger のヘッダは一覧・新規・編集すべてに付与。

3. モックストアの実装(menu.mock.ts)

この章では、UIが参照・更新する唯一のデータ源として MenuRecord[] を保持するモックストアを作ります。将来はAPIレスポンスに差し替える前提で、CRUD・並び替え・参照系ユーティリティまでを1ファイルに集約します。INITIAL_MENU_RECORDS は読みやすさのため別定義とし、起動時に store へ展開します。
責務の整理:
区分役割
データINITIAL_MENU_RECORDS: MenuRecord[](UI唯一ソース)
保持store: MenuRecord[](ミュータブル/UIからの操作で更新)
参照getMenus()(親→子→孫の安定ソート)、getMenuByDisplayId()getChildren()hasChildren()
更新addMenu()updateMenu()deleteMenu()(子がいる場合は不可)
並び替え`swapOrder(displayId, "up"
採番nextDisplayId()M00000001 形式)
保全normalizeOrder()(兄弟内の order を 0..N に詰める)
注意点:ロール可視性(minPriority親→子継承)は、次章の変換レイヤMenuTree に組み立てる際に適用します。モックストアは UIが編集する値をそのまま保持します。

src/lib/sidebar/menu.mock.ts

src/lib/sidebar/menu.mock.tsを下記内容で新規作成します。
ts
1// src/lib/sidebar/menu.mock.ts 2// 初期データ(UI唯一ソース):将来はAPIレスポンスに置き換え 3import type { MenuRecord } from "./menu.schema"; 4 5export const INITIAL_MENU_RECORDS: MenuRecord[] = [ 6 // ───────── ルート階層 ───────── 7 { 8 displayId: "M00000001", 9 parentId: null, 10 order: 0, 11 title: "ダッシュボード", 12 href: undefined, // 見出し 13 iconName: "SquareTerminal", // 変換レイヤで LucideIcon に解決 14 match: "prefix", 15 pattern: undefined, 16 minPriority: undefined, // 未選択=全員表示 17 isSection: true, 18 isActive: true, 19 }, 20 { 21 displayId: "M00000002", 22 parentId: null, 23 order: 1, 24 title: "ドキュメント", 25 href: undefined, // 見出し 26 iconName: "BookOpen", 27 match: "prefix", 28 pattern: undefined, 29 minPriority: undefined, 30 isSection: true, 31 isActive: true, 32 }, 33 { 34 displayId: "M00000003", 35 parentId: null, 36 order: 2, 37 title: "設定", 38 href: undefined, // 見出し 39 iconName: "Settings2", 40 match: "prefix", 41 pattern: undefined, 42 minPriority: 100, // 親minPriorityは子に継承(仕様:上書き不可) 43 isSection: true, 44 isActive: true, 45 }, 46 47 // ───────── ダッシュボード配下 ───────── 48 { 49 displayId: "M00000004", 50 parentId: "M00000001", 51 order: 0, 52 title: "概要", 53 href: "/dashboard", 54 iconName: undefined, 55 match: "exact", 56 pattern: undefined, 57 minPriority: undefined, 58 isSection: false, 59 isActive: true, 60 }, 61 // ---- 無効状態確認用 62 { 63 displayId: "M00000005", 64 parentId: "M00000001", 65 order: 0, 66 title: "詳細", 67 href: "#", 68 iconName: undefined, 69 match: "exact", 70 pattern: undefined, 71 minPriority: undefined, 72 isSection: false, 73 isActive: false, 74 }, 75 76 // ───────── ドキュメント配下 ───────── 77 { 78 displayId: "M00000007", 79 parentId: "M00000002", 80 order: 0, 81 title: "チュートリアル", 82 href: "#", 83 iconName: undefined, 84 match: "prefix", 85 pattern: undefined, 86 minPriority: undefined, 87 isSection: false, 88 isActive: true, 89 }, 90 { 91 displayId: "M00000008", 92 parentId: "M00000002", 93 order: 1, 94 title: "更新履歴", 95 href: "#", 96 iconName: undefined, 97 match: "prefix", 98 pattern: undefined, 99 minPriority: undefined, 100 isSection: false, 101 isActive: true, 102 }, 103 104 // ───────── 設定配下 ───────── 105 { 106 displayId: "M00000010", 107 parentId: "M00000003", 108 order: 0, 109 title: "マスタ管理", 110 href: "/masters", 111 iconName: undefined, 112 match: "prefix", 113 pattern: undefined, 114 minPriority: undefined, 115 isSection: false, 116 isActive: true, 117 }, 118 { 119 displayId: "M00000011", 120 parentId: "M00000003", 121 order: 1, 122 title: "ユーザ管理", 123 href: "/users", 124 iconName: undefined, 125 match: "prefix", 126 pattern: undefined, 127 minPriority: undefined, 128 isSection: false, 129 isActive: true, 130 }, 131 132 // ───────── マスタ管理の子 ───────── 133 { 134 displayId: "M00000012", 135 parentId: "M00000010", 136 order: 0, 137 title: "マスタ一覧", 138 href: "/masters", 139 iconName: undefined, 140 match: "exact", 141 pattern: undefined, 142 minPriority: undefined, 143 isSection: false, 144 isActive: true, 145 }, 146 { 147 displayId: "M00000013", 148 parentId: "M00000010", 149 order: 1, 150 title: "ロール管理", 151 href: "/masters/roles", 152 iconName: undefined, 153 match: "prefix", 154 pattern: undefined, 155 minPriority: undefined, 156 isSection: false, 157 isActive: true, 158 }, 159 { 160 displayId: "M00000014", 161 parentId: "M00000010", 162 order: 2, 163 title: "メニュー管理", 164 href: "/masters/menus", 165 iconName: undefined, 166 match: "prefix", 167 pattern: undefined, 168 minPriority: undefined, 169 isSection: false, 170 isActive: true, 171 }, 172 173 // ───────── ユーザ管理の子 ───────── 174 { 175 displayId: "M00000015", 176 parentId: "M00000011", 177 order: 0, 178 title: "一覧", 179 href: "/users", 180 iconName: undefined, 181 match: "exact", 182 pattern: undefined, 183 minPriority: undefined, 184 isSection: false, 185 isActive: true, 186 }, 187 { 188 displayId: "M00000016", 189 parentId: "M00000011", 190 order: 1, 191 title: "新規登録", 192 href: "/users/new", 193 iconName: undefined, 194 match: "exact", 195 pattern: undefined, 196 minPriority: undefined, 197 isSection: false, 198 isActive: true, 199 }, 200]; 201 202// ストア本体とCRUD・並び替え・参照ユーティリティ 203// ミュータブルなストア(UI操作で更新) 204let store: MenuRecord[] = INITIAL_MENU_RECORDS.map((r) => ({ ...r })); 205 206/** 参照:一覧(親→子→孫の順で安定ソート) */ 207export function getMenus(): MenuRecord[] { 208 return store.slice().sort((a, b) => { 209 const pa = a.parentId ?? ""; 210 const pb = b.parentId ?? ""; 211 return pa === pb ? a.order - b.order : pa.localeCompare(pb); 212 }); 213} 214 215/** 参照:1件取得 */ 216export function getMenuByDisplayId(displayId: string): MenuRecord | undefined { 217 return store.find((r) => r.displayId === displayId); 218} 219 220/** 参照:子ノード一覧 */ 221export function getChildren(parentId: string | null): MenuRecord[] { 222 return getMenus().filter((r) => r.parentId === parentId); 223} 224 225/** 参照:子が存在するか(削除ガード用) */ 226export function hasChildren(displayId: string): boolean { 227 return store.some((r) => r.parentId === displayId); 228} 229 230/** 採番:次の表示ID(M00000001 形式) */ 231export function nextDisplayId(): string { 232 const max = store 233 .map((r) => Number(r.displayId.slice(1))) 234 .reduce((acc, n) => Math.max(acc, n), 0); 235 return `M${String(max + 1).padStart(8, "0")}`; 236} 237 238/** 追加:兄弟末尾に追加し order を付与 */ 239export function addMenu( 240 input: Omit<MenuRecord, "displayId" | "order">, 241): MenuRecord { 242 const displayId = nextDisplayId(); 243 const siblings = store.filter((r) => r.parentId === input.parentId); 244 const rec: MenuRecord = { ...input, displayId, order: siblings.length }; 245 store.push(rec); 246 normalizeOrder(store); 247 return rec; 248} 249 250/** 更新:存在すれば置換(親変更も可)。整合のため order 正規化 */ 251export function updateMenu(updated: MenuRecord): boolean { 252 const i = store.findIndex((r) => r.displayId === updated.displayId); 253 if (i === -1) return false; 254 store[i] = { ...updated }; 255 normalizeOrder(store); 256 return true; 257} 258 259/** 削除:子がいれば不可(UI側で警告表示を想定) */ 260export function deleteMenu(displayId: string): boolean { 261 if (hasChildren(displayId)) return false; 262 const i = store.findIndex((r) => r.displayId === displayId); 263 if (i === -1) return false; 264 store.splice(i, 1); 265 normalizeOrder(store); 266 return true; 267} 268 269/** 並び替え:兄弟内で ↑↓ を入れ替え */ 270export function swapOrder(displayId: string, dir: "up" | "down"): boolean { 271 const me = getMenuByDisplayId(displayId); 272 if (!me) return false; 273 274 const siblings = store 275 .filter((r) => r.parentId === me.parentId) 276 .sort((a, b) => a.order - b.order); 277 278 const idx = siblings.findIndex((s) => s.displayId === me.displayId); 279 const targetIdx = dir === "up" ? idx - 1 : idx + 1; 280 if (targetIdx < 0 || targetIdx >= siblings.length) return false; 281 282 const a = siblings[idx]; 283 const b = siblings[targetIdx]; 284 const tmp = a.order; 285 a.order = b.order; 286 b.order = tmp; 287 288 normalizeOrder(store); 289 return true; 290} 291 292/** 兄弟ごとに order を 0..N へ正規化(欠番防止) */ 293export function normalizeOrder(list: MenuRecord[]): void { 294 const byParent = new Map<string | null, MenuRecord[]>(); 295 for (const r of list) { 296 const key = r.parentId ?? null; 297 const arr = byParent.get(key) ?? []; 298 arr.push(r); 299 byParent.set(key, arr); 300 } 301 for (const [, arr] of byParent) { 302 arr.sort((a, b) => a.order - b.order); 303 arr.forEach((r, i) => (r.order = i)); 304 } 305} 306 307/** リセット(テストや開発用) */ 308export function resetMenus(next?: MenuRecord[]): void { 309 store = next 310 ? next.map((r) => ({ ...r })) 311 : INITIAL_MENU_RECORDS.map((r) => ({ ...r })); 312}

ポイント

  • 一覧は 親ID→order で安定ソートし、UI側のインデント表示(親→子→孫)に素直に使えます。
  • 兄弟内の並び替えは swapOrder()normalizeOrder() の流れで 欠番を防止。常に 0..N の連番に保ちます。
  • 削除は 子がいる場合は不可 とし、UI側で「配下にメニューが存在するため削除できません」と警告表示を出せます。
  • minPriority親→子継承 はこの層では行わず、次章の 変換レイヤ(MenuRecord[] → MenuTree) で適用します。UIは「項目そのもの」を編集・保存するだけに専念します。
  • 将来のAPI置換は、getMenus() などの中身を fetch() に差し替えるだけでOK。UIコードの変更は最小限になります。

4. スキーマ定義とバリデーション(menu.schema.ts)

前章で menu.mock.ts に UI用のデータソースを用意しました。
ここでは、そのデータを正しく型安全に扱うための スキーマ定義とZodによるバリデーション を実装します。
型とスキーマはすべて src/lib/sidebar/menu.schema.ts に集約し、単一出所として管理します。src/lib/sidebar/menu.schema.tsは過去記事で作成した内容を変更していきます。

MenuNode / MenuTree の定義

まずは、復習という感じですが、実行時に利用するサイドバーツリーの構造を定義しています。これはこのまま残します。 MenuNode は1つの要素を表し、MenuTree はその配列です。前章で作成したmenu.mock.tsに定義した箇所は削除してしまいます。
ts
1// src/lib/sidebar/menu.schema.ts(前半) 2 3import { z } from "zod"; 4import type { LucideIcon } from "lucide-react"; 5 6/** URL一致の判定方法 */ 7export type MatchMode = "exact" | "prefix" | "regex"; 8 9/** サイドバー実行時のツリー構造 */ 10export type MenuNode = { 11 id: string; 12 title: string; 13 href?: string; 14 icon?: LucideIcon; 15 match?: MatchMode; 16 pattern?: RegExp; 17 children?: MenuNode[]; 18}; 19 20export type MenuTree = MenuNode[];
上記のようにシンプルになります。これに続けて下記の設定を行います。

MenuRecord と Zod スキーマ

次に、UI専用の編集用データを MenuRecord として定義します。 これは Data Table や Form 入力で利用される基本データになります。
ts
1// src/lib/sidebar/menu.schema.ts(続き) 2 3/** 編集用の1レコード(UI専用) */ 4export const menuRecordSchema = z 5 .object({ 6 displayId: z.string().min(1), 7 parentId: z.string().nullable(), 8 order: z.number().int().nonnegative(), 9 title: z.string().min(1), 10 href: z 11 .string() 12 .regex(/^\/(?!.*\/$).*/, "先頭は /、末尾スラッシュは不可") 13 .optional(), 14 iconName: z.string().optional(), 15 match: z.enum(["exact", "prefix", "regex"]).default("prefix"), 16 pattern: z.string().optional(), 17 minPriority: z.number().int().positive().optional(), 18 isSection: z.boolean().default(false), 19 isActive: z.boolean(), 20 }) 21 .superRefine((val, ctx) => { 22 // 見出しノードのときはリンク関連を禁止 23 if (val.isSection) { 24 if (val.href) { 25 ctx.addIssue({ code: "custom", message: "セクションではhref不要です" }); 26 } 27 if (val.pattern) { 28 ctx.addIssue({ 29 code: "custom", 30 message: "セクションではpattern不要です", 31 }); 32 } 33 } 34 35 // regex指定時は pattern 必須 36 if (val.match === "regex" && !val.pattern) { 37 ctx.addIssue({ code: "custom", message: "regex指定時はpattern必須です" }); 38 } 39 40 // regex以外では pattern を禁止 41 if (val.match !== "regex" && val.pattern) { 42 ctx.addIssue({ code: "custom", message: "regex以外でpattern不要です" }); 43 } 44 }); 45 46export type MenuRecord = z.infer<typeof menuRecordSchema>;

💡ポイント

  • isSection: true の場合は hrefpattern を禁止し、見出し専用ノードとする。
  • match: "regex" の場合のみ pattern を必須化。
  • minPriority が未設定なら「未選択=全員表示」と解釈。

集約のメリット

  • 単一出所: 型とスキーマを1ファイルに集め、フォーム・テーブル・変換処理の整合性を保つ。
  • 移行の容易さ: 将来的にAPIから MenuRecord[] を取得する場合も同じスキーマで検証可能。
  • 拡張性: ランタイム用 MenuTree と UI用 MenuRecord[] を分離し、責務を整理。
次章では、この MenuRecord[] を変換して MenuTree を構築し、既存の use-active.ts に渡す処理を実装します。

5. 変換レイヤ(MenuRecord → MenuTree)

UIで保持する MenuRecord[] はフラットな配列ですが、use-active.ts が扱う MenuTree は入れ子構造(親 → 子 → 孫)です。
そこで、変換レイヤ(menu.transform.ts)を設け、MenuRecord[] → MenuTree の構築を行います。

実装のポイント

  • 親子関係を parentId で解決し、ツリーを組み立てる。
  • isSection=true の場合は href を undefined にする。
  • iconName があれば icons.map.ts を参照し LucideIcon に変換。
  • match==="regex" の場合のみ pattern を new RegExp() で生成。
  • 親に minPriority が設定されている場合、子は親の値を継承(上書き不可)。
  • order を基準に兄弟間の並び順を正しく並べ替える。

menu.transform.tsの作成

src/lib/sidebar/menu.transform.tsを下記内容で作成します。
ts
1// src/lib/sidebar/menu.transform.ts 2import type { MenuRecord, MenuTree, MenuNode } from "./menu.schema"; 3import { ICONS } from "./icons.map"; 4 5/** MenuRecord[] → MenuTree に変換 */ 6export function toMenuTree(records: MenuRecord[]): MenuTree { 7 // displayId をキーにマップ化 8 const map = new Map<string, MenuNode>(); 9 const roots: MenuNode[] = []; 10 11 // 一旦フラットに作成 12 for (const r of records) { 13 const icon = r.iconName ? ICONS[r.iconName] : undefined; 14 const node: MenuNode = { 15 id: r.displayId, 16 title: r.title, 17 href: r.isSection ? undefined : r.href, 18 icon, 19 match: r.match ?? "prefix", 20 pattern: 21 r.match === "regex" && r.pattern ? new RegExp(r.pattern) : undefined, 22 children: [], 23 }; 24 map.set(r.displayId, node); 25 } 26 27 // 階層を組み立て 28 for (const r of records) { 29 const node = map.get(r.displayId)!; 30 if (r.parentId) { 31 const parent = map.get(r.parentId); 32 if (parent) { 33 parent.children = parent.children ?? []; 34 parent.children.push(node); 35 } 36 } else { 37 roots.push(node); 38 } 39 } 40 41 // order順にソート 42 function sortChildren(list: MenuNode[]) { 43 for (const n of list) { 44 if (n.children?.length) { 45 n.children.sort((a, b) => { 46 const ra = records.find((r) => r.displayId === a.id)!; 47 const rb = records.find((r) => r.displayId === b.id)!; 48 return ra.order - rb.order; 49 }); 50 sortChildren(n.children); 51 } 52 } 53 } 54 sortChildren(roots); 55 56 return roots; 57}

icons.map.tsの作成

ts
1// src/lib/sidebar/icons.map.ts 2// Lucide のアイコン名文字列を LucideIcon に解決する辞書 3import type { LucideIcon } from "lucide-react"; 4import { SquareTerminal, BookOpen, Settings2 } from "lucide-react"; 5 6// 値の型を LucideIcon にするのがポイント 7export const ICONS: Record<string, LucideIcon> = { 8 SquareTerminal, 9 BookOpen, 10 Settings2, 11};

💡利用イメージ

  • menu.mock.ts に保持している INITIAL_MENU_RECORDS を buildMenuTree() に渡す。
  • 戻り値の MenuTree を use-active.ts にそのまま投入すれば、 URL一致・展開判定ロジックが既存通りに動作する。
次章で、この変換レイヤーを既存のソースコードへ組み込みます。

6. 既存ソースコードへの変換レイヤーの適用

この章では、前章の変換レイヤを使って MenuRecord[] → MenuTree を生成し、実際にサイドバーが動く状態にします。
ポイントは「見出し(isSection: true)は href を持たない」ため、use-active.ts 側で href が未定義でも安全に動くように微修正することです。
変更点(要約):
  • FlatNode.href必須→任意 に変更。
  • scoreForhref 未定義ならスコア 0(見出しはアクティブ対象外)。
  • 代表リンク判定(pageId)やソートで undefined を安全に扱う
  • 祖先判定・開閉判定のロジックは現状のまま(見出しもツリーには存在するため開閉は機能)。

use-active.tsに変更

src/lib/sidebar/use-active.tsを下記のように変更します。
diff : use-active.ts
1// src/lib/sidebar/use-active.ts(変更箇所のみ抜粋) 2 3type FlatNode = { 4 id: string; 5- href: string; 6+ href?: string; // 見出しは href を持たないため任意に 7 match: "exact" | "prefix" | RegExp; // "regex" は RegExp に正規化して持つ 8 parentId: string | null; 9 depth: number; 10}; 11 12+ // 末尾スラッシュを除去(undefined はそのまま) 13+ function stripTrailingSlash(p?: string): string | undefined { 14+ return typeof p === "string" ? p.replace(/\/+$/, "") : undefined; 15+ } 16 17export function useActive(menu: MenuTree) { 18 const pathname = usePathname(); 19 20 // 1) フラット化 21 const flat = useMemo(() => { 22 const buf: FlatNode[] = []; 23- flatten(menu, null, 0, buf); 24+ // ★ 安全ガード:誤って MenuRecord[] や undefined が来ても落ちないように 25+ const tree = Array.isArray(menu) ? menu : []; 26+ // MenuNode の形であることが前提。型が崩れているとスコアは0になり、表示に影響しない 27+ flatten(tree, null, 0, buf); 28 return buf; 29 }, [menu]); 30 31 // 2) スコア計算 32 const scoreFor = useCallback( 33 /* 既存のまま */ (n: FlatNode): number => { 34- const href = n.href.replace(/\/+$/, ""); 35- const current = pathname.replace(/\/+$/, ""); 36+ const href = stripTrailingSlash(n.href); 37+ const current = stripTrailingSlash(pathname) ?? ""; 38+ // 見出し(href 未定義)はアクティブ対象外 39+ if (!href) return 0; 40 if (n.match === "exact") { 41 return current === href ? 1_000_000 + href.length : 0; 42 } 43 if (n.match === "prefix") { 44- const pref = href === "" ? "/" : href + "/"; 45- return current.startsWith(pref) ? href.length : 0; 46+ // ★ 親自身または配下でヒットさせる 47+ const isSelf = current === href; 48+ const isUnder = current.startsWith(href + "/"); 49+ return isSelf || isUnder ? href.length : 0; 50 } 51 52 53 // 3) ベストを選ぶ 54 const active = useMemo(() => { 55 const scored = flat 56 .map((n) => ({ ...n, score: scoreFor(n) })) 57 .filter((n) => n.score > 0) 58 .sort( 59 (a, b) => 60 b.score - a.score || 61 b.depth - a.depth || 62- b.href.length - a.href.length, 63+ (b.href?.length ?? 0) - (a.href?.length ?? 0), 64 ); 65 return scored[0] ?? null; 66 }, [flat, scoreFor]); 67 68 // 3.5) ★ 代表リンク(/users の exact 子)に振り替えるための pageId を確定 69- const pageId = useMemo(() => { 70- if (!active) return undefined; 71- const node = nodeById.get(active.id); 72- if (!node) return active.id; 73 74 // 親が prefix で、自分と同じ href を持つ exact の子がいれば、それを代表にする 75- const exactChild = node.children?.find( 76- (c) => 77- (c.match ?? "exact") === "exact" && 78- c.href.replace(/\/+$/, "") === node.href.replace(/\/+$/, ""), 79- ); 80- return exactChild?.id ?? active.id; 81- }, [active, nodeById]); 82 83+ const pageId = useMemo(() => { 84+ if (!active) return undefined; 85+ const node = nodeById.get(active.id); 86+ if (!node) return active.id; 87 88+ const nodeHref = stripTrailingSlash(node.href); 89+ const current = stripTrailingSlash(pathname) ?? ""; 90 91+ // ★ 代表リンクへの振替は「現在URLが親そのもののときだけ」 92+ if (node.match === "prefix" && nodeHref && current === nodeHref) { 93+ const exactChild = node.children?.find((c) => { 94+ const cHref = stripTrailingSlash(c.href); 95+ return (c.match ?? "exact") === "exact" && cHref === nodeHref; 96+ }); 97+ return exactChild?.id ?? active.id; 98+ } 99+ return active.id; 100+ }, [active, nodeById, pathname]);

app-sidebar.tsx の変更

diff
1// src/components/sidebar/app-sidebar.tsx(変更点のみ抜粋) 2 3- import * form "react"; 4+ import { useMemo } from "react"; 5 6// ★ 単一出所に統一:ここからメニューを取る 7- import { MENU } from "@/lib/sidebar/menu.schema"; 8+ import { getMenus } from "@/lib/sidebar/menu.mock"; // MenuRecord[] を返す 9+ import { toMenuTree } from "@/lib/sidebar/menu.transform"; // 変換レイヤ 10 11export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { 12+ // ① MenuRecord[] を取得(UIモックストア) 13+ const records = getMenus(); 14+ // ② MenuTree に変換(useMemo で安定化) 15+ const tree = useMemo(() => toMenuTree(records), [records]); 16 17 return ( 18 <Sidebar collapsible="icon" {...props}> 19 <SidebarHeader> 20 <NavTeam team={mockTeam} /> 21 </SidebarHeader> 22 23 <SidebarContent> 24 {/* a11y ランドマーク:メインメニュー */} 25 <nav aria-label="メインメニュー"> 26- <NavMain items={MENU} /> 27+ <NavMain items={tree} /> 28 </nav> 29 </SidebarContent>

nav-main.tsx の変更

src/components/sidebar/nav-main.tsxについては、href={node.href}href={node.href ?? "#"}とする変更を行います。該当は下記の3箇所です。
diff : nav-main.tsx
1// src/components/sidebar/nav-main.tsx(変更点のみ抜粋) 2 3/* ========== トップ階層用:親は SidebarMenuItem、子は SidebarMenuSub で受ける ========== */ 4 5function TopNode({ // ───の部分 6 7 // 子なし(トップの葉) → そのままリンク 8 if (!hasChildren) { 9 return ( 10 <SidebarMenuItem> 11 <SidebarMenuButton 12 asChild 13 tooltip={node.title} 14 className="data-[active=true]:bg-muted data-[active=true]:font-semibold" 15 > 16 <NavLink 17- href={node.href} 18+ href={node.href ?? "#"} 19 active={isActive(node.id)} 20 ariaCurrent={ariaCurrentFor(node.id)} 21 > 22 {node.icon && <node.icon className="size-4" />} 23 <span>{node.title}</span> 24 </NavLink> 25 </SidebarMenuButton> 26 </SidebarMenuItem> 27 ); 28 } 29 30/* ========== サブ階層用:子を持つなら“さらに”Collapsible、持たなければリンク ========== */ 31function SubNode({ // ───の部分 32 33 // 子なし(葉) → リンク 34 if (!hasChildren) { 35 return ( 36 <SidebarMenuSubItem> 37 <SidebarMenuSubButton 38 asChild 39 className="data-[active=true]:bg-muted data-[active=true]:font-semibold" 40 > 41 <NavLink 42- href={node.href} 43+ href={node.href ?? "#"} 44 active={isActive(node.id)} 45 ariaCurrent={ariaCurrentFor(node.id)} 46 > 47 <span>{node.title}</span> 48 </NavLink> 49 </SidebarMenuSubButton> 50 </SidebarMenuSubItem> 51 ); 52 } 53 54 // 子あり(サブ階層の親) → サブ内 Collapsible。親は開閉のみ(リンクにしない)の箇所 55 56 <SidebarMenuSub className="ml-2"> 57 {node.children?.map((gchild) => ( 58 <SidebarMenuSubItem key={gchild.id}> 59 <SidebarMenuSubButton 60 asChild 61 className="data-[active=true]:bg-muted data-[active=true]:font-semibold" 62 > 63 <NavLink 64- href={gchild.href} 65+ href={gchild.href ?? "#"} 66 active={isActive(gchild.id)} 67 ariaCurrent={ariaCurrentFor(gchild.id)} 68 > 69 <span>{gchild.title}</span> 70 </NavLink> 71 </SidebarMenuSubButton> 72 </SidebarMenuSubItem> 73 ))} 74 </SidebarMenuSub> 75
これで、menu.mockのデータ →menu.transform.tsと使ってサイドバーメニューのツリー形状へ変換 → サイトバーメニューへの適用 という流れが完成しました。これで、後々、サーバアクション等でテーブルからメニューデータを取得するようにした際に、スムーズに移行できると思います。
npm run devで起動して、これまで同様の動作を行うことが確認できます。
続けて、 メニューデータの新規登録・編集・一覧のそれぞれのUIを作成していきます。

7. 新規ページ(/masters/menus/new)

新規メニューを作成する画面を実装します。構成はこれまでどおり SSR の page.tsxCSR の client.tsx に分離し、フォーム本体は共通コンポーネント src/components/masters/menus/menu-form.tsx を用います。
入力項目は「親選択」「見出し/リンク切替」「href/一致方式(regexは詳細設定)」「アイコン」「minPriority(未選択=全員表示)」「有効/無効」です。兄弟内 order入力させず末尾に自動付与 し、保存はモックストア addMenu() を呼び出します。
なお、RHF + Zod のパターンをロール管理に合わせるため、menu.schema.tsCreate/Update 用スキーマ を追加します(本章で追記コードを提示)。

メニューデータファイルへの追記(menu.mock.ts)

フォームの選択肢で利用するデータ抽出関連の関数を追加します。ここでは、親メニューの選択肢につかう関数を追加します。
ts
1// src/lib/sidebar/menu.mock.ts(追加) 2export type ParentOption = { value: string | null; label: string }; 3 4/** 5 * 親セレクト用の候補を返す 6 * - ルート: (ルート) を先頭で固定(value=null) 7 * - それ以外: “各親の直下の子” だけを親の直後に並べる(孫は除外) 8 * - 表示: 親はそのまま、子はインデント付き(例: └ ラベル) 9 */ 10export function getParentOptions(): ParentOption[] { 11 const opts: ParentOption[] = [{ value: null, label: "(ルート)" }]; 12 const all = getMenus(); // parentId→order で安定している 13 // 親IDごとにグルーピング 14 const byParent = new Map<string | null, MenuRecord[]>(); 15 for (const r of all) { 16 const key = r.parentId ?? null; 17 const arr = byParent.get(key) ?? []; 18 arr.push(r); 19 byParent.set(key, arr); 20 } 21 // 親 → 直下の子(孫は出さない) 22 const roots = byParent.get(null) ?? []; 23 for (const parent of roots) { 24 opts.push({ value: parent.displayId, label: parent.title }); 25 const children = byParent.get(parent.displayId) ?? []; 26 for (const c of children) { 27 opts.push({ value: c.displayId, label: ` └ ${c.title}` }); 28 } 29 } 30 return opts; 31}

スキーマ定義ファイルへの追記(menu.schema.ts)

ts
1// src/lib/sidebar/menu.schema.ts(追記:Create/Update スキーマと型) 2import { z } from "zod"; 3 4/** 既存:menuRecordSchema / MenuRecord は前章で定義済み */ 5 6// Create: 新規登録時に入力させる項目だけを許容(displayId/order は自動) 7export const menuCreateSchema = z 8 .object({ 9 parentId: z.string().nullable(), 10 title: z.string().min(1, "タイトルは必須です"), 11 isSection: z.boolean().default(false), 12 href: z 13 .string() 14 .regex(/^\/(?!.*\/$).*/, "先頭は /、末尾スラッシュは不可") 15 .optional(), 16 match: z.enum(["exact", "prefix", "regex"]).default("prefix"), 17 // 「詳細設定」想定:regex のときだけ pattern を入れられる 18 pattern: z.string().optional(), 19 iconName: z.string().optional(), 20 // 未選択=全員表示。入力では string/number/空文字を受けて number | undefined に正規化 21 minPriority: z 22 .union([z.string(), z.number()]) 23 .optional() 24 .transform((v) => (v === "" || v === undefined ? undefined : Number(v))), 25 isActive: z.boolean().default(true), 26 }) 27 .superRefine((val, ctx) => { 28 // ★ 見出しではリンク系禁止(既存) 29 if (val.isSection) { 30 if (val.href) 31 ctx.addIssue({ 32 code: "custom", 33 message: "見出しでは href は不要です", 34 path: ["href"], 35 }); 36 if (val.pattern) 37 ctx.addIssue({ 38 code: "custom", 39 message: "見出しでは pattern は不要です", 40 path: ["pattern"], 41 }); 42 } 43 // ★ 見出しOFFのときは親が必須(= (ルート) は候補に出さない & null禁止) 44 if (!val.isSection && !val.parentId) { 45 ctx.addIssue({ 46 code: "custom", 47 message: "親メニューを選択してください", 48 path: ["parentId"], 49 }); 50 } 51 // regex の相関(既存) 52 if (!val.isSection && val.match === "regex" && !val.pattern) { 53 ctx.addIssue({ 54 code: "custom", 55 message: "regex 指定時は pattern が必要です", 56 path: ["pattern"], 57 }); 58 } 59 if (val.match !== "regex" && val.pattern) { 60 ctx.addIssue({ 61 code: "custom", 62 message: "regex 以外では pattern は不要です", 63 path: ["pattern"], 64 }); 65 } 66 }); 67 68export type MenuCreateInput = z.input<typeof menuCreateSchema>; 69export type MenuCreateValues = z.output<typeof menuCreateSchema>; 70 71// Update: 表示ID/order を含む(編集章で使用予定) 72export const menuUpdateSchema = menuCreateSchema 73 .extend({ 74 displayId: z.string().min(1), 75 order: z.number().int().nonnegative(), 76 }) 77 .superRefine((val, ctx) => { 78 // Create と同じ相関を維持 79 if (val.isSection) { 80 if (val.href) 81 ctx.addIssue({ 82 code: "custom", 83 message: "見出しでは href は不要です", 84 path: ["href"], 85 }); 86 if (val.pattern) 87 ctx.addIssue({ 88 code: "custom", 89 message: "見出しでは pattern は不要です", 90 path: ["pattern"], 91 }); 92 } 93 if (!val.isSection && !val.parentId) { 94 ctx.addIssue({ 95 code: "custom", 96 message: "親メニューを選択してください", 97 path: ["parentId"], 98 }); 99 } 100 if (!val.isSection && val.match === "regex" && !val.pattern) { 101 ctx.addIssue({ 102 code: "custom", 103 message: "regex 指定時は pattern が必要です", 104 path: ["pattern"], 105 }); 106 } 107 if (val.match !== "regex" && val.pattern) { 108 ctx.addIssue({ 109 code: "custom", 110 message: "regex 以外では pattern は不要です", 111 path: ["pattern"], 112 }); 113 } 114 }); 115 116export type MenuUpdateInput = z.input<typeof menuUpdateSchema>; 117export type MenuUpdateValues = z.output<typeof menuUpdateSchema>;

フォーム本体(menu-form.tsx)

ロール管理の role-form.tsx と同じ骨格で、Create と Edit の2モードを用意します。
本章では Create モードのみ使用。親セレクトは (ルート) を含め、isSection が true のときは href/match/pattern を自動的に無効化します。
minPriority は空=未選択(全員表示)、数値を入れたら「その数値以上のロールに表示」の意味になります。
tsx
1// src/components/menus/menu-form.tsx 2"use client"; 3 4import * as React from "react"; 5import { useForm } from "react-hook-form"; 6import { zodResolver } from "@hookform/resolvers/zod"; 7import { z } from "zod"; 8 9import { 10 Form, 11 FormField, 12 FormItem, 13 FormLabel, 14 FormControl, 15 FormMessage, 16 FormDescription, 17} from "@/components/ui/form"; 18import { Input } from "@/components/ui/input"; 19import { 20 Select, 21 SelectTrigger, 22 SelectValue, 23 SelectContent, 24 SelectItem, 25} from "@/components/ui/select"; 26import { Switch } from "@/components/ui/switch"; 27import { Button } from "@/components/ui/button"; 28import { Card, CardContent, CardFooter } from "@/components/ui/card"; 29import { 30 AlertDialog, 31 AlertDialogAction, 32 AlertDialogCancel, 33 AlertDialogContent, 34 AlertDialogFooter, 35 AlertDialogHeader, 36 AlertDialogTitle, 37 AlertDialogTrigger, 38} from "@/components/ui/alert-dialog"; 39import { 40 menuCreateSchema, 41 menuUpdateSchema, 42 type MenuCreateValues, 43 type MenuUpdateValues, 44 type MatchMode, 45} from "@/lib/sidebar/menu.schema"; 46import type { LucideIcon } from "lucide-react"; 47import { ICONS } from "@/lib/sidebar/icons.map"; 48 49export type ParentOption = { value: string | null; label: string }; 50export type IconOption = { value: string; label: string }; 51 52type BaseProps = { 53 parentOptions: ParentOption[]; // 先頭に {value:null,label:"(ルート)"} を含める 54 iconOptions: IconOption[]; // 先頭は {value:"",label:"(なし)"} ではなく value を与えない ⇒ 未選択は undefined で保持 55 onCancel?: () => void; 56 onDelete?: () => void; 57}; 58 59type CreateProps = BaseProps & { 60 mode: "create"; 61 onSubmit: (values: MenuCreateValues) => void; 62 initialValues?: never; 63}; 64 65type EditProps = BaseProps & { 66 mode: "edit"; 67 onSubmit: (values: MenuUpdateValues) => void; 68 initialValues: MenuUpdateValues; // displayId/order を含む完全値 69}; 70 71type Props = CreateProps | EditProps; 72 73export default function MenuForm(props: Props) { 74 return props.mode === "create" ? ( 75 <CreateForm {...props} /> 76 ) : ( 77 <EditForm {...props} /> 78 ); 79} 80 81/* ========================= 82 Create(新規) 83 ========================= */ 84function CreateForm({ 85 parentOptions, 86 iconOptions, 87 onSubmit, 88 onCancel, 89}: CreateProps) { 90 // 入力型/出力型(z.input / z.output)でロールと同一パターン 91 type Input = z.input<typeof menuCreateSchema>; 92 type Values = z.output<typeof menuCreateSchema>; 93 94 const form = useForm<Input, undefined, Values>({ 95 resolver: zodResolver(menuCreateSchema), 96 defaultValues: { 97 parentId: null, 98 title: "", 99 isSection: false, 100 href: "", 101 match: "prefix", 102 pattern: "", 103 iconName: undefined, 104 minPriority: undefined, 105 isActive: true, 106 }, 107 mode: "onBlur", 108 }); 109 110 const handleSubmit = form.handleSubmit(onSubmit); 111 112 return ( 113 <Form {...form}> 114 <form onSubmit={handleSubmit} data-testid="menu-form-create"> 115 <Card className="w-full rounded-md"> 116 <CardContent className="space-y-6 pt-1"> 117 {/* 親候補は SSR 側で getParentOptions() 済をそのまま渡す */} 118 <IsSectionField /> 119 <TitleField /> 120 {/* セクションでない時だけリンク系を表示 */} 121 {form.watch("isSection") ? ( 122 <IconField iconOptions={iconOptions} /> 123 ) : ( 124 <> 125 <ParentField parentOptions={parentOptions} /> 126 <HrefField /> 127 <MatchField /> 128 {form.watch("match") === "regex" && <PatternField />} 129 </> 130 )} 131 132 <MinPriorityField /> 133 <IsActiveField /> 134 </CardContent> 135 136 <CardFooter className="mt-4 flex gap-2"> 137 <Button 138 type="button" 139 variant="outline" 140 onClick={onCancel} 141 className="cursor-pointer" 142 > 143 キャンセル 144 </Button> 145 <Button 146 type="submit" 147 disabled={form.formState.isSubmitting} 148 className="cursor-pointer" 149 data-testid="submit-create" 150 > 151 登録する 152 </Button> 153 </CardFooter> 154 </Card> 155 </form> 156 </Form> 157 ); 158} 159 160/* ========================= 161 Edit(編集) 162 ========================= */ 163function EditForm({ 164 parentOptions, 165 iconOptions, 166 initialValues, 167 onSubmit, 168 onCancel, 169 onDelete, 170}: EditProps) { 171 type Input = z.input<typeof menuUpdateSchema>; 172 type Values = z.output<typeof menuUpdateSchema>; 173 174 const form = useForm<Input, undefined, Values>({ 175 resolver: zodResolver(menuUpdateSchema), 176 defaultValues: initialValues, 177 mode: "onBlur", 178 }); 179 180 const handleSubmit = form.handleSubmit(onSubmit); 181 182 return ( 183 <Form {...form}> 184 <form onSubmit={handleSubmit} data-testid="menu-form-edit"> 185 <Card className="w-full rounded-md"> 186 <CardContent className="space-y-6 pt-1"> 187 <DisplayIdField /> 188 <IsSectionField /> 189 <TitleField /> 190 {/* セクションでない時だけリンク系を表示 */} 191 {form.watch("isSection") ? ( 192 <IconField iconOptions={iconOptions} /> 193 ) : ( 194 <> 195 <ParentField parentOptions={parentOptions} /> 196 <HrefField /> 197 <MatchField /> 198 {form.watch("match") === "regex" && <PatternField />} 199 </> 200 )} 201 <MinPriorityField /> 202 <IsActiveField /> 203 </CardContent> 204 205 <CardFooter className="mt-4 flex items-center justify-between"> 206 <div className="flex gap-2"> 207 <Button 208 type="button" 209 variant="outline" 210 onClick={onCancel} 211 className="cursor-pointer" 212 > 213 キャンセル 214 </Button> 215 <Button 216 type="submit" 217 disabled={form.formState.isSubmitting} 218 className="cursor-pointer" 219 data-testid="submit-update" 220 > 221 更新する 222 </Button> 223 </div> 224 225 {onDelete && ( 226 <AlertDialog> 227 <AlertDialogTrigger asChild> 228 <Button 229 type="button" 230 variant="destructive" 231 className="cursor-pointer" 232 data-testid="delete-open" 233 > 234 削除する 235 </Button> 236 </AlertDialogTrigger> 237 <AlertDialogContent> 238 <AlertDialogHeader> 239 <AlertDialogTitle> 240 このメニューを論理削除しますか? 241 </AlertDialogTitle> 242 </AlertDialogHeader> 243 <AlertDialogFooter> 244 <AlertDialogCancel data-testid="delete-cancel"> 245 キャンセル 246 </AlertDialogCancel> 247 <AlertDialogAction 248 onClick={onDelete} 249 data-testid="delete-confirm" 250 > 251 削除する 252 </AlertDialogAction> 253 </AlertDialogFooter> 254 </AlertDialogContent> 255 </AlertDialog> 256 )} 257 </CardFooter> 258 </Card> 259 </form> 260 </Form> 261 ); 262} 263 264/* ========================= 265 小さなフィールド群 266 ========================= */ 267 268export function ParentField({ 269 parentOptions, 270}: { 271 parentOptions: ParentOption[]; 272}) { 273 // (ルート) を除外して、深さ判定のための簡易データを作る 274 const options = React.useMemo( 275 () => 276 parentOptions 277 .filter((o) => o.value !== null) // ★ ここで(ルート)を排除 278 .map((o) => ({ 279 value: o.value!, // null は除外済み 280 label: o.label, 281 depth: o.label.startsWith(" └ ") ? 1 : 0, 282 })), 283 [parentOptions], 284 ); 285 286 return ( 287 <FormField 288 name="parentId" 289 render={({ field }) => ( 290 <FormItem> 291 <FormLabel className="font-semibold">親メニュー&nbsp;*</FormLabel> 292 <Select 293 name={field.name} 294 // 未選択は undefined にして placeholder を出す 295 value={field.value ?? undefined} 296 onValueChange={(v) => field.onChange(v)} 297 > 298 <FormControl> 299 <SelectTrigger 300 aria-label="親メニューを選択" 301 data-testid="parent-trigger" 302 > 303 <SelectValue 304 placeholder="選択してください" 305 data-testid="parent-value" 306 /> 307 </SelectTrigger> 308 </FormControl> 309 <SelectContent data-testid="parent-list"> 310 {options.map((opt) => ( 311 <SelectItem key={opt.value} value={opt.value}> 312 <span className={opt.depth === 1 ? "pl-4" : ""}> 313 {opt.label} 314 </span> 315 </SelectItem> 316 ))} 317 </SelectContent> 318 </Select> 319 <FormMessage data-testid="parentId-error" /> 320 </FormItem> 321 )} 322 /> 323 ); 324} 325 326function TitleField() { 327 return ( 328 <FormField 329 name="title" 330 render={({ field }) => ( 331 <FormItem> 332 <FormLabel className="font-semibold">タイトル&nbsp;*</FormLabel> 333 <FormControl> 334 <Input 335 {...field} 336 placeholder="メニュー名" 337 aria-label="タイトル" 338 data-testid="title" 339 /> 340 </FormControl> 341 <FormMessage data-testid="title-error" /> 342 </FormItem> 343 )} 344 /> 345 ); 346} 347 348function IsSectionField() { 349 return ( 350 <FormField 351 name="isSection" 352 render={({ field }) => ( 353 <FormItem className="mt-1 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> 354 <FormLabel className="font-semibold"> 355 見出し(トップレベル)&nbsp;* 356 </FormLabel> 357 <FormControl> 358 <Switch 359 name={field.name} 360 checked={Boolean(field.value)} 361 onCheckedChange={field.onChange} 362 aria-label="見出しフラグ" 363 data-testid="isSection" 364 /> 365 </FormControl> 366 <FormMessage data-testid="isSection-error" /> 367 </FormItem> 368 )} 369 /> 370 ); 371} 372 373function HrefField() { 374 return ( 375 <FormField 376 name="href" 377 render={({ field }) => ( 378 <FormItem> 379 <FormLabel className="font-semibold"> 380 URL(先頭/、末尾/なし)* 381 </FormLabel> 382 <FormControl> 383 <Input 384 {...field} 385 placeholder="/masters" 386 aria-label="URL" 387 data-testid="href" 388 /> 389 </FormControl> 390 <FormMessage data-testid="href-error" /> 391 </FormItem> 392 )} 393 /> 394 ); 395} 396 397function MatchField() { 398 const modes: { value: MatchMode; label: string }[] = [ 399 { value: "prefix", label: "前方一致(既定)" }, 400 { value: "exact", label: "完全一致" }, 401 { value: "regex", label: "正規表現" }, 402 ]; 403 return ( 404 <FormField 405 name="match" 406 render={({ field }) => ( 407 <FormItem> 408 <FormLabel className="font-semibold">URL一致 *</FormLabel> 409 <Select 410 name={field.name} 411 value={field.value ?? "prefix"} 412 onValueChange={(v) => field.onChange(v as MatchMode)} 413 > 414 <FormControl> 415 <SelectTrigger aria-label="一致方法" data-testid="match-trigger"> 416 <SelectValue placeholder="選択してください" /> 417 </SelectTrigger> 418 </FormControl> 419 <SelectContent data-testid="match-list"> 420 {modes.map((m) => ( 421 <SelectItem key={m.value} value={m.value}> 422 {m.label} 423 </SelectItem> 424 ))} 425 </SelectContent> 426 </Select> 427 <FormMessage data-testid="match-error" /> 428 </FormItem> 429 )} 430 /> 431 ); 432} 433 434function PatternField() { 435 return ( 436 <FormField 437 name="pattern" 438 render={({ field }) => ( 439 <FormItem> 440 <FormLabel className="font-semibold">正規表現</FormLabel> 441 <FormControl> 442 <Input 443 {...field} 444 placeholder="^/reports/[0-9]+$" 445 aria-label="正規表現" 446 data-testid="pattern" 447 /> 448 </FormControl> 449 <FormDescription className="text-xs"> 450 `match = regex` の時だけ使用。その他では未入力にしてください。 451 </FormDescription> 452 <FormMessage data-testid="pattern-error" /> 453 </FormItem> 454 )} 455 /> 456 ); 457} 458 459function IconField({ iconOptions }: { iconOptions: IconOption[] }) { 460 return ( 461 <FormField 462 name="iconName" 463 render={({ field }) => ( 464 <FormItem> 465 <FormLabel className="font-semibold">アイコン</FormLabel> 466 <Select 467 name={field.name} 468 // 未選択は undefined を保持(空文字は使わない) 469 value={field.value ?? "___NONE___"} 470 onValueChange={(v) => 471 field.onChange(v === "___NONE___" ? undefined : v) 472 } 473 > 474 <FormControl> 475 <SelectTrigger aria-label="アイコン" data-testid="icon-trigger"> 476 <SelectValue placeholder="(なし)" /> 477 </SelectTrigger> 478 </FormControl> 479 480 <SelectContent data-testid="icon-list"> 481 <SelectItem value="___NONE___">(なし)</SelectItem> 482 483 {iconOptions.map((opt) => { 484 // ★ 動的 JSX は不可。まず変数に代入してから使う 485 const Icon = ICONS[opt.value] as LucideIcon | undefined; 486 return ( 487 <SelectItem key={opt.value} value={opt.value}> 488 <span className="flex items-center gap-2"> 489 {Icon ? ( 490 <Icon className="text-foreground size-4" /> 491 ) : null} 492 <span>{opt.label}</span> 493 </span> 494 </SelectItem> 495 ); 496 })} 497 </SelectContent> 498 </Select> 499 <FormMessage data-testid="iconName-error" /> 500 </FormItem> 501 )} 502 /> 503 ); 504} 505 506function MinPriorityField() { 507 return ( 508 <FormField 509 name="minPriority" 510 render={({ field }) => ( 511 <FormItem> 512 <FormLabel className="font-semibold">可視しきい値</FormLabel> 513 <FormControl> 514 <Input 515 type="number" 516 inputMode="numeric" 517 min={0} 518 step={1} 519 value={ 520 field.value === undefined || field.value === null 521 ? "" 522 : String(field.value) 523 } 524 onChange={(e) => { 525 const v = e.target.value; 526 field.onChange(v === "" ? undefined : Number(v)); 527 }} 528 placeholder="未選択(全員表示)" 529 aria-label="可視しきい値" 530 data-testid="minPriority" 531 /> 532 </FormControl> 533 <FormDescription className="text-xs"> 534 未入力=全員に表示。指定すると「この値以上のロール」に表示します。 535 </FormDescription> 536 <FormMessage data-testid="minPriority-error" /> 537 </FormItem> 538 )} 539 /> 540 ); 541} 542 543function IsActiveField() { 544 return ( 545 <FormField 546 name="isActive" 547 render={({ field }) => ( 548 <FormItem className="mt-1 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> 549 <FormLabel className="font-semibold">有効&nbsp;*</FormLabel> 550 <FormControl> 551 <Switch 552 name={field.name} 553 checked={Boolean(field.value)} 554 onCheckedChange={field.onChange} 555 aria-label="有効" 556 data-testid="isActive" 557 /> 558 </FormControl> 559 <FormMessage data-testid="isActive-error" /> 560 </FormItem> 561 )} 562 /> 563 ); 564} 565 566function DisplayIdField() { 567 return ( 568 <FormField 569 name="displayId" 570 render={({ field }) => ( 571 <FormItem> 572 <FormLabel className="font-semibold">表示ID</FormLabel> 573 <FormControl> 574 <Input 575 {...field} 576 readOnly 577 aria-readonly="true" 578 data-testid="displayId" 579 className="bg-muted text-muted-foreground border-none focus-visible:ring-0" 580 /> 581 </FormControl> 582 <FormMessage data-testid="displayId-error" /> 583 </FormItem> 584 )} 585 /> 586 ); 587}

新規ページ(SSR):/masters/menus/new/page.tsx

ヘッダ(パンくず+SidebarTrigger)を付け、親候補は「(ルート) + 既存の見出し/リンク問わず全メニュー」を提示します。
アイコン候補は icons.map.ts のキーを列挙します。CSR 側 Client へ渡します。
tsx
1// src/app/(protected)/masters/menus/new/page.tsx 2import type { Metadata } from "next"; 3import { 4 Breadcrumb, 5 BreadcrumbItem, 6 BreadcrumbLink, 7 BreadcrumbList, 8 BreadcrumbPage, 9 BreadcrumbSeparator, 10} from "@/components/ui/breadcrumb"; 11import { Separator } from "@/components/ui/separator"; 12import { SidebarTrigger } from "@/components/ui/sidebar"; 13 14import Client from "./client"; 15import { getParentOptions } from "@/lib/sidebar/menu.mock"; 16import type { ParentOption, IconOption } from "@/components/menus/menu-form"; 17import { ICONS } from "@/lib/sidebar/icons.map"; 18 19export const metadata: Metadata = { 20 title: "メニュー新規登録 | 管理画面レイアウト【DELOGs】", 21 description: "サイドバーメニューを新規作成(UIのみ:モックストアへ保存)", 22}; 23 24export default async function Page() { 25 // 親候補((ルート) + 既存メニュー) 26 const parentOptions: ParentOption[] = getParentOptions(); 27 28 // アイコン候補(ICONS のキーを列挙) 29 const iconOptions: IconOption[] = Object.keys(ICONS) 30 .sort((a, b) => a.localeCompare(b)) // UX: アルファベット順に 31 .map((k) => ({ value: k, label: k })); 32 33 return ( 34 <> 35 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"> 36 <div className="flex items-center gap-2 px-4"> 37 <SidebarTrigger className="-ml-1" /> 38 <Separator 39 orientation="vertical" 40 className="mr-2 data-[orientation=vertical]:h-4" 41 /> 42 <Breadcrumb> 43 <BreadcrumbList> 44 <BreadcrumbItem className="hidden md:block"> 45 <BreadcrumbLink href="/masters">マスタ管理</BreadcrumbLink> 46 </BreadcrumbItem> 47 <BreadcrumbSeparator className="hidden md:block" /> 48 <BreadcrumbItem className="hidden md:block"> 49 <BreadcrumbLink href="/masters/menus"> 50 メニュー管理 51 </BreadcrumbLink> 52 </BreadcrumbItem> 53 <BreadcrumbSeparator className="hidden md:block" /> 54 <BreadcrumbItem> 55 <BreadcrumbPage>新規登録</BreadcrumbPage> 56 </BreadcrumbItem> 57 </BreadcrumbList> 58 </Breadcrumb> 59 </div> 60 </header> 61 62 <div className="max-w-2xl p-4 pt-0"> 63 <Client parentOptions={parentOptions} iconOptions={iconOptions} /> 64 </div> 65 </> 66 ); 67}

新規ページ(CSR):/masters/menus/new/client.tsx

送信時に addMenu() を呼び、モックストアへ保存します。成功トースト後、一覧 /masters/menus へ遷移します。
tsx
1// src/app/(protected)/masters/menus/new/client.tsx 2"use client"; 3 4import { useRouter } from "next/navigation"; 5import { toast } from "sonner"; 6 7import MenuForm from "@/components/menus/menu-form"; 8import { addMenu } from "@/lib/sidebar/menu.mock"; 9import type { MenuRecord, MenuCreateValues } from "@/lib/sidebar/menu.schema"; 10import type { ParentOption, IconOption } from "@/components/menus/menu-form"; 11 12type Props = { 13 parentOptions: ParentOption[]; 14 iconOptions: IconOption[]; 15}; 16 17export default function NewMenuClient({ parentOptions, iconOptions }: Props) { 18 const router = useRouter(); 19 20 return ( 21 <MenuForm 22 mode="create" 23 parentOptions={parentOptions} 24 iconOptions={iconOptions} 25 onSubmit={(values: MenuCreateValues) => { 26 // モックストアへ保存(order は addMenu 側で末尾採番) 27 const created: MenuRecord = addMenu({ 28 parentId: values.parentId, // null or displayId 29 title: values.title, 30 isSection: values.isSection, 31 href: values.isSection ? undefined : values.href, 32 match: values.isSection ? "prefix" : values.match, 33 pattern: values.isSection ? undefined : values.pattern, 34 iconName: values.iconName || undefined, 35 minPriority: values.minPriority, // number | undefined 36 isActive: values.isActive, 37 }); 38 39 toast.success("メニューを作成しました", { 40 description: `${created.displayId} / ${created.title}`, 41 }); 42 router.push("/masters/menus"); 43 }} 44 onCancel={() => history.back()} 45 /> 46 ); 47}

仕上げのチェックポイント

  • フォームの「見出し(isSection)」ONで href/match/pattern を自動無効化。
  • minPriority は空(未選択)を許容し、Zod で number | undefined に正規化。
  • 保存は addMenu() を経由して 兄弟末尾へ自動配置order 採番)。
  • 画面のヘッダは「パンくず+SidebarTrigger」を付与し、既存ページと統一。
  • 送信後はトースト表示 → 一覧 /masters/menus に遷移。
npm run dev/masters/menus/newにアクセスすると下図のようになります。
メニューの新規登録画面
次章では、このフォームをベースに 参照・更新・削除(/masters/menus/[displayId]) を実装します。

8. 編集ページ(/masters/menus/[displayId])

参照・更新・削除を行う画面を実装します。構成は 7 章と同じく SSR の page.tsxCSR の client.tsx の 2 段構成で、フォーム本体は共通の src/components/menus/menu-form.tsx を再利用します。
基本動作は次のとおりです。
・サーバ側(page.tsx)
  • URL パラメータ displayId で対象メニューを取得(見つからなければ 404)
  • 親候補(getParentOptions())とアイコン候補(ICONS のキー)を用意
  • 選択メニューの値を initialValues として client.tsx に渡す
・クライアント側(client.tsx)
  • <MenuForm mode="edit" ... /> を描画
  • 送信時は updateMenu() を呼び出して保存(order の正規化はモック側)
  • 「削除する」押下で deleteMenu() を実行
    • 子が存在する場合は deleteMenu()false を返すため、トーストで警告
  • 保存・削除後は一覧 /masters/menus へ遷移
入力仕様は 7 章と同じです。
  • 見出し(isSection=true)の時だけアイコン選択を表示
  • 見出し OFF の時だけ親選択/URL/一致方法/正規表現を表示
  • 親セレクトは (ルート) を出さない(= 見出し ON と同義)
  • minPriority は「未入力=全員表示」
以降、SSR と CSR の順に実装していきます。

編集ページ(SSR):/masters/menus/[displayId]/page.tsx

tsx
1// src/app/(protected)/masters/menus/[displayId]/page.tsx 2import type { Metadata } from "next"; 3import { 4 Breadcrumb, 5 BreadcrumbItem, 6 BreadcrumbLink, 7 BreadcrumbList, 8 BreadcrumbPage, 9 BreadcrumbSeparator, 10} from "@/components/ui/breadcrumb"; 11import { Separator } from "@/components/ui/separator"; 12import { SidebarTrigger } from "@/components/ui/sidebar"; 13 14import Client from "./client"; 15import { getMenuByDisplayId, getParentOptions } from "@/lib/sidebar/menu.mock"; 16import type { ParentOption, IconOption } from "@/components/menus/menu-form"; 17import { ICONS } from "@/lib/sidebar/icons.map"; 18import { notFound } from "next/navigation"; 19 20type Props = { 21 params: Promise<{ displayId: string }>; 22}; 23 24export const metadata: Metadata = { 25 title: "メニュー編集 | 管理画面レイアウト【DELOGs】", 26 description: "サイドバーメニューを編集(UIのみ:モックストアへ保存)", 27}; 28 29export default async function Page({ params }: Props) { 30 const { displayId } = await params; 31 32 // 対象取得(なければ 404) 33 const rec = getMenuByDisplayId(displayId); 34 if (!rec) notFound(); 35 36 // 親候補とアイコン候補 37 const parentOptions: ParentOption[] = getParentOptions(); 38 const iconOptions: IconOption[] = Object.keys(ICONS) 39 .sort((a, b) => a.localeCompare(b)) 40 .map((k) => ({ value: k, label: k })); 41 42 // RHF 既定に合わせて initialValues を準備 43 const initialValues = { 44 displayId: rec.displayId, 45 parentId: rec.parentId, 46 order: rec.order, 47 title: rec.title, 48 isSection: rec.isSection, 49 href: rec.href ?? "", 50 match: rec.match ?? "prefix", 51 pattern: rec.pattern ?? "", 52 iconName: rec.iconName ?? undefined, 53 minPriority: 54 rec.minPriority === null || rec.minPriority === undefined 55 ? undefined 56 : rec.minPriority, 57 isActive: rec.isActive, 58 }; 59 60 return ( 61 <> 62 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"> 63 <div className="flex items-center gap-2 px-4"> 64 <SidebarTrigger className="-ml-1" /> 65 <Separator 66 orientation="vertical" 67 className="mr-2 data-[orientation=vertical]:h-4" 68 /> 69 <Breadcrumb> 70 <BreadcrumbList> 71 <BreadcrumbItem className="hidden md:block"> 72 <BreadcrumbLink href="/masters">マスタ管理</BreadcrumbLink> 73 </BreadcrumbItem> 74 <BreadcrumbSeparator className="hidden md:block" /> 75 <BreadcrumbItem className="hidden md:block"> 76 <BreadcrumbLink href="/masters/menus"> 77 メニュー管理 78 </BreadcrumbLink> 79 </BreadcrumbItem> 80 <BreadcrumbSeparator className="hidden md:block" /> 81 <BreadcrumbItem> 82 <BreadcrumbPage>編集</BreadcrumbPage> 83 </BreadcrumbItem> 84 </BreadcrumbList> 85 </Breadcrumb> 86 </div> 87 </header> 88 89 <div className="max-w-2xl p-4 pt-0"> 90 <Client 91 initialValues={initialValues} 92 parentOptions={parentOptions} 93 iconOptions={iconOptions} 94 /> 95 </div> 96 </> 97 ); 98}

編集ページ(CSR):/masters/menus/[displayId]/client.tsx

tsx
1// src/app/(protected)/masters/menus/[displayId]/client.tsx 2"use client"; 3 4import { useRouter } from "next/navigation"; 5import { toast } from "sonner"; 6 7import MenuForm from "@/components/menus/menu-form"; 8import { updateMenu, deleteMenu } from "@/lib/sidebar/menu.mock"; 9import type { MenuRecord, MenuUpdateValues } from "@/lib/sidebar/menu.schema"; 10import type { ParentOption, IconOption } from "@/components/menus/menu-form"; 11 12type Props = { 13 initialValues: MenuUpdateValues; 14 parentOptions: ParentOption[]; 15 iconOptions: IconOption[]; 16}; 17 18export default function EditMenuClient({ 19 initialValues, 20 parentOptions, 21 iconOptions, 22}: Props) { 23 const router = useRouter(); 24 25 return ( 26 <MenuForm 27 mode="edit" 28 initialValues={initialValues} 29 parentOptions={parentOptions} 30 iconOptions={iconOptions} 31 onSubmit={(values: MenuUpdateValues) => { 32 // 更新(親変更や isSection 切り替えも許容) 33 const ok = updateMenu({ 34 displayId: values.displayId, 35 parentId: values.isSection ? null : values.parentId, 36 order: values.order, 37 title: values.title, 38 isSection: values.isSection, 39 href: values.isSection ? undefined : values.href, 40 match: values.isSection ? "prefix" : values.match, 41 pattern: values.isSection ? undefined : values.pattern, 42 iconName: values.iconName || undefined, 43 minPriority: values.minPriority, 44 isActive: values.isActive, 45 } as MenuRecord); 46 47 if (!ok) { 48 toast.error("更新に失敗しました"); 49 return; 50 } 51 52 toast.success("メニューを更新しました", { 53 description: `${values.displayId} / ${values.title}`, 54 }); 55 router.push("/masters/menus"); 56 }} 57 onCancel={() => history.back()} 58 onDelete={() => { 59 const ok = deleteMenu(initialValues.displayId); 60 if (!ok) { 61 toast.warning("配下にメニューがあるため削除できません"); 62 return; 63 } 64 toast.success("メニューを削除しました", { 65 description: initialValues.displayId, 66 }); 67 router.push("/masters/menus"); 68 }} 69 /> 70 ); 71}
npm run dev/masters/menus/M00000014など、src/lib/sidebar/menu.mock.tsで設定したdisplayIdをつけてアクセスすると下図のようになります。
メニューの編集画面
次章では一覧ページ(Data Table)の実装に進み、検索・ソート・並び替え(↑↓)までを仕上げます。

9. 一覧ページ(/masters/menus)

メニューの一覧画面を実装します。7章・8章と同じく「SSR の page.tsx → 'use client' の DataTable」という構成で、列定義は columns.tsx に切り出します。
今回の一覧はロールと違い“階層(親→子→孫)”と“兄弟間の並び順(↑↓)”がポイントです。削除は一覧には出さず、編集画面のダイアログのみで行います。並び順変更は兄弟内でのみ有効です。変更後はモックストア(menu.mock.ts)の swapOrder() を呼んでから再取得して即時反映します。
画面の基本構成は以下のとおりです
  • ヘッダ:パンくず+SidebarTrigger
  • 検索:表示ID・タイトル・パス(href)に対する単純な文字列検索
  • 状態フィルタ:有効/無効/すべて
  • テーブル:操作(編集)/表示ID/タイトル(インデント表示)/Path/一致(exact/prefix/regex)/可視しきい値(minPriority)/順序(↑↓)/状態
  • 新規登録ボタン:/masters/menus/new
実装は「型拡張 → 列定義 → DataTable 本体 → SSR ページ」の順に進めます。

型拡張

これから作成する一覧では「↑↓」ボタンでメニュー表示順の並び換え機能を用意します。これは、行そのものではなく「テーブル全体の再配置処理(兄弟swap→正規化→再取得→再描画)」を呼び出します。
このハンドラを columns.tsx 側に直書きしてしまうと、カラム定義がストアやルーターに強く依存して密結合(保守が面倒)になります。
shadcn/uiのデータテーブルは@tanstack/react-tableを裏側で利用しています。@tanstack/react-table では、テーブル全体から列定義へ“お作法”で値を渡すために TableMeta という拡張ポイントが用意されています。
ここに 「↑↓」ボタンの並び替えボタンを押したときに呼ばれるイベントハンドラであるonMoveUp / onMoveDown を載せると、カラム定義ファイル と データテーブル本体のコード をゆるくつなぎつつ、安全で拡張しやすい設計になります。
src/types/table-meta.d.tsを下記の内容で作成します。
ts
1// src/types/table-meta.d.ts 2import "@tanstack/table-core"; 3 4declare module "@tanstack/table-core" { 5 interface TableMeta<TData extends RowData> { 6 onMoveUp?: (id: string, _row?: TData) => void; 7 onMoveDown?: (id: string, _row?: TData) => void; 8 } 9} 10 11export {};
なお、これは TypeScript のモジュール拡張(宣言マージ)という正攻法です。 ライブラリ本体を改変しているわけではなく、アプリ側の ambient 型宣言として 「うちのプロジェクトでは TableMeta にこういうキーがあるよ」と型だけ足しています。
ランタイムに余計なコードは増えず、Next.js / App Router でも問題なく機能します。

列定義(columns.tsx)

tsx
1/* =============================================== 2 src/app/(protected)/masters/menus/columns.tsx 3 =============================================== */ 4"use client"; 5 6import Link from "next/link"; 7import type { ColumnDef } from "@tanstack/react-table"; 8import { ArrowDown, ArrowUp, SquarePen } from "lucide-react"; 9import { Badge } from "@/components/ui/badge"; 10import { Button } from "@/components/ui/button"; 11import { 12 Tooltip, 13 TooltipContent, 14 TooltipTrigger, 15} from "@/components/ui/tooltip"; 16import type { MenuRecord, MatchMode } from "@/lib/sidebar/menu.schema"; 17 18/** 状態フィルタ型(列の filterFn と揃える) */ 19export type StatusFilter = "ALL" | "ACTIVE" | "INACTIVE"; 20 21/** インデント表示(0/1/2) */ 22// 孫レベルを強めに、子は中くらいに 23function IndentedTitle({ title, depth }: { title: string; depth: number }) { 24 const cls = depth === 0 ? "" : depth === 1 ? "pl-4" : "pl-10"; 25 return <div className={cls}>{title}</div>; 26} 27 28/** 一致方法の短縮バッジ表示 */ 29function MatchBadge({ mode }: { mode: MatchMode | undefined }) { 30 const m = mode ?? "prefix"; 31 if (m === "exact") return <Badge variant="secondary">exact</Badge>; 32 if (m === "regex") return <Badge variant="outline">regex</Badge>; 33 return <Badge>prefix</Badge>; 34} 35 36export const columns: ColumnDef< 37 MenuRecord & { depth: number; canUp: boolean; canDown: boolean } 38>[] = [ 39 { 40 id: "actions", 41 header: "操作", 42 size: 40, 43 enableResizing: false, 44 enableSorting: false, 45 cell: ({ row }) => ( 46 <Tooltip> 47 <TooltipTrigger asChild> 48 <Button 49 asChild 50 size="icon" 51 variant="outline" 52 data-testid={`edit-${row.original.displayId}`} 53 className="size-8 cursor-pointer" 54 > 55 <Link href={`/masters/menus/${row.original.displayId}`}> 56 <SquarePen /> 57 </Link> 58 </Button> 59 </TooltipTrigger> 60 <TooltipContent> 61 <p>参照・編集</p> 62 </TooltipContent> 63 </Tooltip> 64 ), 65 }, 66 { 67 accessorKey: "displayId", 68 header: "表示ID", 69 size: 80, 70 enableResizing: false, 71 cell: ({ row }) => ( 72 <span className="font-mono">{row.original.displayId}</span> 73 ), 74 }, 75 { 76 accessorKey: "title", 77 header: "タイトル", 78 cell: ({ row }) => ( 79 <IndentedTitle title={row.original.title} depth={row.original.depth} /> 80 ), 81 }, 82 { 83 accessorKey: "href", 84 header: "Path", 85 cell: ({ row }) => 86 row.original.isSection ? ( 87 <span className="text-muted-foreground"></span> 88 ) : ( 89 <span className="font-mono">{row.original.href}</span> 90 ), 91 }, 92 { 93 id: "match", 94 header: "一致", 95 size: 80, 96 enableResizing: false, 97 enableSorting: false, 98 cell: ({ row }) => 99 row.original.isSection ? ( 100 <span className="text-muted-foreground"></span> 101 ) : ( 102 <MatchBadge mode={row.original.match} /> 103 ), 104 }, 105 { 106 accessorKey: "minPriority", 107 header: "しきい値", 108 size: 80, 109 enableResizing: false, 110 cell: ({ row }) => 111 row.original.minPriority === undefined ? ( 112 <span className="text-muted-foreground">(全員)</span> 113 ) : ( 114 <span className="font-mono tabular-nums"> 115 {row.original.minPriority} 116 </span> 117 ), 118 sortingFn: (a, b, id) => { 119 const av = a.getValue(id) as number | undefined; 120 const bv = b.getValue(id) as number | undefined; 121 if (av === undefined && bv === undefined) return 0; 122 if (av === undefined) return -1; // 未設定(全員)を小さく 123 if (bv === undefined) return 1; 124 return av === bv ? 0 : av > bv ? 1 : -1; 125 }, 126 }, 127 { 128 id: "order", 129 header: "順序", 130 size: 90, 131 enableResizing: false, 132 enableSorting: false, 133 cell: ({ row, table }) => { 134 const { canUp, canDown } = row.original; 135 const onUp = table.options.meta?.onMoveUp; 136 const onDown = table.options.meta?.onMoveDown; 137 return ( 138 <div className="flex gap-1"> 139 <Button 140 size="icon" 141 variant="outline" 142 className="size-8 cursor-pointer" 143 onClick={() => onUp?.(row.original.displayId)} 144 disabled={!canUp} 145 aria-label="ひとつ上へ" 146 > 147 <ArrowUp /> 148 </Button> 149 <Button 150 size="icon" 151 variant="outline" 152 className="size-8 cursor-pointer" 153 onClick={() => onDown?.(row.original.displayId)} 154 disabled={!canDown} 155 aria-label="ひとつ下へ" 156 > 157 <ArrowDown /> 158 </Button> 159 </div> 160 ); 161 }, 162 }, 163 { 164 accessorKey: "isActive", 165 header: "状態", 166 size: 70, 167 enableResizing: false, 168 cell: ({ row }) => 169 row.original.isActive ? ( 170 <Badge data-testid="badge-active">有効</Badge> 171 ) : ( 172 <Badge variant="outline" data-testid="badge-inactive"> 173 無効 174 </Badge> 175 ), 176 filterFn: (row, _id, value: StatusFilter) => 177 value === "ALL" 178 ? true 179 : value === "ACTIVE" 180 ? row.original.isActive 181 : !row.original.isActive, 182 }, 183 // 検索用の hidden 列(displayId / title / href を結合) 184 { 185 id: "q", 186 accessorFn: (r) => 187 `${r.displayId} ${r.title} ${r.href ?? ""}`.toLowerCase(), 188 enableHiding: true, 189 enableSorting: false, 190 enableResizing: false, 191 size: 0, 192 header: () => null, 193 cell: () => null, 194 }, 195];

💡ポイント

  • 責務分離:columns.tsx は「どう表示するか」だけに専念し、処理(↑↓ボタン押下時の再配置)は TableMeta 経由で data-table.tsx に委譲しています。
  • インデント表示:親子関係に応じて pl-4 / pl-10 を付与し、子と孫を視覚的に区別できるようにしています。
  • 順序カラム:メニュー兄弟間の並び替え専用。table.options.meta に載せたハンドラを呼び出し、行側には canUp / canDown の可否フラグを付与してボタンの無効化を制御します。

DataTable 本体(data-table.tsx)

tsx
1/* ======================================================= 2 src/app/(protected)/masters/menus/data-table.tsx 3 ======================================================= */ 4"use client"; 5 6import * as React from "react"; 7import type { ColumnDef } from "@tanstack/react-table"; 8import { 9 flexRender, 10 getCoreRowModel, 11 getPaginationRowModel, 12 useReactTable, 13} from "@tanstack/react-table"; 14import Link from "next/link"; 15import { useRouter } from "next/navigation"; 16import { Input } from "@/components/ui/input"; 17import { 18 Select, 19 SelectContent, 20 SelectItem, 21 SelectTrigger, 22 SelectValue, 23} from "@/components/ui/select"; 24import { 25 Table, 26 TableBody, 27 TableCell, 28 TableHead, 29 TableHeader, 30 TableRow, 31} from "@/components/ui/table"; 32import { Button } from "@/components/ui/button"; 33import type { MenuRecord } from "@/lib/sidebar/menu.schema"; 34import type { StatusFilter } from "./columns"; 35import { getMenus, swapOrder } from "@/lib/sidebar/menu.mock"; 36 37function orderHierarchically(list: MenuRecord[]): MenuRecord[] { 38 // parentId -> children[] の索引を作る(order順で保持) 39 const byParent = new Map<string | null, MenuRecord[]>(); 40 for (const r of list) { 41 const key = r.parentId ?? null; 42 const arr = byParent.get(key) ?? []; 43 arr.push(r); 44 byParent.set(key, arr); 45 } 46 for (const [, arr] of byParent) { 47 arr.sort((a, b) => a.order - b.order); 48 } 49 50 const out: MenuRecord[] = []; 51 const walk = (parentId: string | null) => { 52 const children = byParent.get(parentId) ?? []; 53 for (const c of children) { 54 out.push(c); 55 walk(c.displayId); // 孫以降も辿る(最大3層想定) 56 } 57 }; 58 walk(null); 59 return out; 60} 61 62/** 深さ(0/1/2)を計算するユーティリティ(既存) */ 63function calcDepthMap(rows: MenuRecord[]): Map<string, number> { 64 const parentById = new Map<string, string | null>(); 65 rows.forEach((r) => parentById.set(r.displayId, r.parentId)); 66 67 const depthById = new Map<string, number>(); 68 const depthOf = (id: string | null | undefined): number => { 69 if (!id) return 0; // ← ルートは 0 にする 70 if (depthById.has(id)) return depthById.get(id)!; 71 const p = parentById.get(id); 72 const d = p ? 1 + depthOf(p) : 0; // ← 親がいれば +1、なければ 0 73 depthById.set(id, d); 74 return d; 75 }; 76 77 rows.forEach((r) => depthOf(r.displayId)); 78 return depthById; 79} 80 81/** 兄弟内の ↑↓ 可否を付与(既存) */ 82function withMoveFlags(rows: MenuRecord[]) { 83 const byParent = new Map<string | null, MenuRecord[]>(); 84 rows.forEach((r) => { 85 const key = r.parentId ?? null; 86 const arr = byParent.get(key) ?? []; 87 arr.push(r); 88 byParent.set(key, arr); 89 }); 90 const flags = new Map<string, { canUp: boolean; canDown: boolean }>(); 91 for (const [, arr] of byParent) { 92 arr 93 .slice() 94 .sort((a, b) => a.order - b.order) 95 .forEach((r, i, list) => { 96 flags.set(r.displayId, { 97 canUp: i > 0, 98 canDown: i < list.length - 1, 99 }); 100 }); 101 } 102 return rows.map((r) => ({ 103 ...r, 104 ...(flags.get(r.displayId) ?? { canUp: false, canDown: false }), 105 })); 106} 107 108type Props<TData> = { 109 columns: ColumnDef<TData, unknown>[]; 110 data: MenuRecord[]; // SSR 初期データ 111 newPath: string; 112}; 113 114export default function MenusDataTable<TData extends MenuRecord>({ 115 columns, 116 data, 117 newPath, 118}: Props<TData>) { 119 const router = useRouter(); 120 121 // 初期データを階層順に整列してからセット 122 const [rows, setRows] = React.useState<MenuRecord[]>( 123 orderHierarchically(data), 124 ); 125 126 // UI 状態 127 const [q, setQ] = React.useState(""); 128 const [status, setStatus] = React.useState<StatusFilter>("ALL"); 129 130 // ① フィルタを復活(並びは rows のまま=階層順を維持) 131 const filtered = React.useMemo(() => { 132 const needle = q.trim().toLowerCase(); 133 return rows.filter((r) => { 134 const passQ = 135 !needle || 136 `${r.displayId} ${r.title} ${r.href ?? ""}` 137 .toLowerCase() 138 .includes(needle); 139 const passStatus = 140 status === "ALL" || 141 (status === "ACTIVE" ? r.isActive === true : r.isActive === false); 142 return passQ && passStatus; 143 }); 144 }, [rows, q, status]); 145 146 // ② 深さ&↑↓可否は filtered を入力にする(順序は rows と同じまま) 147 const enriched = React.useMemo(() => { 148 const depthMap = calcDepthMap(rows); // 深さ計算は全体の親子関係でOK 149 const withDepth = filtered.map((r) => ({ 150 ...r, 151 depth: Math.min(depthMap.get(r.displayId) ?? 0, 2), 152 })); 153 return withMoveFlags(withDepth); 154 }, [rows, filtered]); 155 156 // 並び替え 157 const handleMoveUp = (id: string) => { 158 swapOrder(id, "up"); // ストア更新 159 setRows(orderHierarchically(getMenus())); // ★ 階層順で再整列 160 router.refresh(); 161 }; 162 163 const handleMoveDown = (id: string) => { 164 swapOrder(id, "down"); 165 setRows(orderHierarchically(getMenus())); // ★ 166 router.refresh(); 167 }; 168 // ★ 並び替えモデルを使わない(ストア順=表示順) 169 const table = useReactTable({ 170 data: enriched as unknown as TData[], 171 columns, 172 getCoreRowModel: getCoreRowModel(), 173 getPaginationRowModel: getPaginationRowModel(), 174 // 初期ページサイズを30に 175 initialState: { pagination: { pageIndex: 0, pageSize: 30 } }, 176 // コールバックを meta に載せる(型は table-core の宣言マージで拡張済み) 177 meta: { 178 onMoveUp: handleMoveUp, 179 onMoveDown: handleMoveDown, 180 }, 181 }); 182 183 return ( 184 <div className="space-y-3"> 185 {/* 検索/フィルタ */} 186 <div className="flex flex-wrap items-center gap-3"> 187 <Input 188 name="filter-q" 189 data-testid="filter-q" 190 value={q} 191 onChange={(e) => setQ(e.target.value)} 192 placeholder="表示ID・タイトル・Pathで検索" 193 className="w-[260px] basis-full text-sm md:basis-auto" 194 aria-label="検索キーワード" 195 /> 196 197 <Select 198 value={status} 199 onValueChange={(v) => setStatus(v as StatusFilter)} 200 name="filter-status" 201 > 202 <SelectTrigger className="w-auto" data-testid="filter-status"> 203 <SelectValue placeholder="状態" /> 204 </SelectTrigger> 205 <SelectContent> 206 <SelectItem value="ALL"> 207 <span className="text-muted-foreground">すべての状態</span> 208 </SelectItem> 209 <SelectItem value="ACTIVE">有効のみ</SelectItem> 210 <SelectItem value="INACTIVE">無効のみ</SelectItem> 211 </SelectContent> 212 </Select> 213 </div> 214 215 <div className="flex items-center justify-between"> 216 <div className="text-sm" data-testid="count"> 217 表示件数: {filtered.length}218 </div> 219 <Button asChild> 220 <Link href={newPath}>新規登録</Link> 221 </Button> 222 </div> 223 224 {/* テーブル */} 225 <div className="overflow-x-auto rounded-md border pb-1"> 226 <Table data-testid="menus-table" className="w-full"> 227 <TableHeader className="bg-muted/50 text-xs"> 228 {table.getHeaderGroups().map((hg) => ( 229 <TableRow key={hg.id}> 230 {hg.headers.map((header) => ( 231 <TableHead 232 key={header.id} 233 style={{ width: header.column.getSize() }} 234 > 235 {header.isPlaceholder 236 ? null 237 : flexRender( 238 header.column.columnDef.header, 239 header.getContext(), 240 )} 241 </TableHead> 242 ))} 243 </TableRow> 244 ))} 245 </TableHeader> 246 <TableBody> 247 {table.getRowModel().rows.length ? ( 248 table.getRowModel().rows.map((row) => ( 249 <TableRow 250 key={row.id} 251 data-testid={`row-${(row.original as MenuRecord).displayId}`} 252 > 253 {row.getVisibleCells().map((cell) => ( 254 <TableCell 255 key={cell.id} 256 style={{ width: cell.column.getSize() }} 257 > 258 {flexRender( 259 cell.column.columnDef.cell, 260 cell.getContext(), 261 )} 262 </TableCell> 263 ))} 264 </TableRow> 265 )) 266 ) : ( 267 <TableRow> 268 <TableCell 269 colSpan={columns.length} 270 className="text-muted-foreground py-10 text-center text-sm" 271 > 272 条件に一致するメニューが見つかりませんでした。 273 </TableCell> 274 </TableRow> 275 )} 276 </TableBody> 277 </Table> 278 </div> 279 280 {/* ページング */} 281 <div className="flex items-center justify-end gap-2"> 282 <span className="text-muted-foreground text-sm"> 283 Page {table.getState().pagination.pageIndex + 1} /{" "} 284 {table.getPageCount() || 1} 285 </span> 286 <Button 287 variant="outline" 288 size="sm" 289 onClick={() => table.previousPage()} 290 disabled={!table.getCanPreviousPage()} 291 data-testid="page-prev" 292 className="cursor-pointer" 293 > 294 前へ 295 </Button> 296 <Button 297 variant="outline" 298 size="sm" 299 onClick={() => table.nextPage()} 300 disabled={!table.getCanNextPage()} 301 data-testid="page-next" 302 className="cursor-pointer" 303 > 304 次へ 305 </Button> 306 </div> 307 </div> 308 ); 309}

💡ポイント

  • 階層順の安定化:orderHierarchically() で親→子→孫の順にソートしてからテーブルへ渡し、検索やフィルタ後も階層順が崩れないようにしています。
  • 深さ計算:calcDepthMap() で親をたどって深さを算出。最大 2 (=孫) までに制限して視覚化に利用しています。
  • 移動処理:swapOrder() → getMenus() 再取得 → setRows() の流れで即時反映。router.refresh() も呼び出すことでサイドバー表示順も揃います。
  • フィルタリング:検索キーワードと状態フィルタを同時に適用し、結果件数をヘッダにリアルタイム表示します。

SSR ページ(page.tsx)

tsx
1/* ================================================== 2 src/app/(protected)/masters/menus/page.tsx 3 ================================================== */ 4import type { Metadata } from "next"; 5import { SidebarTrigger } from "@/components/ui/sidebar"; 6import { 7 Breadcrumb, 8 BreadcrumbItem, 9 BreadcrumbLink, 10 BreadcrumbList, 11 BreadcrumbPage, 12 BreadcrumbSeparator, 13} from "@/components/ui/breadcrumb"; 14import { Separator } from "@/components/ui/separator"; 15 16import MenusDataTable from "./data-table"; 17import { columns } from "./columns"; 18import { getMenus } from "@/lib/sidebar/menu.mock"; 19 20export const metadata: Metadata = { 21 title: "メニュー一覧 | 管理画面レイアウト【DELOGs】", 22 description: 23 "サイドバーメニューの一覧。階層表示と兄弟間の↑↓入れ替えに対応(UIのみ、モックストア連携)。", 24}; 25 26export default async function Page() { 27 // UIのみ:モックから取得(親→子→孫の安定ソートは mock 側の getMenus() に準拠) 28 const menus = getMenus(); 29 30 return ( 31 <> 32 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"> 33 <div className="flex items-center gap-2 px-4"> 34 <SidebarTrigger className="-ml-1" /> 35 <Separator 36 orientation="vertical" 37 className="mr-2 data-[orientation=vertical]:h-4" 38 /> 39 <Breadcrumb> 40 <BreadcrumbList> 41 <BreadcrumbItem className="hidden md:block"> 42 <BreadcrumbLink href="/masters">マスタ管理</BreadcrumbLink> 43 </BreadcrumbItem> 44 <BreadcrumbSeparator className="hidden md:block" /> 45 <BreadcrumbItem> 46 <BreadcrumbPage>メニュー一覧</BreadcrumbPage> 47 </BreadcrumbItem> 48 </BreadcrumbList> 49 </Breadcrumb> 50 </div> 51 </header> 52 53 <div className="max-w-full p-4 pt-0"> 54 <MenusDataTable 55 columns={columns} 56 data={menus} 57 newPath="/masters/menus/new" 58 /> 59 </div> 60 </> 61 ); 62}

動作とポイント

  • タイトル列は深さに応じてインデントします(0=親、1=子、2=孫)。深さはクライアント側で parentId をたどって算出しています。
  • 順序(↑↓)は兄弟単位でのみ入れ替え可能です。クリック時に swapOrder() を呼び、直後に getMenus() を再取得してテーブルを更新します。
  • 検索は表示ID/タイトル/Path を素朴に合成したテキストで行い、状態フィルタは有効/無効を切り替えます。検索結果件数はヘッダ部にリアルタイムで表示されます。
  • 一致方法は exact / prefix / regex を小さなバッジで示し、セクション(見出し)は Path列と一致列を「—」で表すようにしています。
  • ページングは初期状態で30件/ページに設定してあり、大量データでも一度に確認しやすくしています。
npm run dev/masters/menusにアクセスすると下図のようになります。
メニューの一覧画面

10.まとめ

今回の記事で、管理画面のUIフォーマットがほぼ完成しました。バックエンド開発で、各種mock.tsにあるデータをテーブルから取得するように置き換えやすく整えたつもりです。 次回は、「404ページとパスワードリセット導線UI」の予定です。これが終われば、デモサイトで公開予定です。もちろん、完成形のデータも別途Githubにアップする予定です。

参考文献

今回の一覧実装で触れた要素(@tanstack/react-table の型拡張、shadcn/ui テーブル構成、TypeScript の宣言マージ)に関連する一次情報や公式ドキュメントを整理しておきます。
コードの引用は最小限に留め、実際の実装に役立つリンク中心としました。

Githubリポジトリ

この記事で作成した内容は下記のGithubリポジトリにアップしています。ご参考にどうぞ。
この記事の執筆・編集担当
DE

松本 孝太郎

DELOGs編集部/中年新米プログラマー

ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。