paper_trail_viewer 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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