ASP.NET Web Formsでモーダル内DropDownListの選択値をDataTableに追加しGridView/ListViewへ表示する方法

ASP.NET Web Formsで、モーダル内のDropDownListで選んだ値を「追加ボタン」でDataTableへ溜め、GridView/ListViewへ表示したい――この要件は“初回はDBを読み、状況によりDBとメモリの両方を同期する”設計にすると破綻しません。本記事では、重複防止・削除・DBあり/なし分岐まで含めて実装の型をまとめます。

目次

よくあるつまずきポイントと結論

モーダル内のDropDownListで選んだ値を、画面上で一時的に溜めて一覧表示する場合、Web Formsでは「ポストバックのたびに状態が消える」「重複が入る」「削除はできたがDBと表示がズレる」といった問題が起きがちです。ポイントは、DataTableを“画面の状態”として保持し、さらにDBに既存レコードがあるかどうかで処理を分岐させることです。

この記事のゴールは次の要件を、破綻しない形で満たすことです。

  • 初回表示でDBを確認し、既存レコードがあればそれを一覧表示
  • DBが空なら、DropDownList+AddボタンでDataTableへ追加して一覧表示
  • DBにレコードがある状態で追加/削除したら、DBとDataTableの両方へ反映
  • DropDownListのSelectedIndexChangedではなく、Addボタン押下で追加

処理の全体像

実装は、主に3つのイベントで完結します。イベントごとに役割を固定すると、仕様追加が入っても崩れにくくなります。

タイミングイベントやること
初回表示Page_Loadの!IsPostBackDB→DataTable生成、DBが空か判定、一覧へDataBind
追加AddボタンのClick選択値取得、重複チェック、ルールに応じてDB/DT更新、再バインド
削除GridView RowDeletingまたはListView ItemCommandキー取得、ルールに応じてDB/DT削除、再バインド

要件の分岐をDBモードとメモリモードで整理する

最初に「DBが空かどうか」を判定して、以降の挙動を2モードに分けると迷いません。

モード判定条件追加削除おすすめの用途
メモリモードDBから取得したDataTableが0件DataTableだけ更新DataTableだけ更新一時的な選択や最後にまとめて保存する画面
DBモードDBから取得したDataTableが1件以上INSERTしてからDataTableへ反映または再取得DELETEしてからDataTableから削除または再取得一覧が既に永続化されたデータとして扱われる画面

「DBモードで削除を繰り返して0件になったら、以降はメモリモードに切り替えるべきか?」は要件次第です。一般的には、初回判定でモードを固定してしまう方が動作が安定します。途中で切り替えると保存先のルールが変わり、ユーザーにも開発者にも分かりづらくなるためです。

DataTable設計で重複防止を簡単にする

DropDownListで選択した値を溜めるだけなら、最低限「ID」と「表示名」の2列で十分です。重複チェックを毎回DataTable.Select()で書くこともできますが、PrimaryKeyを設定してdt.Rows.Find()を使うと高速で読みやすくなります。

private DataTable CreateTable()
{
    var dt = new DataTable();
    dt.Columns.Add("ProdId", typeof(int));
    dt.Columns.Add("ProdName", typeof(string));
    dt.Columns.Add("AddedAt", typeof(DateTime)); // 任意:追加日時を表示したい場合

    // 重複防止用(dt.Rows.Find(id) が使える)
    dt.PrimaryKey = new[] { dt.Columns["ProdId"] };

    return dt;
}

「ValueだけでなくTextも表示したい」場合、上のように両方の列を持っておくと、GridView/ListView側の表示がシンプルになります。さらに数量や並び順などの入力が増える見込みがあるなら、DataTableに列を足しておくと拡張が楽です。

DataTableの保持方法

Web Formsで「DataTableに溜める」場合、最重要なのはDataTableをどこに保持するかです。staticにすると全ユーザーで共有され、別ユーザーの操作が混線します。運用で事故りやすいので避けましょう。

保持先特徴向いているケース注意点
Sessionユーザー単位で保持。サイズが多少大きくても扱いやすい複数ポストバックをまたいで確実に状態を残したいセッション切れ、Webファーム時のセッション管理
ViewStateページに埋め込まれるため簡単だが、肥大化しやすい件数が少ない、データが軽いページが重くなる、改ざん対策が前提
毎回DB再取得最も正攻法。状態管理が単純DBモード中心の画面クエリ回数が増える

ここでは「分かりやすさ」と「破綻しにくさ」を優先し、SessionにDataTableを保持する例で進めます。

private const string SessionKey = "SelectedProducts";

private DataTable SelectedTable
{
    get
    {
        if (Session[SessionKey] == null)
        {
            Session[SessionKey] = CreateTable();
        }
        return (DataTable)Session[SessionKey];
    }
    set { Session[SessionKey] = value; }
}

// 初回にDBが空だったかを保持
private bool DataBaseIsEmpty
{
    get { return (bool)(ViewState["DataBaseIsEmpty"] ?? false); }
    set { ViewState["DataBaseIsEmpty"] = value; }
}

初回表示の実装

初回表示では、DBから取得してDataTableを作り、件数でモードを決めてから一覧にバインドします。ここでのコツは2つです。

  • SelectedTableを必ず初期化し、以降のイベントで同じDataTableを参照できるようにする
  • モード判定は、取得したDataTableの件数で確定する
protected void Page_Load(object sender, EventArgs e)
{
    if (!IsPostBack)
    {
        BindDropDown(); // DropDownListの中身を用意

        // DBから取得(0件なら空のDataTableが返る想定)
        var dt = GetFromDb(); 
        if (dt == null)
        {
            dt = CreateTable();
        }

        // 主キー設定(GetFromDbで戻るDataTableに無い場合の保険)
        if (dt.PrimaryKey == null || dt.PrimaryKey.Length == 0)
        {
            dt.PrimaryKey = new[] { dt.Columns["ProdId"] };
        }

        SelectedTable = dt;

        DataBaseIsEmpty = (dt.Rows.Count == 0);

        BindGrid();
    }
}

private void BindGrid()
{
    GV1.DataSource = SelectedTable;
    GV1.DataBind();

    // ListViewの場合も同様
    // LV1.DataSource = SelectedTable;
    // LV1.DataBind();
}

DropDownListのバインド例

DropDownListは「ValueにID」「Textに名称」を持たせ、先頭に未選択項目を入れておくとAdd_Click側の判定が簡単です。

private void BindDropDown()
{
    // 例:DBから商品マスタを取得してDataTableで受ける
    var master = GetProductMaster();

    DDL1.DataSource = master;
    DDL1.DataValueField = "ProdId";
    DDL1.DataTextField = "ProdName";
    DDL1.DataBind();

    DDL1.Items.Insert(0, new ListItem("選択してください", ""));
}

未選択をValue空文字にしておけば、Add_ClickではSelectedValueが空かどうかで判定できます。実装の好みに合わせて統一してください。

画面側の最小構成例

ボタンで追加したい場合、DropDownListはAutoPostBackを無理に使う必要がありません。AddボタンのClickで処理を一本化すると、実装と検証が楽になります。

<!-- モーダル内想定:DropDownListとAddボタン -->
<asp:DropDownList ID="DDL1" runat="server" />
<asp:Button ID="btnAdd" runat="server" Text="Add" OnClick="Add_Click" />
<asp:Label ID="lblMessage" runat="server" EnableViewState="false" />

<asp:GridView ID="GV1" runat="server"
    AutoGenerateColumns="False"
    DataKeyNames="ProdId"
    OnRowDeleting="GV1_RowDeleting">
    <Columns>
        <asp:BoundField DataField="ProdId" HeaderText="ID" />
        <asp:BoundField DataField="ProdName" HeaderText="商品名" />
        <asp:BoundField DataField="AddedAt" HeaderText="追加日時" DataFormatString="{0:yyyy/MM/dd HH:mm}" />
        <asp:CommandField ShowDeleteButton="true" />
    </Columns>
</asp:GridView>

GridViewの削除はDataKeyNamesにキーを設定しておけば、RowDeletingで安全にキーが取れます。

Addボタンの実装

Add_Clickの流れは次の通りです。

  • 未選択を弾く
  • DropDownList.SelectedItem.ValueとTextを取得
  • DataTableに同一キーがあるか確認して重複を防ぐ
  • DBモードならINSERTして成功したらDataTableへ反映
  • メモリモードならDataTableへ反映のみ
  • 最後に再バインドする
protected void Add_Click(object sender, EventArgs e)
{
    lblMessage.Text = "";

    // 例:先頭に「選択してください」を入れている場合
    if (DDL1.SelectedIndex <= 0) return;

    int id;
    if (!int.TryParse(DDL1.SelectedItem.Value, out id)) return;

    string name = DDL1.SelectedItem.Text;

    var dt = SelectedTable;

    // dt.PrimaryKey を設定していれば Find が使える
    var existing = dt.Rows.Find(id);
    if (existing != null)
    {
        lblMessage.Text = "既に追加されています。";
        BindGrid();
        ReopenModalIfNeeded();
        return;
    }

    // 追加対象の行を作る(共通)
    var row = dt.NewRow();
    row["ProdId"] = id;
    row["ProdName"] = name;
    row["AddedAt"] = DateTime.Now;

    if (DataBaseIsEmpty)
    {
        // DBが空の間はメモリ運用
        dt.Rows.Add(row);
    }
    else
    {
        // DBが空でないならDBにも反映(成功したらdtへ)
        if (InsertToDb(id, name))
        {
            dt.Rows.Add(row);
        }
        else
        {
            lblMessage.Text = "DBへの追加に失敗しました。";
        }
    }

    SelectedTable = dt;
    BindGrid();
    ReopenModalIfNeeded();
}

DBモードではDB更新が成功したらDataTableも更新に寄せると、INSERT失敗時に表示とDBがズレません。

削除の実装

削除は「どの行を消すか」というキーさえ取れれば、追加よりも簡単です。GridViewはDataKeysからキーを取り、DataTableから行を削除します。

protected void GV1_RowDeleting(object sender, GridViewDeleteEventArgs e)
{
    lblMessage.Text = "";

    int id = (int)GV1.DataKeys[e.RowIndex].Value;

    var dt = SelectedTable;
    var row = dt.Rows.Find(id);
    if (row == null) return;

    if (!DataBaseIsEmpty)
    {
        // DBモード:DB→成功したらdt
        if (!DeleteFromDb(id))
        {
            lblMessage.Text = "DBの削除に失敗しました。";
            BindGrid();
            ReopenModalIfNeeded();
            return;
        }
    }

    dt.Rows.Remove(row);
    SelectedTable = dt;

    BindGrid();
    ReopenModalIfNeeded();
}

ListViewで削除する場合

ListViewでも考え方は同じです。CommandArgumentにキーを渡し、ItemCommandで削除します。

<asp:ListView ID="LV1" runat="server" OnItemCommand="LV1_ItemCommand">
  <ItemTemplate>
    <%# Eval("ProdId") %> : <%# Eval("ProdName") %>
    <asp:LinkButton ID="btnDel" runat="server"
        CommandName="Del"
        CommandArgument='<%# Eval("ProdId") %>'
        Text="削除" />
  </ItemTemplate>
</asp:ListView>
protected void LV1_ItemCommand(object sender, ListViewCommandEventArgs e)
{
    if (e.CommandName != "Del") return;

    int id;
    if (!int.TryParse(Convert.ToString(e.CommandArgument), out id)) return;

    var dt = SelectedTable;
    var row = dt.Rows.Find(id);
    if (row == null) return;

    if (!DataBaseIsEmpty)
    {
        if (!DeleteFromDb(id))
        {
            lblMessage.Text = "DBの削除に失敗しました。";
            BindGrid();
            ReopenModalIfNeeded();
            return;
        }
    }

    dt.Rows.Remove(row);
    SelectedTable = dt;

    BindGrid();
    ReopenModalIfNeeded();
}

DB操作の最小例

DB更新は必ずパラメータ化してください。ここではINSERTとDELETEの最小例を示します。実運用では例外ログ、トランザクション、ユニーク制約なども併用してください。

private bool InsertToDb(int id, string name)
{
    var cs = System.Configuration.ConfigurationManager
                .ConnectionStrings["DefaultConnection"].ConnectionString;

    const string sql = @"
        INSERT INTO SelectedProducts(ProdId, ProdName)
        VALUES(@ProdId, @ProdName);
    ";

    try
    {
        using (var con = new System.Data.SqlClient.SqlConnection(cs))
        using (var cmd = new System.Data.SqlClient.SqlCommand(sql, con))
        {
            cmd.Parameters.AddWithValue("@ProdId", id);
            cmd.Parameters.AddWithValue("@ProdName", name);

            con.Open();
            return cmd.ExecuteNonQuery() == 1;
        }
    }
    catch
    {
        return false;
    }
}

private bool DeleteFromDb(int id)
{
    var cs = System.Configuration.ConfigurationManager
                .ConnectionStrings["DefaultConnection"].ConnectionString;

    const string sql = @"DELETE FROM SelectedProducts WHERE ProdId = @ProdId;";

    try
    {
        using (var con = new System.Data.SqlClient.SqlConnection(cs))
        using (var cmd = new System.Data.SqlClient.SqlCommand(sql, con))
        {
            cmd.Parameters.AddWithValue("@ProdId", id);

            con.Open();
            return cmd.ExecuteNonQuery() == 1;
        }
    }
    catch
    {
        return false;
    }
}

「同じProdIdを二重に入れたくない」場合は、DB側にもユニーク制約を付けるとより堅牢です。UI側の重複防止だけだと、同時操作や別画面からの更新で抜けることがあります。

GetFromDbの実装例

DBからDataTableへ読み込む処理は、DataAdapterで埋める形にしておくと見通しが良くなります。ここでもWhere条件で対象範囲を絞り、必要ならPrimaryKeyを設定して返します。

private DataTable GetFromDb()
{
    var cs = System.Configuration.ConfigurationManager
                .ConnectionStrings["DefaultConnection"].ConnectionString;

    // 例:この画面の親キー(案件IDなど)で絞り込む前提
    int parentId = GetParentIdFromQueryOrSession();

    const string sql = @"
        SELECT ProdId, ProdName, AddedAt
        FROM SelectedProducts
        WHERE ParentId = @ParentId
        ORDER BY ProdId;
    ";

    var dt = CreateTable();

    using (var con = new System.Data.SqlClient.SqlConnection(cs))
    using (var da = new System.Data.SqlClient.SqlDataAdapter(sql, con))
    {
        da.SelectCommand.Parameters.AddWithValue("@ParentId", parentId);
        da.Fill(dt);
    }

    // 念のためPrimaryKeyを設定
    dt.PrimaryKey = new[] { dt.Columns["ProdId"] };

    return dt;
}

このGetFromDbが「誰の、どの親データに紐づく一覧なのか」を正しく返すように設計できれば、以降の追加/削除の整合性が一気に安定します。

モーダルが閉じる問題の対処

モーダル内のボタンはポストバックが発生するため、処理後にモーダルが閉じてしまうことがあります。対処は使っているモーダル方式で変わります。

Bootstrapモーダルの例

Bootstrapを使っているなら、AddやDeleteの処理後にJavaScriptでモーダルを再表示します。

private void ReopenModalIfNeeded()
{
    var script = "$('#editModal').modal('show');";
    ScriptManager.RegisterStartupScript(this, this.GetType(),
        "reopenModal", script, true);
}

AjaxControlToolkit ModalPopupExtenderの例

ModalPopupExtenderの場合は、サーバー側でShowを呼べます。

private void ReopenModalIfNeeded()
{
    mpeEdit.Show();
}

UpdatePanelを併用している場合は、Addボタンや削除ボタンが正しく非同期ポストバックになるようにTriggersも確認してください。モーダルが開いたまま更新される体験を作ると、ユーザーの操作が一気に楽になります。

実務でハマりやすい点のチェックリスト

  • static DataTableは使わない。全ユーザーで共有されて混線する
  • DataKeyNamesを設定し、削除対象のキーはDataKeysから取る
  • DBモードではDB成功の後にDataTable反映。失敗時にズレない
  • ユニーク制約でDB側でも重複を防ぐ
  • モーダルの再表示を忘れない。ポストバックで閉じる
  • Session運用なら、セッション切れ時の再初期化を想定しておく

標準設計に寄せるならDBモードは再取得で単純化できる

要件により「DBにレコードがあるなら、追加/削除は常にDBが正」という前提であれば、DB更新後にDataTableを作り直して再バインドする方が、整合性のバグが減ります。たとえばAdd_ClickやRowDeletingの最後を次のように置き換えられます。

if (!DataBaseIsEmpty)
{
    // DBが正:更新後に再取得して表示を確定させる
    SelectedTable = GetFromDb();
}
BindGrid();

業務画面の件数が多くない限り、保守性のメリットが勝つことが多いです。メモリモードは最後にまとめて保存するタイミングを別途設計しておくと安心です。

まとめ

DropDownListの選択値をDataTableへ溜めてGridViewやListViewに出す要件は、構造化すると難しくありません。

  • 初回にDB→DataTableを作り、DBが空かどうかでモードを決める
  • AddボタンClickで選択値を取得し、重複防止してモードに応じて更新する
  • 削除はキーを取り、モードに応じてDBとDataTableを同期する

この型を押さえておけば、行編集、並び替え、一括保存などの拡張も同じ発想で組み立てられます。まずはDataTableの保持先とモード判定を固定し、一覧の整合性が崩れない土台を作ってください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次