狛ログ

2022年5月16日月曜日

Angular ユニットテスト エラー(NgRxのReducerで発生する「TypeError: Cannot read properties of undefined (reading 'xxxxx')」)の対応。

5月 16, 2022
オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

前回前々回に引き続き、Angularでユニットテスト作成する時のエラー対応です。
今回はNgRx関連のエラーです。
error properties: Object({ longStack: 'TypeError: Cannot read properties of undefined (reading 'xxxxx')
      at http://localhost:9876/_karma_webpack_/webpack:/src/app/koma/store/reducers/index.ts:xx:xx
みたいなエラーが出てしまった場合の対応です。

・・・・ええ、言いたいことは分かります。
undefinedって出ているんだから、該当箇所見ればすぐ分かるじゃん。
って事ですよね?

まあ、仰る通りなのですが、エラーとなっている箇所は、コンポーネント側ではなく、NgRxのReducer(index.ts)で、エラーも微妙な位置なので、どう修正すれば良いのか、分からないんですよね・・・・。

ちなみに、コンポーネント側は、
[koma-confirm.component.ts]
export class KomaConfirmComponent implements OnInit, OnDestroy {
  komaConfirmSubscription: Subscription = new Subscription();
  saveData: KomaViewSaveModel;

  constructor(
    private router: Router,
    public store: Store<fromKoma.State>
  ) {}

  ngOnInit(): void {
    // 画面間保持項目の退避
    this.komaConfirmSubscription.add(
      this.store
        .pipe(select(fromKoma.getDataRegisterPost))
        .subscribe(model => {
          if (model) {
            this.saveData = model;
          } else {
            this.router.navigateByUrl('/koma/home');
          }
        }),
    );
  }

  ngOnDestroy(): void {
    this.komaConfirmSubscription.unsubscribe();
  }
}
となっています。

良くある登録系機能(入力→確認→完了)の「確認画面」を想定して頂くと分かりやすいです。
入力画面で入力された値をStoreに格納し、それを確認画面(のngOnInit)で取得しようとしています。
もし、Storeからデータを取得出来ない場合、不正な遷移を行ったと見做し、ホーム画面に遷移させています。

なので、
      this.store
        .pipe(select(fromKoma.getDataRegisterPost))
が何となく怪しいのは分かるのですが、Reducer(index.ts)側は、

[/src/app/koma/store/reducers/index.ts]
export const getKomaState = createSelector(
  getKomaFeatureState,
  (state: KomaFeatureState) => state.koma, // ←ここがエラーになってる。
);

export const getDataRegisterPost = createSelector(
  getKomaState,
  fromKoma.getDataRegisterPost, // ←せめてここでエラーになっていれば・・・・
);
上記の「state.koma」の部分がエラーとなっていて、内容としては、
TypeError: Cannot read properties of undefined (reading 'koma')
と出ています。

せめて、
  fromKoma.getDataRegisterPost,
の部分でエラーになっていれば、エラー原因の当たりがすぐにつくのですが・・・・

では、このエラーの対応をしていきたいと思います。

テストファイルの方を見ていきましょう。
[koma-confirm.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { KomaConfirmComponent } from './koma-confirm.component';

describe('KomaConfirmComponent', () => {
  let component: KomaConfirmComponent;
  let fixture: ComponentFixture<KomaConfirmComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [KomaConfirmComponent],
      providers: [
        provideMockStore(),
      ],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(KomaConfirmComponent);

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
DIの設定だけはしている感じですね。

前回は、「StoreのdispatchでActionの実行をテストする」だったので、特に意識しなかったのですが、
Storeからデータを取得する、という場合は、「selector」と、取得するデータを定義する必要があります。

[koma-confirm.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import * as fromKoma from '../../store/reducers';
import { KomaConfirmComponent } from './koma-confirm.component';

describe('KomaConfirmComponent', () => {
  let component: KomaConfirmComponent;
  let fixture: ComponentFixture<KomaConfirmComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [KomaConfirmComponent],
      providers: [
        provideMockStore({
          selectors: [
            { selector: fromKoma.getDataRegisterPost,
              value: {
                komaId: '1',
                komaData: '1111',
              }
            },
          ]
        }),
      ],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(KomaConfirmComponent);

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

provideMockStoreの定義に引数として、selectorとvalueを設定しています。

これで、エラー(TypeError: Cannot read properties of undefined (reading 'xxxxx'))は出なくなると思います。

では、ちゃんとテストケースを追加していこうと思います。

まず、書き換えたいのが、
provideMockStoreの定義に引数として、selectorとvalueを設定している部分です。
今、そこを追加したのに・・・変えるんかい!って感じですね。

後々、テストケースが追加になった時に、selector内の値の入れ替えを楽にしたいので、
provideMockStoreの引数で設定するのではなく、テストクラス内で使えるように変えていこうと思います。
テストケースも追加していきます。
[koma-confirm.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import * as fromKoma from '../../store/reducers';
import { KomaConfirmComponent } from './koma-confirm.component';
import { KomaViewSaveModel } from '../../models/koma';

describe('KomaConfirmComponent', () => {
  let component: KomaConfirmComponent;
  let fixture: ComponentFixture<KomaConfirmComponent>;
  // 全テストケースで利用出来るように、ここで定義
  let store: MockStore<fromKoma.State>;
  let router: Router;
  let mockDataRegisterPostSelector;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [KomaConfirmComponent],
      providers: [
        provideMockStore(),
      ],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(KomaConfirmComponent);

    // DI後のインスタンスを取得
    router = TestBed.inject(Router);
    store = TestBed.inject(MockStore);

    // データの初期値を設定
    mockDataRegisterPostSelector = store.overrideSelector(
      fromAccount.getDataRegisterPost, {
        komaId: '1',
        komaData: '1111'
      }
    );

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('ngOnInitのテスト', () => {
    it('画面引き継ぎ情報が無い場合、ホーム画面へ遷移すること', () => {
      // 処理のモックを作成
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // データにnullを設定
      mockDataRegisterPostSelector.setResult(null);

      // テスト対象のメソッドを呼び出す
      component.ngOnInit();

      // navigateByUrlが、'/koma/home'を引数として呼ばれているか確認
      expect(navigateByUrlSpy).toHaveBeenCalledWith('/koma/home');
    });

    it('画面引き継ぎ情報がある場合、データを退避すること', () => {
      // 処理のモックを作成
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // データを再設定
      const formModel: KomaViewSaveModel = {
        komaId: '2',
        komaData: '2222'
      };
      mockDataRegisterPostSelector.setResult(formModel);

      // テスト対象のメソッドを呼び出す
      component.ngOnInit();

      // 退避されたデータの中身をチェック
      expect(component.saveData).not.toBeUndefined();
      expect(component.saveData).not.toBeNull();
      expect(component.saveData.komaId).toBe('2');
      expect(component.saveData.komaData).toBe('2222');

      // navigateByUrlが、呼ばれていないか確認
      expect(navigateByUrlSpy).not.toHaveBeenCalled();
    });
  });
});

ポイントとしては、「overrideSelector」によって、selectorを再定義している部分
    mockDataRegisterPostSelector = store.overrideSelector(
        // (中略)
    );
と、値を再設定する
    mockDataRegisterPostSelector.setResult(formModel);
の部分ですね。

これで、Store内のデータを設定し、テストをすることが可能になりました!

今後も、Angularでユニットテストについて、ブログに書いて行こうと思います。

2022年5月13日金曜日

Angular ユニットテスト エラー( NullInjectorError: No provider for Store )の対応。

5月 13, 2022
オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

前回に引き続き、Angularでユニットテスト作成時のエラー対応です。
今回もDI関連のエラーです。
NullInjectorError: R3InjectorError(DynamicTestModule)[Store -> Store]: 
  NullInjectorError: No provider for Store!
上記のエラーが出てしまった場合の対応です。

これは、NgRxのStoreを使用している場合に出てくるエラーとなります。
NgRxの公式サイトを見ると、コンポーネント側のテスト記載のサンプルがあるので、詳細はそちらを見るとして、とりあえず、エラーを無くしてみます。
まずは、コンポーネント側のロジックを確認してみましょう。

例として、よくある「入力画面(から確認画面)」を挙げてみます。
「入力画面で、入力した値をSubmitによって、Storeに保存し、確認画面へ引き継ぐ」って感じですね。
[input.component.ts]
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import * as fromInput from '../../store/reducers';
import * as InputActions from '../../store/actions/input.actions';
import { InputViewSaveModel } from '../../models/input';
// (中略)
  constructor(
    private router: Router,
    public store: Store<fromInput.State>,
  ) {}
// (中略)
  onSubmit(inputData: InputViewSaveModel): void {
    // 入力情報をStoreに格納して(=引き継ぎ情報として)確認画面へ遷移
    this.store.dispatch(InputActions.setRegisterPostData({ data: inputData }));
    this.router.navigateByUrl('/koma/confirm');
  }

ここではNgRxの仕様については触れませんので、NgRxを知っている前提で進めます。
それと、前回やった「Router」もありますので、一緒にエラーが出ないように対応してみます。

まずは必要モジュールのインポートと、DIをします。
(Angularでは、DI可能なクラスであれば、コンポーネントのコンストラクタに定義する事で、DIして使用可能になります。)
[input.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { InputComponent } from './input.component';

describe('InputComponent', () => {
  let component: InputComponent;
  let fixture: ComponentFixture<InputComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [InputComponent],
      providers: [provideMockStore()],
      imports: [RouterTestingModule],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(InputComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
と言う感じになります。

Storeに関する部分は、
import { provideMockStore } from '@ngrx/store/testing';
と、
      providers: [provideMockStore()],
を追加しています。
先ほどのエラー(NullInjectorError)を無くすだけであれば、実は上記でOKです。

ただ、当然テストケースは作らないといけないので、ついでにやってみましょう
[input.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import * as fromInput from '../../store/reducers';
import * as InputActions from '../../store/actions/input.actions';
import { InputViewSaveModel } from '../../models/input';
import { InputComponent } from './input.component';

describe('InputComponent', () => {
  let component: InputComponent;
  let fixture: ComponentFixture<InputComponent>;
  // 全テストケースで利用出来るように、ここで定義
  let router: Router;
  let store: MockStore<fromInput.State>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [InputComponent],
      providers: [provideMockStore()],
      imports: [RouterTestingModule],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(InputComponent);

    // DI後のインスタンスを取得
    router = TestBed.inject(Router);
    store = TestBed.inject(MockStore);

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('ボタンアクションテスト', () => {
    it('確認ボタン押下時 Storeにデータを保存し、確認画面へ遷移する事', () => {
      // 処理のモックを作成
      const dispatchSpy = spyOn(store, 'dispatch');
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // 画面から入力された値を再現
      const formModel: InputViewSaveModel = {
        id: '111',
        name: 'koma-chan',
        address: 'tokyo',
      };

      // 実行されるアクションを定義(画面からの入力値をStoreへ保存するアクション)
      const expectedAction = InputActions.setRegisterPostData({ data: formModel });

      // テスト対象のメソッドを呼び出す
      component.onSubmit(formModel);

      // Storeのdispatchが、アクションを引数として呼ばれているか確認
      expect(dispatchSpy).toHaveBeenCalledWith(expectedAction);

      // navigateByUrlが、'/koma/confirm'を引数として呼ばれているか確認
      expect(navigateByUrlSpy).toHaveBeenCalledWith('/koma/confirm');
    });
  });
});

ポイントとしては、DIしたクラスをテスト側で使用する為には、
    store = TestBed.inject(MockStore);
が必要、というところです。
実際のテストの部分は、プログラム内のコメントを参照して頂ければと思いますが、流れ的に、

① spyOn で、Store の dispatch に対するモックを作成
② 画面の入力値を Store に保存するアクションを定義
③ コンポーネント側の onSubmit を呼び出す
③ expect の toHaveBeenCalledWith で、指定の引数(アクション)で dispatch が呼ばれたかを判定

となっています。

ちょっと長くなってしまったので、その他のStore関連のテストエラーは別途記事にしようと思います。

2022年5月12日木曜日

position: absolute;を使って重ねた画像にCSSで乗算の影をつける。

5月 12, 2022


 

こんにちは、オフィス狛 デザイン部のSatoです。


先日、並んで表示されている複数の要素の上に、position: absolute;を使って画像を重ねて配置し、配置した画像に影をつけたいことがありました。

しかし、配置した画像は別の色の要素を跨いでいるため、普通の方法ではなかなか自然な影にならず困りました。

そこでCSSのブレンドモードで乗算の影をつけたら自然になるはずと思い試していたのですが、調整に時間がかかってしまいました。

あまり発生しないケースかもしれませんが、今後の自分用の備忘録も兼ねて解決方法を書いていこうかとおもいます。


上手くいかなかった方法


最初、imgをdivで囲みdrop-shadowを指定してあげれば影ができるはずですし、一緒に「mix-blend-mode: multiply;」を指定してみました。予想はできていましたが、影だけではなく画像まで乗算になってしまいました😔


それならば、imgタグに擬似要素「::before」をつけて、画像と同じサイズにした影を作りz-indexを使って画像下になるよう重ねてみようと思いました。……影が出てこないので、調べた所「::before」「::after」はimgには指定できないとのこと。初歩的なミスをやらかしてしまいました😔


それならば…imgを囲んでいるdivに擬似要素「::before」をつけようとしました。

影が画像の上に乗ってしまったのでz-indexを指定すると、乗算でなくなってしまいます😔mix-blend-modeとスタックコンテキスト

↑こちらのサイト曰くmix-blend-modeは同じスタックコンテキストに属している要素にしか効果がないらしいです。
ブレンドモードを使いこなすのは難しいですね…。



上手くいった方法


親要素の中に影用のdivを作り、画像と同じサイズにした影用の要素を画像の下に重なるよう配置した所、ようやく想像した通りの見た目になりました😄

See the Pen position: absolute;で浮かせた画像に乗算の影をつける by sato (@officekoma_sato) on CodePen.

上記のような感じになりました。

どちらの色のエリアでも違和感のない自然な影になり満足です✌️

あまり発生しないケースですし、影色を乗算にしなければ良いのですが、どうしても乗算を使いたいケースがあれば参考にしてみてください。

2022年4月28日木曜日

Angular ユニットテスト エラー( NullInjectorError: No provider for Router )の対応。

4月 28, 2022
オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

突然ですが、Angularでユニットテストしてますか?
CLIでコンポーネントを作ると、「xxxxxx.component.spec.ts」が作成されますが、
「そのまま何も変えない(ユニットテスト使わない) or 邪魔だから消す」と、なっているかもしれないですね・・・・
まあ、Angularのユニットテスト、割と難しいので、気持ちは分かります。

という事で、Angularでユニットテストを書いていく上で、良く出るエラーの対処方法を記載していこうと思います。
今回はDI関連のエラーです。

NullInjectorError: R3InjectorError(DynamicTestModule)[Router -> Router]: 
  NullInjectorError: No provider for Router!
みたいなエラーが出てしまった場合の対応ですね。
メッセージそのままですが、Router絡みのエラーであることが分かります。

Angularでは、DI可能なクラスであれば、コンポーネントのコンストラクタに定義する事で、DIして使用可能になります。
[hoge.component.ts]
import { Router } from '@angular/router';
// (中略)
  constructor(private router: Router) {}
// (中略)
  onClickBack(): void {
  	// 「戻る」ボタンを押したら、「Top画面」に遷移する
    this.router.navigateByUrl('/hoge/top');
  }

・・・つまりテストクラス側でもDIをする必要があるって事ですね
[hoge.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { HogeComponent } from './hoge.component';

describe('HogeComponent', () => {
  let component: HogeComponent;
  let fixture: ComponentFixture<HogeComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [HogeComponent],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(HogeComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
って感じになります。
デフォルト(CLIによって作成された状態)から、
import { RouterTestingModule } from '@angular/router/testing';
と、
      imports: [
        RouterTestingModule,
      ],
を追加しています。

先ほどのエラー(NullInjectorError)を無くすだけであれば、実は上記でOKです。

ただ、当然テストケースは作らないといけないので、ついでにやってみましょう
[hoge.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
// Routerをimport
import { Router } from '@angular/router';
import { HogeComponent } from './hoge.component';

describe('HogeComponent', () => {
  let component: HogeComponent;
  let fixture: ComponentFixture<HogeComponent>;
  // 全テストケースで利用出来るように、ここで定義
  let router: Router;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [HogeComponent],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(HogeComponent);

    // DI後のインスタンスを取得
    router = TestBed.inject(Router);

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('ボタンアクションテスト', () => {
    it('戻るボタン押下時  ホーム画面へ遷移する事', () => {
      // navigateByUrl処理のモックを作成
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // テスト対象のメソッドを呼び出す
      component.onClickBack();

      // navigateByUrlが、'/hoge/top'を引数として呼ばれているか確認
      expect(navigateByUrlSpy).toHaveBeenCalledWith('/hoge/top');
    });
  });
});

ポイントとしては、DIしたクラスをテスト側で使用する為には、
    router = TestBed.inject(Router);
が必要、というところです。
実際のテストの部分は、プログラム内のコメントを参照して頂ければと思いますが、流れ的に、

① spyOn で、navigateByUrl のモックを作成
② コンポーネント側の onClickBack を呼び出す
③ expect の toHaveBeenCalledWith で、指定の引数で navigateByUrl が呼ばれたかを判定

となっています。

ちょっと長くなってしまったので、その他のDI関連のテストエラーは別途記事にしようと思います。

2022年4月22日金曜日

【C#】モーダル内に非同期でデータを表示させたかった話【ajax】

4月 22, 2022
おはようございます。こんにちは。こんばんは。
オフィス狛 開発担当のnagoです。

今回は、最近初めて使用したC#(ASP.NET Core MVC)で、
なるほどなぁと思ったモーダル実装についてまとめたいと思います。
DBはPostgreSQL、ビューにはBootstrapを使用しました。

<やりたかったこと>
親画面からボタンクリック

親画面とは別のcshtmlファイルに記述したモーダルを開きつつ、
DBから持ってきたデータをモーダル内に表示させる

<できたもの>
コンサートなどのチケット購入サイト風サンプルを作成してみました。

・Topページ


↓詳細ボタンを押下したら

こんなかんじで表示。
・モーダル

データはこんなかんじ。

処理全体の流れとしては、
①親画面にモーダルのcshtmlを入れ込むためのdivを置いておく
②ボタンが押されたら、ajaxで非同期でコントローラにデータ取得用パラメータを渡す
③コントローラでデータ取得
④取得したデータを成形してビューモデルにセット
⑤コントローラからPertialViewでjsにcshtmlとビューモデルを返却する
⑥モーダル表示
というかんじで作ってみました。

<ソース>
・TopController.cs
using Microsoft.AspNetCore.Mvc;
using DotNetTest.Models;

namespace DotNetTest.Controllers
{
    public class TopController : Controller
    {
        private readonly DotNetTestContext _context;

        public TopController(DotNetTestContext context){
            this._context = context;
        }

        [HttpGet]
        public IActionResult Top() {
            return View(this._context.performance);
        }
    }
}
・ModalController.cs
using Microsoft.AspNetCore.Mvc;
using DotNetTest.Models;
using System.Linq;

namespace DotNetTest.Controllers
{
    public class ModalController : Controller
    {
        private readonly DotNetTestContext _context;

        public ModalController(DotNetTestContext context){
            this._context = context;
        }

        [HttpPost]
        public IActionResult ShowModal(ModalViewModel viewModel) {
            try
            {
                if ( viewModel.id != string.Empty && _context.performance != null) {
                    if( Int32.TryParse( viewModel.id, out int intId )) {
                        // レコード取得
                        var performance = _context.performance.SingleOrDefault(p => p.id == intId);
                        if (performance != null) {
                            viewModel.name = performance.name;
                            viewModel.date = performance.date.ToString("yyyy年MM月dd日 HH:mm");
                            viewModel.place = performance.place;
                            viewModel.description = performance.description;
                            viewModel.errorMessage = string.Empty;
                            // ビューとビューモデルを返却
                            return PartialView("Modal", viewModel);
                        }
                    }
                }
                // エラーの場合はエラーメッセージ入りビューモデルを返却
                return Json(viewModel);
            }
            catch (System.Exception)
            {
                throw;
            }
        }
    }
}
・DotNetTestContext.cs
using Microsoft.EntityFrameworkCore;
using DotNetTest.Models;

namespace DotNetTest.Models
{
    public class DotNetTestContext: DbContext
    {
        public DotNetTestContext(DbContextOptions options) : base(options)
        {           
        }
        public DbSet performance { get; set; } = null!;
    }
}
・Performance.cs
namespace DotNetTest.Models
{
    public class performance
    {
        public int id { get; set; }
        public string name { get; set; } = string.Empty;
        public DateTime date { get; set; }
        public string place { get; set; } = string.Empty;
        public string description { get; set; } = string.Empty;
    }
}
・ModalViewModel.cs
using Microsoft.AspNetCore.Mvc;

namespace DotNetTest.Models
{
    [BindProperties]
    public class ModalViewModel
    {
        public string id { get; set; } = string.Empty;
        public string name { get; set; } = string.Empty;
        public string date { get; set; } = string.Empty;
        public string place { get; set; } = string.Empty;
        public string description { get; set; } = string.Empty;
        public string errorMessage { get; set; } = "エラーが発生しました";
    }
}
・Top.cshtml
@model IEnumerable<DotNetTest.Models.performance>
@{
  ViewData["Title"] = "Top";
}
<!doctype html>
<html lang="ja" >
<head>
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
  <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" >
</head>
<body>
  <table>
    <tr>
      <td colspan="2">
        <p class="error_message" id="error_message"></p>
      </td>
    </tr>
    @foreach(var item in Model)
    {
      <tr>
        <td>
          <p>・ @item.name @item.date.ToString("yyyy年M月d日 HH:mm")〜</p>
        </td>
        <td class="button_td">
          <button type="button" class="btn btn-primary" data-toggle="modal" value="@item.id" onclick="openModal(this.value);">
            詳細
          </button>
        </td>
      </tr>
    }
  </table>
  <div id="modal_base"></div>
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
  <script src="~/js/site.js" asp-append-version="true"></script>
</body>
</html>
・Modal.cshtml
@using DotNetTest.Models
@model ModalViewModel
<!-- Modal -->
<div class="modal fade" id="modal" tabindex="-1" aria-labelledby="modalLabel" aria-hidden="true">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">@Model.name</h5>
        <button type="button" class="close" data-dismiss="modal" aria-label="Close">
          <span aria-hidden="true">&times;</span>
        </button>
      </div>
      <div class="modal-body">
        <p>
          公演日時:@Model.date 〜<br>
          会場:@Model.place<br>
          説明:@Model.description
        </p>
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-primary">
          予約
        </button>
      </div>
    </div>
  </div>
</div>
・site.js
function openModal(id) {
    $('#error_message').val('');
    // コントローラ名/メソッド名
    const url = new URL(window.location.href) + 'Modal/ShowModal';
    // パラメータ成形
    let form = new FormData();
    form.append('id', id);
    $.ajax({
        url: url,
        type: 'POST',
        processData: false,
        contentType: false,
        data: form
    }).done(function(data){
        const data_stringify = JSON.stringify(data);
        const data_json = JSON.parse(data_stringify);
        if(data_json.errorMessage != null) {
            $('#error_message').append(data_json.errorMessage);
        } else {
            // 親画面に置いておいたdivに、コントローラから受け取ったモーダルのcshtmlをセットする
            $('#modal_base').html(data);
            // モーダル表示
            $('#modal_base').find('.modal').modal('show');
        }
    });
}

親画面と子画面(モーダル)のcshtmlを別ファイルで管理したかったのと、
jsファイルも別ファイルで管理したかったということがあり、苦戦したのですが、
これで思い通りの動きに出来た時はすごく嬉しかったです。
もし同じようなことで躓いた方がいらしたら、参考になれば幸いです。

参考URL:
https://docs.microsoft.com/ja-jp/dotnet/api/system.web.mvc.controller.partialview?view=aspnet-mvc-5.2

2022年3月31日木曜日

要素同士の余白に使える!gapプロパティが便利

3月 31, 2022

こんにちは、オフィス狛 デザイン部のSatoです。


 Internet Explorerシリーズのサポートが今年6月15日終わりますね。

便利なCSSプロパティが「モダンブラウザでは問題ないけれどIEで非対応だった!」と焦るシチュエーションがなくなり、モダンブラウザで使えるさまざまなCSS要素がIEの存在を気にせず使えるようになると思うと、個人的にはかなりありがたいです!


IEを気にしなくて良くなり、気軽に使えるようになったCSS要素で「要素をタイル状に横並びにする」レイアウトをCSSで実装する際に使える「gapプロパティ」を使ってみた所、とても便利でしたので今回のブログでご紹介します。



gapプロパティは要素と要素の間を指定するプロパティです。

marginに近いですが、要素の有無に関係なく余白を指定できるmarginと違い、gapプロパティは要素と要素の隙間を指定できます。


今まで「840pxの横幅の要素の中に、要素を3つずつ横並びで表示する、要素と要素の間は4px」といったレイアウトを作成する際余白を開けるために、要素の数が決まっている際はjustify-content: space-between;で並べたり…、要素の数に増減がある際はjustify-content: space-between;を使わずmarginで2番目の要素だけに余白をつけたり…。

などと私の場合考えることが多かったです。

レスポンシブサイトでのスマホビュー時のレイアウト変更にも少し手間がかかっていました。


しかし、gapプロパティを使うと余白をデザインの際に横に並べる数が変わった時などに変更する数値や箇所がわかりやすい+要素が右寄せで表示+レスポンシブの際のコード変更箇所が減る…などなど良いこと尽くめです。

しかもgapプロパティは上下右左個別に指定可能なんです。とってもありがたいです。


See the Pen 要素を並べて表示 by sato (@officekoma_sato) on CodePen.

👆デモを作成してみました。

HTML&CSSも見れますので、参考にしてみてください。

今後はグリッドレイアウトなどと一緒に使うことで、色々な可能性が広がりそうですね。



今回ご紹介したgapプロパティはリニューアルしたオフィス狛のWebサイトのこまちゃん紹介ページでも使用しています。

よろしければ、リニューアルしたオフィス狛のWebサイトも見ていってください。

2022年2月24日木曜日

【JavaScript】メールテンプレート内のプレースホルダーを独自に式展開(置換)する方法。

2月 24, 2022
オフィス狛 技術部のyuckieee(ゆっきー)です。

■はじめに

今回は、私が関わったプロジェクトで「ユーザがメールテンプレートを自由に編集できるようにしたい!」という要望を受けて、その実現方法を検討・実装した際のお話です。
要件実現にあたって使用したのがJavaScriptの「replace」という関数なのですが、この関数が非常に使える子で...ふるえました(,,゚Д゚)。 ←初心者感丸出しなのと語彙力(笑)

今後も活用する場面がありそうなので、自分の理解向上と備忘録としてコチラにまとめます。

■今回の要件整理

最初に話したとおり、今回の要望はメールテンプレートをユーザが自由に編集・管理したいというものです。また、この要件には「メールの宛先によって一部の内容(名前とか、契約内容とか)はシステムで可変となるようにしたい」という要望も含まれています。
なので、ざっくりこんな感じの要件です。

[要件箇条書き]
     ①メールテンプレートは外部に保存可能とし、ユーザが自由に編集できるようにして欲しい
       ②ユーザが可変指定した箇所は、システムで自動的に置き換えて欲しい

      可変にしたいと言うのは、メールテンプレート内にプレースホルダーを埋め込みたいというイメージです。 そのため、イメージとしては、以下のようにユーザがメールテンプレートを定義したらプレースホルダー(${})部分は、該当する情報に置き換えられて欲しいという感じです。
      (まんま、JavaScriptのテンプレートリテラル内で式展開させる感じと同じです。)

      ■メールテンプレート
      ${last_name} {first_name} 様
      この度は、${company_name}の${plan_name}にご登録いただき、ありがとうございました。
       ↓
      ■メール送信内容
      オフ狛 ゆっきー 様
      この度は、株式会社オフ狛のスペシャルプランにご登録いただき、ありがとうございました。
      

      ■検討結果と実装内容

      テンプレートリテラレルを使えば解決じゃん?と思うのですが、外部から読み込んだ文字列にプレースホルダーが書いてあったとしても式展開されず、単なる文字列になっちゃうんですよね....(´・ω・)
      まぁ、そうですよね、、、ということで、結果、独自にプレースホルダー(${})を置換する関数を作ることにしました。それがコチラ↓

      /**
       * テンプレート置換処理
       * @param  {string} template // 外部から読み込んだメールテンプレート文字列
       * @param  {object} jsonObj  // メールテンプレート内のプレースホルダー置換対象情報
       */
       const templateRepalcer = (template, jsonObj) 
                               => template.replace(/\${([^}]+)}/g, (_, prop) => jsonObj[prop]);
      

      以上です!

      ■実装関数の使用例

      つづいて、実装した関数を使用して簡単な使い方例を以下に紹介します。

      // メールテンプレートのテンプレートリテラレルを顧客情報に置き換える関数を定義
       const templateRepalcer = (template, jsonObj) 
                               => template.replace(/\${([^}]+)}/g, (_, prop) => jsonObj[prop]);
      
      // メールテンプレート(!!実際は外部(DBなど)から読み込む!!)
      const mailTempalte = "${last_name} ${first_name} 様\nこの度は、株式会社オフ狛の${plan_name}にご登録いただきありがとうございました。"
      
      // 顧客情報(!!実際は外部(DBなど)から読み込む!!)
      const userInfo = {
          "user_id": "ID00001",
          "last_name": "オフ狛",
          "first_name": "ゆっきー",
          "plan_name": "スペシャルプラン",
          "mail_address": "yuckieee@officekoma.co.jp"
      }
      
      // メールテンプレートのプレースホルダーを顧客情報に入れ替え
      const mailContents = templateRepalcer(mailTempalte, userInfo);
      
      // 出来上がった変数を本文に編集すれば出来上がり!(この例ではコンソール出力のみ)
      console.log(mailContents);
      

      上記実行の結果、コンソールに出力される内容は以下のとおりです。

      オフ狛 ゆっきー 様
      この度は、株式会社オフ狛のスペシャルプランにご登録いただきありがとうございました。
      

      以降、実装関数について詳細を解説していこうと思います。

      ■関数の詳細説明

      この関数を説明する前にreplace関数についての知識が必要ですが、こちらの詳細は公式(?)ページを参照された方が分かりやすいですし、ここでの説明を割愛します。

      [replace関数の説明サイト]
      MDN Web Docs:String.prototype.replace()
      JavaScript Primer:文字列の置換/削除

      作成した関数は2つあり、replace関数を呼ぶ大元のtemplateReplacerという関数と、replace関数の第二引数で使用する無名関数です。 templateReplacer自体は、テンプレートと差し替えデータを引数に、replace関数を呼び出しているだけですので説明は省略します。
      以下より、replace関数の第2引数で指定している無名関数に焦点をあてて説明したいと思います。

      まず、replace関数全体を眺めてると、以下のとおり、第2引数は、第1引数の結果を引き継いで処理を行う作りとなっていることが分かります。

      template.replace(/\${([^}]+)}/g, (_, prop) => jsonObj[prop]);
      [置換の母体となる文字列].replace(第1引数:[置換対象を正規表現で指定], 第2引数:[第一引数の結果を元に置換処理]);

      無名関数を説明する上で、第1引数も重要となってくるため、第1引数と第2引数の両方を順に説明します。

      第1引数: 置換対象の文字列又は、正規表現を指定

      replace関数の第1引数には、置換対象にしたい文字列を固定で指定するか、正規表現を使って指定する事ができます。
      今回は、汎用性を高めるために正規表現を使用しています。
      なお、/\${([^}]+)}/の箇所は、書き方は色々あると思うので、正規表現をググってみてください。
      .?*とかでも行けると思います。
      ()表記については、第2引数内で説明します。正規表現による抽出とは直接関わりません。

      また、/gを指定することで置換の母体となる文字列全体での抽出が可能になります。
      この指定を行わない場合は、対象が複数あった場合でも、最初に正規表現にひっかかった1件だけが置換される結果となるので注意が必要です。

      第2引数: 置換する文字列又は、置換処理を指定

      第2引数には、置換後の文字列を固定で指定するか、処理自体を指定することが出来ます。今回紹介した関数では、第2引数に無名関数を定義して、置換処理を行う形をとっています。
      なお、第1引数での結果をもとに、以下の引数を無名関数に引き渡すことが可能です。
      ※ブラウザによっては、5つ引数があるようですが、ここでは説明対象外とします。

      (match, p1, p2..., offset, string )

      それぞれの具体的な値について説明します。
      ※実装例で上げたメールテンプレート中の${last_name}を例に上げて説明します
      引数 説明
      match 第1引数の正規表現にマッチした文字列が設定されます。
      今回の例で言うところの${last_name}が該当箇所です。なお、今回使用しないため、関数では_と表記しています。
      p1,p2... 第1引数でキャプチャした文字列が引数として渡されます。
      キャプチャは、第1引数の正規表現内でキャプチャしたい箇所を()で囲むことで指定が可能です。
      複数指定した場合は、指定した分引数が増えていきます。今回は1対1で置換したかったので1つだけ指定しています。
      今回の例ではlast_nameという箇所が抜き出されることになります。
      offset 第1引数の正規表現でヒットした文字列の最初の位置を返します。${last_name}であれば、開始位置の0がセットされる感じです。今回は使用していません。
      string 検索対象全体がセットされます。今回でいうと"${last_name} ${first_name} 様\nこの度は、株式会社狛の"${plan_name}にご登録いただきありがとうございました。"です。今回は使用していません。

      上記の引数を元に置換対象を指定していますが、やっていることはJSONのキーを指定して、キーに紐づく値を取得しているだけです。

      (_, prop) => jsonObj[prop]

      今回の例では、以下のようなJSONが与えられていますので、第一引数でキャプチャしたキー(last_name)を元に、JSONのキーに紐づく値(オフ狛)を取得し、それを置換対象としています。

      {
          "user_id": "ID00001",
          "last_name": "オフ狛",
          "first_name": "ゆっきー",
          "plan_name": "狛スタンダードプラン",
          "mail_address": "yuckieee@officekoma.co.jp"
      }
      

      ■まとめ

      私は、今までreplace関数を単純に置換してくれるだけの関数ででしょ?って思っていました。
      ですが、こんなに汎用性の高い関数であるということを知って、目からウロコ...本当にreplace関数さん申し訳ありません...!!!ってなりました^^;
      特に引数として関数を指定するというスタイルは、自分の作る関数にも活かして行けると面白いなと感じます。