paper_trail_viewer 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import React, {useEffect, useMemo, useState} from "react"
2
2
  import {render} from "react-dom"
3
3
  import {FieldValues, SubmitHandler, useForm} from "react-hook-form"
4
- import {Controls, Pagination, VersionsList} from "./components"
4
+ import {ContextMenu, Controls, Pagination, VersionsList} from "./components"
5
5
  import {ColumnPicks} from "./types"
6
6
 
7
7
  document.addEventListener("DOMContentLoaded", () => {
@@ -21,9 +21,10 @@ const App = () => {
21
21
  hasNextPage: false,
22
22
  versions: [],
23
23
  })
24
- const config = useConfig(initialParams)
25
24
 
26
- const submit: SubmitHandler<FieldValues> = (params) => {
25
+ const submitHandler: SubmitHandler<FieldValues> = (params) => {
26
+ ContextMenu.closeAll()
27
+
27
28
  // put form params into current URL
28
29
  const newURL = new URL(window.location.href.replace(/\?.*/, ""))
29
30
  Object.entries(params).forEach(
@@ -40,15 +41,14 @@ const App = () => {
40
41
  })
41
42
  }
42
43
 
43
- useEffect(() => submit(initialParams), [initialParams])
44
+ const submit = handleSubmit(submitHandler)
45
+ const config = useConfig({initialParams, setValue, submit})
46
+
47
+ useEffect(() => submitHandler(initialParams), [initialParams])
44
48
 
45
49
  return (
46
- <div className="p-2">
47
- <Controls
48
- config={config}
49
- onSubmit={handleSubmit(submit)}
50
- register={register}
51
- />
50
+ <div className="p-2" onClick={() => ContextMenu.closeAll()}>
51
+ <Controls config={config} onSubmit={() => submit()} register={register} />
52
52
 
53
53
  <VersionsList
54
54
  config={config}
@@ -60,7 +60,7 @@ const App = () => {
60
60
  hasNext={data.hasNextPage}
61
61
  onPageChange={(newPage: number) => {
62
62
  setValue("page", newPage)
63
- handleSubmit(submit)()
63
+ submit()
64
64
  }}
65
65
  page={watch("page")}
66
66
  />
@@ -82,7 +82,18 @@ const useInitialParamsFromURL = () =>
82
82
  }
83
83
  }, [])
84
84
 
85
- const useConfig = (initialParams: Record<string, unknown>) => {
85
+ const useConfig = ({
86
+ initialParams,
87
+ setValue,
88
+ submit,
89
+ }: {
90
+ initialParams: Record<string, unknown>
91
+ setValue: Function
92
+ submit: Function
93
+ }) => {
94
+ const el = document.getElementById("mount-paper-trail-viewer")
95
+ const allowRollback = el && el.dataset.allowRollback === "1"
96
+
86
97
  const [columns, setColumns] = useState({
87
98
  actions: true,
88
99
  changes: true,
@@ -96,5 +107,5 @@ const useConfig = (initialParams: Record<string, unknown>) => {
96
107
 
97
108
  const [viewed, setViewed] = useState([])
98
109
 
99
- return {columns, setColumns, viewed, setViewed}
110
+ return {allowRollback, columns, setColumns, viewed, setViewed}
100
111
  }
@@ -0,0 +1,61 @@
1
+ import React, {CSSProperties, useEffect, useMemo} from "react"
2
+
3
+ export const ContextMenu = ({
4
+ children,
5
+ className,
6
+ coords,
7
+ setCoords,
8
+ zIndex = 15,
9
+ }: {
10
+ children: React.ReactNode
11
+ className?: string
12
+ coords: [number, number]
13
+ setCoords: Function
14
+ zIndex?: number
15
+ }) => {
16
+ useEffect(() => {
17
+ const exit = () => setCoords(null)
18
+ const exitOnEsc = ({keyCode}: {keyCode: number}) => keyCode === 27 && exit()
19
+
20
+ document.body.addEventListener("close_all_contextmenus", exit)
21
+ document.body.addEventListener("contextmenu", exit)
22
+ document.body.addEventListener("keydown", exitOnEsc)
23
+ window.addEventListener("hashchange", exit)
24
+
25
+ return () => {
26
+ document.body.removeEventListener("close_all_contextmenus", exit)
27
+ document.body.removeEventListener("contextmenu", exit)
28
+ document.body.removeEventListener("keydown", exitOnEsc)
29
+ window.removeEventListener("hashchange", exit)
30
+ }
31
+ }, [setCoords])
32
+
33
+ if (!coords) return null
34
+
35
+ const [x, y] = coords
36
+
37
+ const menuStyle = useMemo(() => {
38
+ const style = {position: "fixed", zIndex} as CSSProperties
39
+
40
+ // extend from coords towards center of window
41
+ if (x < window.innerWidth / 2) style.left = x
42
+ else style.right = window.innerWidth - x
43
+ if (y < window.innerHeight / 2) style.top = y
44
+ else style.bottom = window.innerHeight - y
45
+
46
+ return style
47
+ }, [x, y])
48
+
49
+ return (
50
+ <div
51
+ className={className}
52
+ onClick={(e) => e.stopPropagation()}
53
+ style={menuStyle}
54
+ >
55
+ {children}
56
+ </div>
57
+ )
58
+ }
59
+
60
+ ContextMenu["closeAll"] = () =>
61
+ document.body.dispatchEvent(new Event("close_all_contextmenus"))
@@ -1,3 +1,4 @@
1
+ export * from "./context_menu"
1
2
  export * from "./controls"
2
3
  export * from "./pagination"
3
4
  export * from "./versions_list"
@@ -0,0 +1,110 @@
1
+ import React, {useCallback} from "react"
2
+ import {Version, VersionContextMenuProps} from "../types"
3
+ import {ContextMenu} from "./context_menu"
4
+ import {FullObjectModal} from "./full_object_modal"
5
+
6
+ export const VersionContextMenu = (props: VersionContextMenuProps) =>
7
+ props.coords ? <VersionContexMenuComponent {...props} /> : null
8
+
9
+ const VersionContexMenuComponent = ({
10
+ config,
11
+ coords,
12
+ version,
13
+ setCoords,
14
+ }: VersionContextMenuProps) => (
15
+ <ContextMenu
16
+ className="bg-light border border-bottom-0 p-0"
17
+ coords={coords}
18
+ setCoords={setCoords}
19
+ >
20
+ <div className="d-flex flex-column" style={{cursor: "pointer"}}>
21
+ <MenuItem show>
22
+ <div
23
+ onClick={() => {
24
+ setCoords(null)
25
+ config.setViewed([...config.viewed, version.id])
26
+ }}
27
+ >
28
+ <input type="checkbox" checked={false} />
29
+ &nbsp;
30
+ <span>Viewed</span>
31
+ </div>
32
+ </MenuItem>
33
+
34
+ <MenuItem show={!!version.object}>
35
+ <FullObjectModal object={version.object} title="👁️ Before" />
36
+ </MenuItem>
37
+
38
+ <MenuItem show={!!version.changeset}>
39
+ <FullObjectModal
40
+ object={merge(version.object, version.changeset)}
41
+ title="👁️ After"
42
+ />
43
+ </MenuItem>
44
+
45
+ <MenuItem show={!!version.item_url}>
46
+ <a
47
+ href={version.item_url}
48
+ style={{textDecoration: "none"}}
49
+ target="_blank"
50
+ >
51
+ ↗️ See live
52
+ </a>
53
+ </MenuItem>
54
+
55
+ <MenuItem show={!/[?&]item_id/.test(window.location.search)}>
56
+ <a
57
+ href={`${window.location.pathname}?item_type=${version.item_type}&item_id=${version.item_id}`}
58
+ style={{textDecoration: "none"}}
59
+ >
60
+ 📌 Track item
61
+ </a>
62
+ </MenuItem>
63
+
64
+ <MenuItem show={config.allowRollback}>
65
+ <RollBack version={version} />
66
+ </MenuItem>
67
+ </div>
68
+ </ContextMenu>
69
+ )
70
+
71
+ const MenuItem = ({
72
+ children,
73
+ show,
74
+ }: {
75
+ children: React.ReactNode
76
+ show: boolean
77
+ }) => show && <div className="border-bottom p-2">{children}</div>
78
+
79
+ const merge = (object: Version["object"], changeset: Version["changeset"]) => {
80
+ const newState = {...object}
81
+ Object.entries(changeset).forEach(([k, [_, newValue]]) => {
82
+ newState[k] = newValue
83
+ })
84
+ return newState
85
+ }
86
+
87
+ const RollBack = ({version: {event, id}}: {version: Version}) => {
88
+ const showRollBackDialog = useCallback(() => {
89
+ if (confirm(`${rollBackInfo[event]}! Are you sure you want to do this?`)) {
90
+ rollBack(id)
91
+ }
92
+ }, [event, id])
93
+
94
+ return <span onClick={() => showRollBackDialog()}>↩️ Roll back</span>
95
+ }
96
+
97
+ const rollBackInfo = {
98
+ create: "As this is a `create` version, this will destroy the record",
99
+ destroy: "As this is a `destroy` version, this will restore the record",
100
+ update: "This will put the record back in the state before this version",
101
+ }
102
+
103
+ const rollBack = (id: Version["id"]) => {
104
+ fetch(`${window.location.pathname}/versions/${id}`, {method: "PATCH"})
105
+ .then((response) => response.json())
106
+ .then((data) => {
107
+ alert(data.message)
108
+ if (data.success) window.location.reload()
109
+ })
110
+ }
@@ -1,7 +1,8 @@
1
- import React, {useCallback} from "react"
2
- import {Version, stickyStyle, Config, ViewedList} from "../types"
1
+ import React, {useCallback, useState} from "react"
2
+ import {Version, stickyStyle, Config} from "../types"
3
3
  import {ChangeDiff} from "./change_diff"
4
- import {FullObjectModal} from "./full_object_modal"
4
+ import {ContextMenu} from "./context_menu"
5
+ import {VersionContextMenu} from "./version_context_menu"
5
6
 
6
7
  export const VersionsList: React.FC<{
7
8
  config: Config
@@ -12,7 +13,7 @@ export const VersionsList: React.FC<{
12
13
 
13
14
  if (versions.length === 0) return <Info text="No versions found." />
14
15
 
15
- const {columns, viewed, setViewed} = config
16
+ const {columns, viewed} = config
16
17
 
17
18
  return (
18
19
  <table className="table">
@@ -39,7 +40,7 @@ export const VersionsList: React.FC<{
39
40
  if (viewed.includes(v.id)) return null
40
41
 
41
42
  return (
42
- <tr key={i} data-ci-type="version-row">
43
+ <tr key={i} data-ci-type="version-row" data-ci-id={v.id}>
43
44
  {columns.version_id && <td>{v.id}</td>}
44
45
  {columns.item_type && <td>{v.item_type}</td>}
45
46
  {columns.item_id && <td>{v.item_id}</td>}
@@ -47,9 +48,7 @@ export const VersionsList: React.FC<{
47
48
  {columns.whodunnit && <TdWhodunnit v={v} />}
48
49
  {columns.time && <TdTime v={v} />}
49
50
  {columns.changes && <TdChanges v={v} />}
50
- {columns.actions && (
51
- <TdActions viewed={viewed} setViewed={setViewed} v={v} />
52
- )}
51
+ {columns.actions && <TdActions config={config} v={v} />}
53
52
  </tr>
54
53
  )
55
54
  })}
@@ -96,47 +95,38 @@ const TdChanges = ({v}: {v: Version}) => (
96
95
  <td>{v.changeset && <ChangeDiff changeset={v.changeset} />}</td>
97
96
  )
98
97
 
99
- const TdActions = ({
100
- viewed,
101
- setViewed,
102
- v,
103
- }: {
104
- viewed: ViewedList
105
- setViewed: Function
106
- v: Version
107
- }) => (
108
- <td>
109
- <div className="d-flex flex-column">
110
- <div onClick={() => setViewed([...viewed, v.id])}>
111
- <input type="checkbox" checked={false} />
112
- &nbsp;
113
- <span>Viewed</span>
114
- </div>
115
- {v.object && <FullObjectModal object={v.object} title="👁️ Before" />}
116
- {v.changeset && (
117
- <FullObjectModal
118
- object={merge(v.object, v.changeset)}
119
- title="👁️ After"
120
- />
121
- )}
122
- {v.item_url && (
123
- <a href={v.item_url} style={{textDecoration: "none"}} target="_blank">
124
- ↗️ See live
125
- </a>
126
- )}
127
- </div>
128
- </td>
129
- )
98
+ const TdActions = ({config, v}: {config: Config; v: Version}) => {
99
+ const [menuCoords, setMenuCoords] = useState<null | [number, number]>(null)
100
+ const showMenu = useCallback(
101
+ (event: React.MouseEvent) => {
102
+ event.preventDefault()
103
+ ContextMenu.closeAll()
104
+ setMenuCoords([event.clientX, event.clientY])
105
+ },
106
+ [setMenuCoords]
107
+ )
108
+
109
+ return (
110
+ <td>
111
+ <button
112
+ className="btn btn-outline-secondary rounded-circle px-3"
113
+ data-ci-type="version-action-button"
114
+ onClick={showMenu}
115
+ >
116
+
117
+ </button>
118
+
119
+ <VersionContextMenu
120
+ config={config}
121
+ coords={menuCoords}
122
+ setCoords={setMenuCoords}
123
+ version={v}
124
+ />
125
+ </td>
126
+ )
127
+ }
130
128
 
131
129
  const truncate = (str: string, len: number) => {
132
130
  if (!str || str.length <= len) return str
133
131
  return str.substring(0, len) + "…"
134
132
  }
135
-
136
- const merge = (object: Version["object"], changeset: Version["changeset"]) => {
137
- const newState = {...object}
138
- Object.entries(changeset).forEach(([k, [_, newValue]]) => {
139
- newState[k] = newValue
140
- })
141
- return newState
142
- }
@@ -16,6 +16,7 @@ export type ColumnPicks = Record<Column, boolean>
16
16
  export type ViewedList = Version["id"][]
17
17
 
18
18
  export type Config = {
19
+ allowRollback: boolean
19
20
  columns: ColumnPicks
20
21
  setColumns: React.Dispatch<React.SetStateAction<ColumnPicks>>
21
22
  viewed: ViewedList
@@ -27,7 +28,7 @@ export interface Version {
27
28
  item_type: string
28
29
  item_id: string
29
30
  whodunnit?: string
30
- event: string
31
+ event: "create" | "update" | "destroy"
31
32
  created_at: string
32
33
  changeset: Record<string, [unknown, unknown]>
33
34
  object: Record<string, unknown>
@@ -35,6 +36,13 @@ export interface Version {
35
36
  user_url?: string
36
37
  }
37
38
 
39
+ export interface VersionContextMenuProps {
40
+ config: Config
41
+ coords: null | [number, number]
42
+ setCoords: Function
43
+ version: Version
44
+ }
45
+
38
46
  export const stickyStyle = {
39
47
  position: "sticky",
40
48
  background: "#ffffffdd",
@@ -3,28 +3,29 @@ require 'kaminari'
3
3
  module PaperTrailViewer::DataSource
4
4
  # The default data source. Queries version records via ActiveRecord.
5
5
  # See DataSource::Bigquery::Adapter for how to implement another source.
6
- class ActiveRecord
7
- def initialize(model: 'PaperTrail::Version')
8
- @model = model
6
+ class ActiveRecord < Base
7
+ def initialize(version_class: 'PaperTrail::Version')
8
+ @version_class = version_class
9
9
  end
10
10
 
11
- def call(item_type: nil, item_id: nil, event: nil, filter: nil, page: 1, per_page: 50)
12
- versions = @model.is_a?(String) ? @model.constantize : @model
13
- versions = versions.order('created_at DESC, id DESC')
11
+ # @param [PaperTrailViewer::Query]
12
+ def perform_query(q)
13
+ version_class = q.version_class || @version_class
14
+ versions = version_class.is_a?(String) ? version_class.constantize : version_class
14
15
 
15
16
  if 'object_changes'.in?(versions.column_names)
16
17
  # Ignore blank versions that only touch updated_at or untracked fields.
17
18
  versions = versions.where.not(object_changes: '')
18
- versions = versions.where('object_changes LIKE ?', "%#{filter}%") if filter.present?
19
+ versions = versions.where('object_changes LIKE ?', "%#{q.filter}%") if q.filter.present?
19
20
  elsif 'object'.in?(versions.column_names)
20
- versions = versions.where('object LIKE ?', "%#{filter}%") if filter.present?
21
+ versions = versions.where('object LIKE ?', "%#{q.filter}%") if q.filter.present?
21
22
  end
22
23
 
23
- versions = versions.where(item_type: item_type) if item_type.present?
24
- versions = versions.where(item_id: item_id) if item_id.present?
25
- versions = versions.where(event: event) if event.present?
24
+ versions = versions.where(item_type: q.item_type) if q.item_type.present?
25
+ versions = versions.where(item_id: q.item_id) if q.item_id.present?
26
+ versions = versions.where(event: q.event) if q.event.present?
26
27
 
27
- versions.page(page).per(per_page)
28
+ versions.order('created_at DESC, id DESC').page(q.page).per(q.per_page)
28
29
  end
29
30
  end
30
31
  end
@@ -0,0 +1,87 @@
1
+ require 'kaminari'
2
+
3
+ module PaperTrailViewer
4
+ module DataSource
5
+ # Abstract superclass for version data sources.
6
+ class Base
7
+ def call(params, main_app)
8
+ query = PaperTrailViewer::Query.new(params)
9
+ relation = perform_query(query)
10
+ versions_as_json = relation.map { |v| version_as_json(v, main_app) }
11
+
12
+ {
13
+ allowRollback: PaperTrailViewer.allow_rollback,
14
+ hasNextPage: versions_as_json.any? && !relation.last_page?,
15
+ query: query.to_h,
16
+ versions: versions_as_json,
17
+ }
18
+ end
19
+
20
+ private
21
+
22
+ def version_as_json(version, main_app)
23
+ {
24
+ **version.attributes.slice(*%w[id whodunnit event created_at]),
25
+ changeset: changeset_for(version),
26
+ object: load_object(version),
27
+ item_id: version.item_id.to_s,
28
+ item_type: version.item_type,
29
+ item_url: change_item_url(version, main_app),
30
+ user_url: user_url(version, main_app),
31
+ }
32
+ end
33
+
34
+ def changeset_for(version)
35
+ case version.event
36
+ when 'create', 'update'
37
+ version.changeset || {}
38
+ when 'destroy'
39
+ record = version.reify rescue nil
40
+ return {} unless record
41
+
42
+ record.attributes.each_with_object({}) do |changes, (k, v)|
43
+ changes[k] = [v, nil] unless v.nil?
44
+ end
45
+ else
46
+ raise ArgumentError, "Unknown event: #{version.event}"
47
+ end
48
+ end
49
+
50
+ # Return the URL for the item represented by the +version+,
51
+ # e.g. a Company record instance referenced by a version.
52
+ def change_item_url(version, main_app)
53
+ version_type = version.item_type.underscore.split('/').last
54
+ main_app.try("#{version_type}_url", version.item_id)
55
+ end
56
+
57
+ def user_url(version, main_app)
58
+ (path_method = PaperTrailViewer.user_path_method).present? &&
59
+ (id = version.whodunnit).present? &&
60
+ !id.start_with?('#') &&
61
+ main_app.try(path_method, id) ||
62
+ nil
63
+ end
64
+
65
+ def load_object(version)
66
+ obj = version.object
67
+ if obj.is_a?(String)
68
+ PaperTrail.serializer.load(obj)
69
+ elsif obj.is_a?(Hash)
70
+ OpenStruct.new(obj)
71
+ else
72
+ obj
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ # backport deserialization fix for recent rubies
80
+ # https://github.com/paper-trail-gem/paper_trail/pull/1338
81
+ if PaperTrail::VERSION.to_s < '12.2.0'
82
+ module PaperTrail::Serializers::YAML
83
+ def load(string)
84
+ ::YAML.respond_to?(:unsafe_load) ? ::YAML.unsafe_load(string) : ::YAML.load(string)
85
+ end
86
+ end
87
+ end
@@ -1,5 +1,5 @@
1
1
  module PaperTrailViewer::DataSource
2
- class Bigquery
2
+ class Bigquery < Base
3
3
  def initialize(project_id:, credentials:, table:)
4
4
  require 'google/cloud/bigquery'
5
5
 
@@ -10,21 +10,22 @@ module PaperTrailViewer::DataSource
10
10
  @table = table
11
11
  end
12
12
 
13
- def call(item_type: nil, item_id: nil, event: nil, filter: nil, page: 1, per_page: 50)
13
+ # @param [PaperTrailViewer::Query]
14
+ def perform_query(q)
14
15
  # https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax
15
- bigquery_result = @bigquery.query(<<~SQL, max: per_page)
16
+ bigquery_result = @bigquery.query(<<~SQL, max: q.per_page)
16
17
  SELECT *
17
18
  FROM `#{@table}`
18
19
  # Ignore blank versions that only touch updated_at or untracked fields.
19
20
  WHERE object_changes != ''
20
- #{"AND item_type = '#{item_type}'" if item_type.present?}
21
- #{"AND item_id = #{item_id}" if item_id.present?}
22
- #{"AND event = '#{event}'" if event.present?}
23
- #{"AND object_changes LIKE '%#{filter}%'" if filter.present?}
21
+ #{"AND item_type = '#{q.item_type}'" if q.item_type.present?}
22
+ #{"AND item_id = #{q.item_id}" if q.item_id.present?}
23
+ #{"AND event = '#{q.event}'" if q.event.present?}
24
+ #{"AND object_changes LIKE '%#{q.filter}%'" if q.filter.present?}
24
25
  ORDER BY created_at DESC, id DESC
25
26
  # Paginate via OFFSET.
26
27
  # LIMIT must be greater than `max:` or result#next? is always false.
27
- LIMIT #{per_page + 1} OFFSET #{(page - 1) * per_page}
28
+ LIMIT #{q.per_page + 1} OFFSET #{(q.page - 1) * q.per_page}
28
29
  SQL
29
30
 
30
31
  Adapter.new(bigquery_result)
@@ -0,0 +1,23 @@
1
+ module PaperTrailViewer
2
+ Query = Struct.new(*%i[event filter item_id item_type page per_page version_class]) do
3
+ def initialize(params)
4
+ self.event = params[:event].presence_in(%w[create update destroy])
5
+ self.filter = (params[:filter].presence if params[:filter] != '%%')
6
+ self.item_id = params[:item_id].presence
7
+ self.item_type = params[:item_type].presence
8
+ self.page = params[:page].to_i.clamp(1, 1000)
9
+ self.per_page = params[:per_page].presence&.to_i&.clamp(1, 1000) || 50
10
+
11
+ # If an item_type is given, try to see if it uses a custom version class.
12
+ if item_type
13
+ begin
14
+ item_class = item_type.classify.constantize
15
+ self.version_class = item_class.version_class_name
16
+ rescue NameError, NoMethodError
17
+ # We might be looking at a model that is deleted or no longer versioned.
18
+ # Ignore and give it a try with the default version class.
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,20 @@
1
+ module PaperTrailViewer::Rollback
2
+ def self.call(version_class: PaperTrail::Version, version_id:)
3
+ PaperTrailViewer.allow_rollback or raise 'Rollback not allowed'
4
+
5
+ version = version_class.find(version_id)
6
+
7
+ if version.event == 'create'
8
+ version.item_type.constantize.find(version.item_id).destroy!
9
+ Result.new(true, 'Rolled back newly-created record by destroying it.')
10
+ else
11
+ version.reify.save!
12
+ Result.new(true, 'Rolled back changes to this record.')
13
+ end
14
+
15
+ rescue StandardError => e
16
+ Result.new(false, "#{e.class}: #{e.message}")
17
+ end
18
+
19
+ Result = Struct.new(:success, :message)
20
+ end
@@ -1,3 +1,3 @@
1
1
  module PaperTrailViewer
2
- VERSION = '1.0.0'
2
+ VERSION = '1.1.0'
3
3
  end
@@ -1,11 +1,15 @@
1
1
  require 'paper_trail'
2
2
 
3
+ require_relative 'paper_trail_viewer/data_source/base'
3
4
  require_relative 'paper_trail_viewer/data_source/active_record'
4
5
  require_relative 'paper_trail_viewer/data_source/bigquery'
5
6
  require_relative 'paper_trail_viewer/engine'
7
+ require_relative 'paper_trail_viewer/query'
8
+ require_relative 'paper_trail_viewer/rollback'
6
9
  require_relative 'paper_trail_viewer/version'
7
10
 
8
11
  module PaperTrailViewer
12
+ mattr_accessor(:allow_rollback) { true }
9
13
  mattr_accessor(:data_source) { DataSource::ActiveRecord.new }
10
14
  mattr_accessor(:user_path_method) { :user_path }
11
15
  end
@@ -13,7 +13,6 @@ Gem::Specification.new do |spec|
13
13
  spec.license = 'MIT'
14
14
 
15
15
  spec.files = `git ls-files -z`.split("\x0") + %w[javascript/compiled.js]
16
- spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
16
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
17
  spec.require_paths = ['lib']
19
18