validation.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import { EventStore, expandRecurring, eventTupleToStore, mapEventInstances, filterEventStoreDefs, isEventDefsGrouped } from './structs/event-store'
  2. import Calendar from './Calendar'
  3. import { DateSpan, parseOpenDateSpan, OpenDateSpanInput, OpenDateSpan, isSpanPropsEqual, isSpanPropsMatching, buildDateSpanApi, DateSpanApi } from './structs/date-span'
  4. import { EventInstance, EventDef, EventTuple, parseEvent } from './structs/event'
  5. import { EventSourceHash } from './structs/event-source'
  6. import { rangeContainsRange, rangesIntersect } from './datelib/date-range'
  7. import EventApi from './api/EventApi'
  8. // TODO: rename to "criteria" ?
  9. export type ConstraintInput = 'businessHours' | string | OpenDateSpanInput | { [timeOrRecurringProp: string]: any }
  10. export type Constraint = 'businessHours' | string | OpenDateSpan | EventTuple
  11. export type Overlap = boolean | ((stillEvent: EventApi, movingEvent: EventApi | null) => boolean)
  12. export type Allow = (span: DateSpanApi, movingEvent: EventApi | null) => boolean
  13. interface ValidationEntity {
  14. dateSpan: DateSpan
  15. event: EventTuple | null
  16. constraint: Constraint | null // in addition to calendar's
  17. overlap: boolean | null // in addition to calendar's. granular entities can't provide functions
  18. allow: Allow | null // in addition to calendar's
  19. }
  20. export function isEventsValid(eventStore: EventStore, calendar: Calendar): boolean {
  21. return isEntitiesValid(
  22. eventStoreToEntities(eventStore, calendar.state.eventSources),
  23. normalizeConstraint(calendar.opt('eventConstraint'), calendar),
  24. calendar.opt('eventOverlap'),
  25. calendar.opt('eventAllow'),
  26. calendar
  27. )
  28. }
  29. export function isSelectionValid(selection: DateSpan, calendar: Calendar): boolean {
  30. return isEntitiesValid(
  31. [ { dateSpan: selection, event: null, constraint: null, overlap: null, allow: null } ],
  32. normalizeConstraint(calendar.opt('selectConstraint'), calendar),
  33. calendar.opt('selectOverlap'),
  34. calendar.opt('selectAllow'),
  35. calendar
  36. )
  37. }
  38. function isEntitiesValid(
  39. entities: ValidationEntity[],
  40. globalConstraint: Constraint | null,
  41. globalOverlap: Overlap | null,
  42. globalAllow: Allow | null,
  43. calendar: Calendar
  44. ): boolean {
  45. let state = calendar.state
  46. for (let entity of entities) {
  47. if (
  48. !isDateSpanWithinConstraint(entity.dateSpan, entity.constraint, calendar) ||
  49. !isDateSpanWithinConstraint(entity.dateSpan, globalConstraint, calendar)
  50. ) {
  51. return false
  52. }
  53. }
  54. let eventEntities = eventStoreToEntities(state.eventStore, state.eventSources)
  55. for (let subjectEntity of entities) {
  56. for (let eventEntity of eventEntities) {
  57. if (
  58. ( // not comparing the same/related event
  59. !subjectEntity.event ||
  60. !eventEntity.event ||
  61. isEventsCollidable(subjectEntity.event, eventEntity.event)
  62. ) &&
  63. dateSpansCollide(subjectEntity.dateSpan, eventEntity.dateSpan) // a collision!
  64. ) {
  65. if (
  66. subjectEntity.overlap === false ||
  67. (eventEntity.overlap === false && subjectEntity.event) || // the eventEntity doesn't like two events colliding
  68. !isOverlapValid(eventEntity.event, subjectEntity.event, globalOverlap, calendar)
  69. ) {
  70. return false
  71. }
  72. }
  73. }
  74. }
  75. for (let entity of entities) {
  76. if (
  77. !isDateSpanAllowed(entity.dateSpan, entity.event, entity.allow, calendar) ||
  78. !isDateSpanAllowed(entity.dateSpan, entity.event, globalAllow, calendar)
  79. ) {
  80. return false
  81. }
  82. }
  83. return true
  84. }
  85. // do we want to compare these events for collision?
  86. // say no if events are the same, or if they share a groupId
  87. function isEventsCollidable(event0: EventTuple, event1: EventTuple): boolean {
  88. if (event0.instance.instanceId === event1.instance.instanceId) {
  89. return false
  90. }
  91. return !isEventDefsGrouped(event0.def, event1.def)
  92. }
  93. function eventStoreToEntities(eventStore: EventStore, eventSources: EventSourceHash): ValidationEntity[] {
  94. return mapEventInstances(eventStore, function(eventInstance: EventInstance, eventDef: EventDef): ValidationEntity {
  95. let eventSource = eventSources[eventDef.sourceId]
  96. let constraint = eventDef.constraint as Constraint
  97. let overlap = eventDef.overlap as boolean
  98. if (constraint == null && eventSource) {
  99. constraint = eventSource.constraint
  100. }
  101. if (overlap == null && eventSource) {
  102. overlap = eventSource.overlap
  103. if (overlap == null) {
  104. overlap = true
  105. }
  106. }
  107. return {
  108. dateSpan: eventToDateSpan(eventDef, eventInstance),
  109. event: { def: eventDef, instance: eventInstance },
  110. constraint,
  111. overlap,
  112. allow: eventSource ? eventSource.allow : null
  113. }
  114. })
  115. }
  116. function isDateSpanWithinConstraint(subjectSpan: DateSpan, constraint: Constraint | null, calendar: Calendar): boolean {
  117. if (constraint === null) {
  118. return true // doesn't care
  119. }
  120. let constrainingSpans: DateSpan[] = constraintToSpans(constraint, subjectSpan, calendar)
  121. for (let constrainingSpan of constrainingSpans) {
  122. if (dateSpanContainsOther(constrainingSpan, subjectSpan)) {
  123. return true
  124. }
  125. }
  126. return false // not contained by any one of the constrainingSpans
  127. }
  128. function constraintToSpans(constraint: Constraint, subjectSpan: DateSpan, calendar: Calendar): DateSpan[] {
  129. if (constraint === 'businessHours') {
  130. let store = getPeerBusinessHours(subjectSpan, calendar)
  131. store = expandRecurring(store, subjectSpan.range, calendar)
  132. return eventStoreToDateSpans(store)
  133. } else if (typeof constraint === 'string') { // an ID
  134. let store = filterEventStoreDefs(calendar.state.eventStore, function(eventDef) {
  135. return eventDef.groupId === constraint
  136. })
  137. return eventStoreToDateSpans(store)
  138. } else if (typeof constraint === 'object' && constraint) { // non-null object
  139. if ((constraint as EventTuple).def) { // an event definition (actually, a tuple)
  140. let store = eventTupleToStore(constraint as EventTuple)
  141. store = expandRecurring(store, subjectSpan.range, calendar)
  142. return eventStoreToDateSpans(store)
  143. } else {
  144. return [ constraint as OpenDateSpan ] // already parsed datespan
  145. }
  146. }
  147. return []
  148. }
  149. function isOverlapValid(still: EventTuple, moving: EventTuple | null, overlap: Overlap | null, calendar: Calendar): boolean {
  150. if (typeof overlap === 'boolean') {
  151. return overlap
  152. } else if (typeof overlap === 'function') {
  153. return Boolean(
  154. overlap(
  155. new EventApi(calendar, still.def, still.instance),
  156. moving ? new EventApi(calendar, moving.def, moving.instance) : null
  157. )
  158. )
  159. }
  160. return true
  161. }
  162. function isDateSpanAllowed(dateSpan: DateSpan, moving: EventTuple | null, allow: Allow | null, calendar: Calendar): boolean {
  163. if (typeof allow === 'function') {
  164. return Boolean(
  165. allow(
  166. buildDateSpanApi(dateSpan, calendar.dateEnv),
  167. moving ? new EventApi(calendar, moving.def, moving.instance) : null
  168. )
  169. )
  170. }
  171. return true
  172. }
  173. function dateSpansCollide(span0: DateSpan, span1: DateSpan): boolean {
  174. return rangesIntersect(span0.range, span1.range) && isSpanPropsEqual(span0, span1)
  175. }
  176. function dateSpanContainsOther(outerSpan: DateSpan, subjectSpan: DateSpan): boolean {
  177. return rangeContainsRange(outerSpan.range, subjectSpan.range) &&
  178. isSpanPropsMatching(subjectSpan, outerSpan) // subjectSpan has all the props that outerSpan has?
  179. }
  180. function eventStoreToDateSpans(store: EventStore): DateSpan[] {
  181. return mapEventInstances(store, function(instance: EventInstance, def: EventDef) {
  182. return eventToDateSpan(def, instance)
  183. })
  184. }
  185. // TODO: plugin
  186. export function eventToDateSpan(def: EventDef, instance: EventInstance): DateSpan {
  187. return {
  188. allDay: def.allDay,
  189. range: instance.range
  190. }
  191. }
  192. // TODO: plugin
  193. function getPeerBusinessHours(subjectSpan: DateSpan, calendar: Calendar): EventStore {
  194. return calendar.component.view.props.businessHours // accessing view :(
  195. }
  196. export function normalizeConstraint(input: ConstraintInput, calendar: Calendar): Constraint | null {
  197. if (typeof input === 'object' && input) { // non-null object
  198. let span = parseOpenDateSpan(input, calendar.dateEnv)
  199. if (span === null || span.range.start || span.range.end) {
  200. return span
  201. } else { // if completely-open range, assume it's a recurring event (prolly with startTime/endTime)
  202. return parseEvent(input, '', calendar)
  203. }
  204. } else if (input != null) {
  205. return String(input)
  206. } else {
  207. return null
  208. }
  209. }