LoginSignup
5
5

複数の会議室や人の予定を並べて表示するJavaScriptライブラリーEvent Calendar

Posted at

商用利用が可能なJavaScript製のカレンダーUIライブラリーEvent Calendarを使って、複数のリソースの予約状況を並べて表示するデモを作ってみました。さらに、ステップ・バイ・ステップで、デモを作っていく様子をご紹介します。

Event Calendarを使う

Event Calendarは複数のリソースを並べて表示できるMITライセンスのカレンダーライブラリーです。素のJavaScript(Vanilla JS)で書かれていて軽量です。READMEが少し不足しているのか、あまりスターがついていないのが残念です。

まずはじめに、Event Calendarを使ったデモをご紹介します。

  • 3つのリソース(Room 1, Room 2, Room 3)の予約状況を表示します。
    • 重複する予約はできません。
    • Editable Eventはドラッグやサイズ変更が可能です。
    • Uneditable Eventはマウス操作では変更できません。クリックして表示されるダイアログから変更します。
  • カレンダー上の空いている時間をドラッグすると、新しく予約できます。
  • 画面右上にある矢印ボタンで日付を変更できます。今日から1週間分のみ表示できます。

See the Pen Event Calendar Demo by Hirokazu Takatama (@takatama) on CodePen.

このデモを作るまでの過程を、3ステップに分けて紹介していきます。

Step1: カレンダーを表示し、空き時間をドラッグして予定を追加

今回はCDNを使ってライブラリーを読み込みます。Event Calendarの他に、日付の計算するためにday.jsも利用します。

<body>にカレンダーを表示する<div>(id="ec")を挿入します。

ec.html
<head>
  <link href="https://cdn.jsdelivr.net/npm/@event-calendar/build@1.5.0/event-calendar.min.css" rel="stylesheet">
</head>
<body>
  <div id="ec"></div>
  <script src="https://cdn.jsdelivr.net/npm/@event-calendar/build@1.5.0/event-calendar.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.9/dayjs.min.js"></script>
</body>

表示するリソースresources、予定eventsを定義し、new EventCalendar(element, options)でカレンダーを生成します。

空き時間をドラッグしたときに予定を追加できるよう、カレンダーのオプションでselectable: trueにして、新しい予定が追加されたときに呼び出されるselect: function(event)を定義します。

select関数の中では、addEvent(event)で予定を追加します。その際、予定のevent.idevent.resourceIds[]を設定しています。

ec.js
const resources = [{
    id: 1, title: 'Room 1'
  }, {
    id: 2, title: 'Room 2'
  }, {
    id: 3, title: 'Room 3'
}];

function createDate(hours, minutes) {
  const now = new Date();
  now.setHours(hours);
  now.setMinutes(minutes);
  now.setSeconds(0);
  return now;
}

const events = [{
  id: 1,
  resourceIds: [1],
  start: createDate(9, 0),
  end: createDate(10, 30),
  title: 'Editable Event',
  editable: true,
}, {
  id: 2,
  resourceIds: [2],
  start: createDate(11, 0),
  end: createDate(13, 30),
  title: 'Uneditable Event',
  // editable: false, // not work
  startEditable: false,
  durationEditable: false,
  backgroundColor: 'red',
}];

const ec = new EventCalendar(document.getElementById('ec'), {
  resources,
  events,
  view: 'resourceTimeGridDay',
  allDaySlot: false,
  slotMinTime: '08:00:00',
  slotMaxTime: '19:00:00',
  nowIndicator: true,
  selectable: true,
  select: function(event) {
    addEvent(event);
  }
});

function addEvent(event) {
  event.id = new Date().getTime();
  event.resourceIds = [ event.resource.id ];
  ec.addEvent(event);
  ec.unselect();
}

Step2: 重複する予約は許可しない

重複する予約の追加や変更ができないようにします。

重複する予定がないか調べるために、新しく追加する(変更する)予定と、同じリソースで、かつ、時間帯が重なっている予定を取得するgetOverlappingEvents()を追加します。この関数を使って、重複する予定の存在を確認するhasOverlappingEvents()と、調査対象の予定自体を除いた他の重複する予定を確認するhasOtherOverlappingEvents()を定義します。

そして、カレンダー上に新しい予定を追加したときに呼び出されるselectの中で、重複する予定があれば予定の追加は中断します。また、ドロップ操作をしたときのeventDropと、予定をドラッグ操作で変更したときのeventResizeでは、重複する予定があるときに、その操作をなかったことにします(revert)。

ec.js
// resources, createDate(), events はそのまま

// ★追加
function getOverlappingEvents(event) {
  // select event has event.resource.id
  // eventDrop event has event.resourceIds
  const rId = event.resource ? event.resource.id : event.resourceIds[0];
  return ec.getEvents().filter(e => e.resourceIds[0] == rId && e => e.start < event.end && event.start < e.end);
}

// ★追加
function hasOverlappingEvents(event) {
  return getOverlappingEvents(event).length > 0;
}

// ★追加
function hasOtherOverlappingEvents(event) {
  return getOverlappingEvents(event).filter(e => e.id != event.id).length > 0
}

const ec = new EventCalendar(document.getElementById('ec'), {
  resources,
  events,
  view: 'resourceTimeGridDay',
  allDaySlot: false,
  slotMinTime: '08:00:00',
  slotMaxTime: '19:00:00',
  nowIndicator: true,
  selectable: true,
  select: function(event) {
    // ★追加
    if (hasOverlappingEvents(event)) {
      ec.unselect();
      return;
    }
    addEvent(event);
  },
  // ★追加
  eventDrop: function ({ event, revert }) {
    if (hasOtherOverlappingEvents(event)) revert();
  },
  // ★追加
  eventResize: function ({ event, revert }) {
    if (hasOtherOverlappingEvents(event)) revert();
  },
});

function addEvent(event) {
  event.id = new Date().getTime();
  event.resourceIds = [ event.resource.id ];
  ec.addEvent(event);
  ec.unselect();
}

Step3: この先1週間の予定だけ表示する

ずっと先の予約ができると困る場合があります。そこで、表示できる期間を制限してみます。

カレンダーのオプションで、画面右上にある矢印ボタンで日付を変更したときに呼び出されるdatesSetを追加します。startが表示している日付です。

toggleDateButtonsFor7Days()で、startが今日から1週間分のときだけボタンを有効にします。無効にしたときにボタンの表示が変わるよう、CSSも追加しておきます。

ec.js
const ec = new EventCalendar(document.getElementById('ec'), {
  ...
  },
  // ★追加
  datesSet: function ({ start }) {
    toggleDateButtonsFor7Days(start);
  }
});

// ★追加
function toggleDateButtonsFor7Days(start) {
  const next = document.querySelector('.ec-next');
  const prev = document.querySelector('.ec-prev');
  const now = dayjs();
  const targetDate = dayjs(start);
  prev.disabled = targetDate.isBefore(now);
  next.disabled = targetDate.isAfter(now.add(6, 'day'));  
}
ec.css
.ec-prev:disabled, .ec-next:disabled {
  background-color: #ced4da;
}

Step4: モーダルダイアログで予定のコメントを入力する

モーダルダイアログを作るのに便利なHTMLタグ<dialog>を使って、予定を作るときにコメントを入力できるようにします。カレンダーのオプションselectでは(addEvent()の代わりに)showModal()を呼び出します。dialogsubmitするときに、addEvent()で予定を追加します。

また、予定をクリックしたときに、ダイアログを表示します。カレンダーのオプションに、eventClickを追加して、showModal()を呼び出します。このままdialogsubmitするとaddEvent()が呼ばれますが、予定の追加ではなく更新にしたいので、event.idがある場合は更新だと判断して、カレンダーのupdateEvent()を呼び出すように変更します。

ec.html
<dialog id="dialog">
  <form id="form" method="dialog">
    <h2>Make Reservation</h2>
    <h3 id="room-name"></h3>
    <h3 id="date"></h3>
    <div class="row">
      <label for="start">Start:</label>
      <input type="time" id="start" name="start-time">
    </div>
    <div class="row">
      <label for="end">End:</label>
      <input type="time" id="end" name="end-time">
    </div>
    <div class="row">
      <label for="comment">Comment:</label>
      <textarea id="comment" name="comment"></textarea>
    </div>
    <menu>
      <button id="cancel" type="reset">Cancel</button>
      <button id="confirm" type="submit">Confirm</button>
    </menu>
  </form>
</dialog>
ec.css
dialog {
  width: 400px;
  padding: 20px;
  border: solid 1px;
}

.row {
  display: flex;
  margin: 10px;
}

.row label {
  width: 90px;
}

textarea {
  width: 250px;
}
ec.js
const ec = new EventCalendar(document.getElementById('ec'), {
  ...
  select: function (event) { // on create event
    if (hasOverlappingEvents(event)) {
      ec.unselect();
      return;
    }
    // ★変更
    showModal(event);
  },
  ...
  // ★追加
  eventClick: function ({ event }) {
    showModal(event);
  },
});

function addEvent(event) {
  // ★追加
  if (event.id) {
    ec.updateEvent(event);
    return;
  }
  event.id = new Date().getTime();
  event.resourceIds = [ event.resource.id ];
  ec.addEvent(event);
  ec.unselect();
}

const dialog = document.querySelector('dialog');

function showModal(event) {
  function getResourceTitle(event) {
    const resourceId = event.resource ? event.resource.id : event.resourceIds[0];
    const resource = resources.find(r => r.id == resourceId);
    return resource ? resource.title : '';
  }
  document.getElementById('room-name').innerText = getResourceTitle(event);
  const startDate = dayjs(event.start);
  document.getElementById('date').innerText = startDate.format('YYYY/MM/DD');
  document.getElementById('start').value = startDate.format('HH:mm');
  document.getElementById('end').value = dayjs(event.end).format('HH:mm');
  document.getElementById('comment').value = event.title || '';
  dialog.event = event;
  dialog.showModal();
}

document.getElementById('form').onsubmit = function(e) {
  e.preventDefault();
  const event = dialog.event;

  const startTime = document.getElementById('start').value.split(':');
  event.start.setHours(Number(startTime[0]));
  event.start.setMinutes(Number(startTime[1]));

  const endTime = document.getElementById('end').value.split(':');
  event.end.setHours(Number(endTime[0]));
  event.end.setMinutes(Number(endTime[1]));

  const comment = document.getElementById('comment').value;
  event.title = comment;
  addEvent(event);
  dialog.close();
}

document.getElementById('cancel').onclick = function() {
  dialog.close();
};

完成形

最後に、完成したコードを掲載します。

ec.html
Powered by Event Calendar <a target="_blank" href="https://github.com/vkurko/calendar">https://github.com/vkurko/calendar</a>

<div id="ec"></div>

<dialog id="dialog">
  <form id="form" method="dialog">
    <h2>Make Reservation</h2>
    <h3 id="room-name"></h3>
    <h3 id="date"></h3>
    <div class="row">
      <label for="start">Start:</label>
      <input type="time" id="start" name="start-time">
    </div>
    <div class="row">
      <label for="end">End:</label>
      <input type="time" id="end" name="end-time">
    </div>
    <div class="row">
      <label for="comment">Comment:</label>
      <textarea id="comment" name="comment"></textarea>
    </div>
    <menu>
      <button id="cancel" type="reset">Cancel</button>
      <button id="confirm" type="submit">Confirm</button>
    </menu>
  </form>
</dialog>

<script src="https://cdn.jsdelivr.net/npm/@event-calendar/build@1.5.0/event-calendar.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.9/dayjs.min.js"></script>
ec.css
.ec-prev:disabled, .ec-next:disabled {
  background-color: #ced4da;
}

dialog {
  width: 400px;
  padding: 20px;
  border: solid 1px;
}

.row {
  display: flex;
  margin: 10px;
}

.row label {
  width: 90px;
}

textarea {
  width: 250px;
}
ec.js
const resources = [
  { id: 1, title: 'Room 1' }, { id: 2, title: 'Room 2' }, { id: 3, title: 'Room 3'}
];

function createDate(hours, minutes) {
  const now = new Date();
  now.setHours(hours);
  now.setMinutes(minutes);
  now.setSeconds(0);
  return now;
}

const events = [{
  id: 1,
  resourceIds: [1],
  start: createDate(9, 0),
  end: createDate(10, 30),
  title: 'Editable Event',
  editable: true,
}, {
  id: 2,
  resourceIds: [2],
  start: createDate(11, 0),
  end: createDate(13, 30),
  title: 'Uneditable Event',
  // editable: false, // not work
  startEditable: false,
  durationEditable: false,
  backgroundColor: 'red',
}];

function getOverlappingEvents(event) {
  // select event has event.resource.id
  // eventDrop event has event.resourceIds
  const rId = event.resource ? event.resource.id : event.resourceIds[0];
  return ec.getEvents().filter(e => e.resourceIds[0] == rId && e => e.start < event.end && event.start < e.end);
}

function hasOverlappingEvents(event) {
  return getOverlappingEvents(event).length > 0;
}

function hasOtherOverlappingEvents(event) {
  return getOverlappingEvents(event).filter(e => e.id != event.id).length > 0
}

const ec = new EventCalendar(document.getElementById('ec'), {
  resources,
  events,
  view: 'resourceTimeGridDay',
  allDaySlot: false,
  slotMinTime: '08:00:00',
  slotMaxTime: '19:00:00',
  nowIndicator: true,
  selectable: true,
  select: function(event) {
    if (hasOverlappingEvents(event)) {
      ec.unselect();
      return;
    }
    showModal(event);
  },
  eventDrop: function ({ event, revert }) {
    if (hasOtherOverlappingEvents(event)) revert();
  },
  eventResize: function ({ event, revert }) {
    if (hasOtherOverlappingEvents(event)) revert();
  },
  datesSet: function ({ start }) {
    toggleDateButtonsFor7Days(start);
  },
  eventClick: function ({ event }) {
    showModal(event);
  },
});

function toggleDateButtonsFor7Days(start) {
  const next = document.querySelector('.ec-next');
  const prev = document.querySelector('.ec-prev');
  const now = dayjs();
  const targetDate = dayjs(start);
  prev.disabled = targetDate.isBefore(now);
  next.disabled = targetDate.isAfter(now.add(6, 'day'));  
}

function addEvent(event) {
  if (event.id) {
    ec.updateEvent(event);
    return;
  }
  event.id = new Date().getTime();
  event.resourceIds = [ event.resource.id ];
  ec.addEvent(event);
  ec.unselect();
}

const dialog = document.querySelector('dialog');

function showModal(event) {
  function getResourceTitle(event) {
    const resourceId = event.resource ? event.resource.id : event.resourceIds[0];
    const resource = resources.find(r => r.id == resourceId);
    return resource ? resource.title : '';
  }
  document.getElementById('room-name').innerText = getResourceTitle(event);
  const startDate = dayjs(event.start);
  document.getElementById('date').innerText = startDate.format('YYYY/MM/DD');
  document.getElementById('start').value = startDate.format('HH:mm');
  document.getElementById('end').value = dayjs(event.end).format('HH:mm');
  document.getElementById('comment').value = event.title || '';
  dialog.event = event;
  dialog.showModal();
}

document.getElementById('form').onsubmit = function(e) {
  e.preventDefault();
  const event = dialog.event;

  const startTime = document.getElementById('start').value.split(':');
  event.start.setHours(Number(startTime[0]));
  event.start.setMinutes(Number(startTime[1]));

  const endTime = document.getElementById('end').value.split(':');
  event.end.setHours(Number(endTime[0]));
  event.end.setMinutes(Number(endTime[1]));

  const comment = document.getElementById('comment').value;
  event.title = comment;
  addEvent(event);
  dialog.close();
}

document.getElementById('cancel').onclick = function() {
  dialog.close();
};
5
5
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5