![[管理画面フォーマット制作編 #7] サイドバーメニュー管理UI ─ 3層・並び順・priority可視制御まで](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-menu-ui%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット制作編 #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 | 想定 |
---|---|---|
ADMIN | 100 | 最高権限 |
EDITOR | 50 | 編集者 |
ANALYST | 20 | 分析担当 |
VIEWER | 10 | 閲覧者 |
例:
例:親カテゴリ「設定」に
minPriority: 20
を指定したメニューは ANALYST / EDITOR / ADMIN に表示され、VIEWERには表示されません。例:親カテゴリ「設定」に
minPriority: 100
を設定した場合、配下のメニューも管理者専用になります(継承・上書き不可)。技術スタックと前提環境
前提とする技術スタックとコードスタイルを確認します。既存の書き方を踏襲します。
Tool / Lib | Version | Purpose |
---|---|---|
React | 19.x | UIの土台。コンポーネント/フックで状態と表示を組み立てる |
Next.js | 15.x | App Router/SSR/SSG、動的ルーティング、メタデータ管理 |
TypeScript | 5.x | 型安全・補完・リファクタリング |
shadcn/ui | latest | RadixベースのUIキット |
Tailwind CSS | 4.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
)
本記事の進行順はつぎのとおりです。
- モックストア(menu.mock.ts):
INITIAL_MENU_RECORDS
(UI唯一ソース)と CRUD/並び替え - 新規ページ(/masters/menus/new):親・見出し/リンク・一致方法・アイコン・
minPriority
入力(pattern
は詳細設定でデフォルト非表示) - 編集ページ(/masters/menus/[displayId]):参照・更新・削除(子がいる場合の削除ガード)
- 一覧ページ(/masters/menus):Data Table で表示(フィルタ/検索/ソート/↑↓)
- 変換レイヤ:
MenuRecord[] → MenuTree
生成(icons.map.ts
で iconName を LucideIcon に解決) 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[] → MenuTree
(iconName → LucideIcon
、pattern → RegExp
、minPriority
の親継承適用)
- アイコン辞書:
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.tsx | Data Table、インデント表示、兄弟内 order スワップ、パンくず+SidebarTrigger |
/masters/menus/new | 新規作成 | SSR: page.tsx → CSR: client.tsx | 親選択、セクション/リンク、href ・match ・(詳細設定で)pattern 、iconName 、minPriority |
/masters/menus/[displayId] | 参照・更新・削除 | SSR: page.tsx → CSR: client.tsx | new と同等+削除ガード(子がいる場合は不可) |
仕様の確定事項:親
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)
5│
6├─ new/
7│ ├─ page.tsx // SSR: 初期値・選択肢(親/アイコン等)取得 → <Client />
8│ └─ client.tsx // 'use client': <MenuForm /> を描画し送信
9│
10└─ [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.ts
のMenuRecord[]
を読み書き(将来は関数の中身だけ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)
前章で
ここでは、そのデータを正しく型安全に扱うための スキーマ定義とZodによるバリデーション を実装します。
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
の場合はhref
とpattern
を禁止し、見出し専用ノードとする。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
を 必須→任意 に変更。scoreFor
で href 未定義ならスコア 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 の
入力項目は「親選択」「見出し/リンク切替」「href/一致方式(regexは詳細設定)」「アイコン」「minPriority(未選択=全員表示)」「有効/無効」です。兄弟内
page.tsx
と CSR の client.tsx
に分離し、フォーム本体は共通コンポーネント src/components/masters/menus/menu-form.tsx
を用います。入力項目は「親選択」「見出し/リンク切替」「href/一致方式(regexは詳細設定)」「アイコン」「minPriority(未選択=全員表示)」「有効/無効」です。兄弟内
order
は 入力させず末尾に自動付与 し、保存はモックストア addMenu()
を呼び出します。なお、RHF + Zod のパターンをロール管理に合わせるため、
menu.schema.ts
に Create/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)
ロール管理の
本章では Create モードのみ使用。親セレクトは
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">親メニュー *</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">タイトル *</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 見出し(トップレベル) *
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">有効 *</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.tsx
と CSR の 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 の宣言マージ)に関連する一次情報や公式ドキュメントを整理しておきます。コードの引用は最小限に留め、実際の実装に役立つリンク中心としました。
-
TypeScript Handbook - Declaration Merging
declare module
を使ったモジュール拡張の仕組み。今回のTableMeta
型追加はこの機能を応用しました。 -
Lucide React アイコン集
今回の「↑↓」ボタンや編集アイコン(SquarePen)は Lucide のアイコンを利用しました。
Githubリポジトリ
この記事で作成した内容は下記のGithubリポジトリにアップしています。ご参考にどうぞ。
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
▼ 関連記事
[管理画面フォーマット制作編 #9] Shadcn/ui で作る管理画面フォーマット ─ デモ公開とカスタマイズ方法
これまで進めてきたログイン画面、ユーザー管理、ロール管理、サイドバー管理などをまとめ、「UIのみ版」デモを公開
2025/9/4公開
![[管理画面フォーマット制作編 #9] Shadcn/ui で作る管理画面フォーマット ─ デモ公開とカスタマイズ方法のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fdashboard-format-ui-demo%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #8] ログイン後404ページ + ログイン前のパスワード忘れ導線UI
管理画面に「ログイン後の404ページ」と、ログイン前にユーザが管理者へ依頼できる「パスワード忘れ導線UI」を追加
2025/9/2公開
![[管理画面フォーマット制作編 #8] ログイン後404ページ + ログイン前のパスワード忘れ導線UIのイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-404-password-forgot%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #6] マスタ管理-ロール管理(UIのみ)
ロールテーブルを管理画面から操作するためのUIを、Next.js 15 + shadcn/ui + React Hook Form + Zodで実装
2025/8/26公開
![[管理画面フォーマット制作編 #6] マスタ管理-ロール管理(UIのみ)のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-role-ui%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #5] ユーザープロフィールUI ─ 情報確認・編集・パスワード変更
管理画面に「プロフィール」ページを追加し、ユーザ自身が情報やパスワードを更新できるUIを作成
2025/8/22公開
![[管理画面フォーマット制作編 #5] ユーザープロフィールUI ─ 情報確認・編集・パスワード変更のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fuser-profile-ui%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #4] サイドバーのメニューと参照中ページの同期
Next.js App Router + shadcn/ui のサイドバーで「いま見ているページ」を正しくハイライト
2025/8/19公開
![[管理画面フォーマット制作編 #4] サイドバーのメニューと参照中ページの同期のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fsidebar-active-sync%2Fhero-thumbnail.jpg&w=1200&q=75)