paper_trail_viewer 1.0.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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +43 -0
  3. data/.gitignore +41 -0
  4. data/Appraisals +13 -0
  5. data/CHANGELOG.md +9 -0
  6. data/Gemfile +4 -0
  7. data/LICENSE.txt +23 -0
  8. data/README.md +53 -0
  9. data/Rakefile +45 -0
  10. data/app/controllers/paper_trail_viewer/js_controller.rb +14 -0
  11. data/app/controllers/paper_trail_viewer/versions_controller.rb +119 -0
  12. data/app/controllers/paper_trail_viewer/viewer_controller.rb +4 -0
  13. data/app/views/paper_trail_viewer/viewer/index.html.erb +5 -0
  14. data/bin/setup +8 -0
  15. data/config/routes.rb +5 -0
  16. data/gemfiles/rails_6.0_paper_trail_11.1.gemfile +10 -0
  17. data/gemfiles/rails_6.0_paper_trail_12.2.gemfile +10 -0
  18. data/gemfiles/rails_7.0_paper_trail_12.2.gemfile +10 -0
  19. data/javascript/compiled.js +2 -0
  20. data/javascript/src/app.tsx +100 -0
  21. data/javascript/src/components/change_diff.tsx +57 -0
  22. data/javascript/src/components/config_modal.tsx +22 -0
  23. data/javascript/src/components/controls.tsx +62 -0
  24. data/javascript/src/components/full_object_modal.tsx +21 -0
  25. data/javascript/src/components/index.ts +3 -0
  26. data/javascript/src/components/modal.tsx +34 -0
  27. data/javascript/src/components/pagination.tsx +53 -0
  28. data/javascript/src/components/versions_list.tsx +142 -0
  29. data/javascript/src/index.ts +1 -0
  30. data/javascript/src/types.ts +42 -0
  31. data/lib/paper_trail_viewer/data_source/active_record.rb +30 -0
  32. data/lib/paper_trail_viewer/data_source/bigquery.rb +45 -0
  33. data/lib/paper_trail_viewer/engine.rb +5 -0
  34. data/lib/paper_trail_viewer/version.rb +3 -0
  35. data/lib/paper_trail_viewer.rb +11 -0
  36. data/package.json +43 -0
  37. data/paper_trail_viewer.gemspec +34 -0
  38. data/spec/app_template.rb +19 -0
  39. data/spec/rails_helper.rb +18 -0
  40. data/spec/support/factories.rb +13 -0
  41. data/spec/system/paper_trail_viewer_spec.rb +114 -0
  42. data/tsconfig.json +13 -0
  43. data/webpack.config.js +26 -0
  44. metadata +251 -0
@@ -0,0 +1,100 @@
1
+ import React, {useEffect, useMemo, useState} from "react"
2
+ import {render} from "react-dom"
3
+ import {FieldValues, SubmitHandler, useForm} from "react-hook-form"
4
+ import {Controls, Pagination, VersionsList} from "./components"
5
+ import {ColumnPicks} from "./types"
6
+
7
+ document.addEventListener("DOMContentLoaded", () => {
8
+ const el = document.getElementById("mount-paper-trail-viewer")
9
+ el && render(<App />, el)
10
+ })
11
+
12
+ const App = () => {
13
+ const initialParams = useInitialParamsFromURL()
14
+
15
+ const {register, handleSubmit, watch, setValue} = useForm({
16
+ defaultValues: initialParams,
17
+ })
18
+
19
+ const [loading, setLoading] = useState(true)
20
+ const [data, setData] = useState({
21
+ hasNextPage: false,
22
+ versions: [],
23
+ })
24
+ const config = useConfig(initialParams)
25
+
26
+ const submit: SubmitHandler<FieldValues> = (params) => {
27
+ // put form params into current URL
28
+ const newURL = new URL(window.location.href.replace(/\?.*/, ""))
29
+ Object.entries(params).forEach(
30
+ ([k, v]) => v && newURL.searchParams.set(k, v)
31
+ )
32
+ window.history.replaceState({}, "", newURL)
33
+
34
+ // call API to fetch matching versions
35
+ fetch(`${window.location.pathname}/versions?${newURL.searchParams}`)
36
+ .then((response) => response.json())
37
+ .then((data) => {
38
+ setData(data)
39
+ setLoading(false)
40
+ })
41
+ }
42
+
43
+ useEffect(() => submit(initialParams), [initialParams])
44
+
45
+ return (
46
+ <div className="p-2">
47
+ <Controls
48
+ config={config}
49
+ onSubmit={handleSubmit(submit)}
50
+ register={register}
51
+ />
52
+
53
+ <VersionsList
54
+ config={config}
55
+ loading={loading}
56
+ versions={data.versions}
57
+ />
58
+
59
+ <Pagination
60
+ hasNext={data.hasNextPage}
61
+ onPageChange={(newPage: number) => {
62
+ setValue("page", newPage)
63
+ handleSubmit(submit)()
64
+ }}
65
+ page={watch("page")}
66
+ />
67
+ </div>
68
+ )
69
+ }
70
+
71
+ const useInitialParamsFromURL = () =>
72
+ useMemo(() => {
73
+ const search = new URLSearchParams(window.location.search)
74
+
75
+ return {
76
+ event: search.get("event") || "",
77
+ filter: search.get("filter") || "",
78
+ item_id: search.get("item_id") || "",
79
+ item_type: search.get("item_type") || "",
80
+ page: parseInt("" + search.get("page")) || 1,
81
+ per_page: parseInt("" + search.get("per_page")) || 20,
82
+ }
83
+ }, [])
84
+
85
+ const useConfig = (initialParams: Record<string, unknown>) => {
86
+ const [columns, setColumns] = useState({
87
+ actions: true,
88
+ changes: true,
89
+ event: true,
90
+ item_id: !initialParams.item_id,
91
+ item_type: !initialParams.item_type,
92
+ time: false,
93
+ version_id: true,
94
+ whodunnit: true,
95
+ } as ColumnPicks)
96
+
97
+ const [viewed, setViewed] = useState([])
98
+
99
+ return {columns, setColumns, viewed, setViewed}
100
+ }
@@ -0,0 +1,57 @@
1
+ import React from "react"
2
+ import ReactDiffViewer, {DiffMethod} from "react-diff-viewer"
3
+ import {Version} from "../types"
4
+
5
+ // maybe render this only when in the viewport if performance is bad for
6
+ // a high number of records
7
+ export const ChangeDiff: React.FC<{changeset: Version["changeset"]}> = ({
8
+ changeset,
9
+ }) => (
10
+ <>
11
+ {Object.entries(changeset).map(([k, [before, after]]) => {
12
+ // some data is nested, e.g. description translations
13
+ if (after && typeof after === "object") {
14
+ const beforeObj = new Object(before) as Record<string, unknown>
15
+ const afterObj = new Object(after) as Record<string, unknown>
16
+ const keys = [
17
+ ...new Set(Object.keys(beforeObj).concat(Object.keys(afterObj))),
18
+ ]
19
+ return (
20
+ <React.Fragment key={k}>
21
+ {keys.map((subK) => (
22
+ <Diff
23
+ key={subK}
24
+ name={`${k}_${subK}`}
25
+ before={beforeObj[subK]}
26
+ after={afterObj[subK]}
27
+ />
28
+ ))}
29
+ </React.Fragment>
30
+ )
31
+ }
32
+ // normal case: non-nested data
33
+ return <Diff key={k} name={k} before={before} after={after} />
34
+ })}
35
+ </>
36
+ )
37
+
38
+ const Diff: React.FC<{name: string; before: unknown; after: unknown}> = ({
39
+ name,
40
+ before,
41
+ after,
42
+ }) => {
43
+ return (
44
+ <>
45
+ <strong>{name}</strong>
46
+ <ReactDiffViewer
47
+ compareMethod={DiffMethod.SENTENCES}
48
+ extraLinesSurroundingDiff={2}
49
+ // split HTML into lines for better diff results
50
+ oldValue={String(before || "").replace(/</g, "\n<")}
51
+ newValue={String(after || "").replace(/</g, "\n<")}
52
+ showDiffOnly
53
+ splitView={false}
54
+ />
55
+ </>
56
+ )
57
+ }
@@ -0,0 +1,22 @@
1
+ import React from "react"
2
+ import {Config, availableColumns} from "../types"
3
+ import {Modal} from "./modal"
4
+
5
+ export const ConfigModal: React.FC<{
6
+ config: Config
7
+ }> = ({config: {columns, setColumns}}) => (
8
+ <Modal trigger="⚙️">
9
+ <h4>Settings</h4>
10
+ <h5>Columns</h5>
11
+ <ul>
12
+ {availableColumns.map((col, i) => (
13
+ <li
14
+ key={i}
15
+ onClick={() => setColumns({...columns, [col]: !columns[col]})}
16
+ >
17
+ <input type="checkbox" checked={columns[col]} /> {col}
18
+ </li>
19
+ ))}
20
+ </ul>
21
+ </Modal>
22
+ )
@@ -0,0 +1,62 @@
1
+ import React from "react"
2
+ import {Config, stickyStyle} from "../types"
3
+ import {ConfigModal} from "./config_modal"
4
+
5
+ export const Controls: React.FC<{
6
+ register: (
7
+ x: "page" | "filter" | "event" | "item_id" | "item_type" | "per_page"
8
+ ) => void
9
+ onSubmit: React.FormEventHandler<HTMLFormElement>
10
+ config: Config
11
+ }> = ({register, onSubmit, config}) => (
12
+ <nav
13
+ className="d-flex align-items-md-center py-2 justify-content-center"
14
+ style={{...stickyStyle, top: 0}}
15
+ >
16
+ <form className="m-0" onSubmit={onSubmit}>
17
+ <span className="mx-1">Versions of:</span>
18
+ <input
19
+ {...register("item_type")}
20
+ type="text"
21
+ placeholder="Model"
22
+ style={{width: 150}}
23
+ />
24
+ <span className="mx-1">#</span>
25
+ <input
26
+ {...register("item_id")}
27
+ type="text"
28
+ placeholder="id"
29
+ style={{width: 110}}
30
+ />
31
+ <span className="mx-1">Per page:</span>
32
+ <select {...register("per_page")}>
33
+ <option value={20}>20</option>
34
+ <option value={50}>50</option>
35
+ <option value={100}>100</option>
36
+ <option value={200}>200</option>
37
+ </select>
38
+ <span className="mx-1">Event:</span>
39
+ <select {...register("event")}>
40
+ <option value="">any</option>
41
+ <option value="create">create</option>
42
+ <option value="update">update</option>
43
+ <option value="destroy">destroy</option>
44
+ </select>
45
+ <span className="mx-1">Filter:</span>
46
+ <input
47
+ {...register("filter")}
48
+ type="text"
49
+ placeholder="e.g. field name/value"
50
+ style={{width: 170}}
51
+ />
52
+ <button
53
+ name="button"
54
+ type="submit"
55
+ className="btn btn-sm btn-primary mx-1"
56
+ >
57
+ Search
58
+ </button>
59
+ <ConfigModal config={config} />
60
+ </form>
61
+ </nav>
62
+ )
@@ -0,0 +1,21 @@
1
+ import React from "react"
2
+ import {Version} from "../types"
3
+ import {Modal} from "./modal"
4
+
5
+ export const FullObjectModal: React.FC<{
6
+ object: Version["object"]
7
+ title: string
8
+ }> = ({object, title}) => (
9
+ <Modal trigger={title}>
10
+ <table className="table">
11
+ <tbody>
12
+ {Object.entries(object).map(([k, v]) => (
13
+ <tr key={k}>
14
+ <th>{k}</th>
15
+ <td>{String(v || "")}</td>
16
+ </tr>
17
+ ))}
18
+ </tbody>
19
+ </table>
20
+ </Modal>
21
+ )
@@ -0,0 +1,3 @@
1
+ export * from "./controls"
2
+ export * from "./pagination"
3
+ export * from "./versions_list"
@@ -0,0 +1,34 @@
1
+ import React, {useState} from "react"
2
+ import ReactModal from "react-modal"
3
+
4
+ export const Modal = ({
5
+ children,
6
+ trigger,
7
+ }: {
8
+ children: React.ReactNode
9
+ trigger: string
10
+ }) => {
11
+ const [open, setOpen] = useState(false)
12
+
13
+ return (
14
+ <>
15
+ <span style={{cursor: "pointer"}} onClick={() => setOpen(!open)}>
16
+ {trigger}
17
+ </span>
18
+ <ReactModal
19
+ ariaHideApp={false}
20
+ isOpen={open}
21
+ onRequestClose={() => setOpen(false)}
22
+ style={{overlay: {zIndex: 20}}}
23
+ >
24
+ <span
25
+ onClick={() => setOpen(false)}
26
+ style={{cursor: "pointer", float: "right"}}
27
+ >
28
+
29
+ </span>
30
+ {children}
31
+ </ReactModal>
32
+ </>
33
+ )
34
+ }
@@ -0,0 +1,53 @@
1
+ import React from "react"
2
+
3
+ export const Pagination: React.FC<{
4
+ page: number
5
+ hasNext: boolean
6
+ onPageChange: (n: number) => void
7
+ }> = ({page, hasNext, onPageChange}) => {
8
+ const goTo = (newPage: number) => {
9
+ if (newPage > 0 && (newPage < page || hasNext)) onPageChange(newPage)
10
+ }
11
+
12
+ return (
13
+ <nav className="d-flex justify-content-center my-2">
14
+ <button
15
+ className="btn btn-sm btn-outline-dark"
16
+ disabled={page <= 1}
17
+ onClick={() => goTo(page - 1)}
18
+ >
19
+ « Back
20
+ </button>
21
+ {page > 1 && (
22
+ <button className="btn btn-sm btn-outline-dark" onClick={() => goTo(1)}>
23
+ 1
24
+ </button>
25
+ )}
26
+ {page > 2 && <span> … </span>}
27
+ {page > 3 && (
28
+ <button
29
+ className="btn btn-sm btn-outline-dark"
30
+ onClick={() => goTo(page - 1)}
31
+ >
32
+ {page - 1}
33
+ </button>
34
+ )}
35
+ <em className="mx-1">{page}</em>
36
+ {hasNext && (
37
+ <button
38
+ className="btn btn-sm btn-outline-dark"
39
+ onClick={() => goTo(page + 1)}
40
+ >
41
+ {page + 1}
42
+ </button>
43
+ )}
44
+ <button
45
+ className="btn btn-sm btn-outline-dark"
46
+ disabled={!hasNext}
47
+ onClick={() => goTo(page + 1)}
48
+ >
49
+ Next »
50
+ </button>
51
+ </nav>
52
+ )
53
+ }
@@ -0,0 +1,142 @@
1
+ import React, {useCallback} from "react"
2
+ import {Version, stickyStyle, Config, ViewedList} from "../types"
3
+ import {ChangeDiff} from "./change_diff"
4
+ import {FullObjectModal} from "./full_object_modal"
5
+
6
+ export const VersionsList: React.FC<{
7
+ config: Config
8
+ loading: boolean
9
+ versions: Array<Version>
10
+ }> = ({config, loading, versions}) => {
11
+ if (loading) return <Info text="⌛" />
12
+
13
+ if (versions.length === 0) return <Info text="No versions found." />
14
+
15
+ const {columns, viewed, setViewed} = config
16
+
17
+ return (
18
+ <table className="table">
19
+ <thead style={{...stickyStyle, top: 47}}>
20
+ <tr>
21
+ {columns.version_id && <th>Version ID</th>}
22
+ {columns.item_type && <th>Item Type</th>}
23
+ {columns.item_id && <th>Item ID</th>}
24
+ {columns.event && <th>Event</th>}
25
+ {columns.whodunnit && <th>Whodunnit</th>}
26
+ {columns.time && <th>Time</th>}
27
+ {columns.changes && <th>Changes</th>}
28
+ {columns.actions && (
29
+ <th>
30
+ Actions&nbsp;
31
+ <Undo config={config} />
32
+ </th>
33
+ )}
34
+ </tr>
35
+ </thead>
36
+
37
+ <tbody>
38
+ {versions.map((v, i) => {
39
+ if (viewed.includes(v.id)) return null
40
+
41
+ return (
42
+ <tr key={i} data-ci-type="version-row">
43
+ {columns.version_id && <td>{v.id}</td>}
44
+ {columns.item_type && <td>{v.item_type}</td>}
45
+ {columns.item_id && <td>{v.item_id}</td>}
46
+ {columns.event && <td>{v.event}</td>}
47
+ {columns.whodunnit && <TdWhodunnit v={v} />}
48
+ {columns.time && <TdTime v={v} />}
49
+ {columns.changes && <TdChanges v={v} />}
50
+ {columns.actions && (
51
+ <TdActions viewed={viewed} setViewed={setViewed} v={v} />
52
+ )}
53
+ </tr>
54
+ )
55
+ })}
56
+ </tbody>
57
+ </table>
58
+ )
59
+ }
60
+
61
+ const Info = ({text}: {text: string}) => (
62
+ <p className="d-flex justify-content-center">{text}</p>
63
+ )
64
+
65
+ const Undo = ({config: {viewed, setViewed}}: {config: Config}) => {
66
+ const undo = useCallback(() => {
67
+ setViewed(viewed.slice(0, -1))
68
+ }, [viewed, setViewed])
69
+
70
+ return (
71
+ <span
72
+ style={viewed.length === 0 ? {visibility: "hidden"} : {}}
73
+ onClick={() => undo()}
74
+ >
75
+
76
+ </span>
77
+ )
78
+ }
79
+
80
+ const TdWhodunnit = ({v}: {v: Version}) => (
81
+ <td>
82
+ {(v.whodunnit && (
83
+ <a href={v.user_url || "#"} title={v.whodunnit}>
84
+ {truncate(v.whodunnit, 8)}
85
+ </a>
86
+ )) ||
87
+ "–"}
88
+ </td>
89
+ )
90
+
91
+ const TdTime = ({v}: {v: Version}) => (
92
+ <td>{v.created_at.replace("T", " ").replace(/\+.*/, "")}</td>
93
+ )
94
+
95
+ const TdChanges = ({v}: {v: Version}) => (
96
+ <td>{v.changeset && <ChangeDiff changeset={v.changeset} />}</td>
97
+ )
98
+
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
+ )
130
+
131
+ const truncate = (str: string, len: number) => {
132
+ if (!str || str.length <= len) return str
133
+ return str.substring(0, len) + "…"
134
+ }
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
+ }
@@ -0,0 +1 @@
1
+ export * from './app'
@@ -0,0 +1,42 @@
1
+ import {CSSProperties} from "react"
2
+
3
+ export const availableColumns = [
4
+ "version_id",
5
+ "item_type",
6
+ "item_id",
7
+ "event",
8
+ "time",
9
+ "whodunnit",
10
+ "changes",
11
+ "actions",
12
+ ] as const
13
+
14
+ export type Column = typeof availableColumns[number]
15
+ export type ColumnPicks = Record<Column, boolean>
16
+ export type ViewedList = Version["id"][]
17
+
18
+ export type Config = {
19
+ columns: ColumnPicks
20
+ setColumns: React.Dispatch<React.SetStateAction<ColumnPicks>>
21
+ viewed: ViewedList
22
+ setViewed: React.Dispatch<React.SetStateAction<ViewedList>>
23
+ }
24
+
25
+ export interface Version {
26
+ id: number
27
+ item_type: string
28
+ item_id: string
29
+ whodunnit?: string
30
+ event: string
31
+ created_at: string
32
+ changeset: Record<string, [unknown, unknown]>
33
+ object: Record<string, unknown>
34
+ item_url?: string
35
+ user_url?: string
36
+ }
37
+
38
+ export const stickyStyle = {
39
+ position: "sticky",
40
+ background: "#ffffffdd",
41
+ zIndex: 10,
42
+ } as CSSProperties
@@ -0,0 +1,30 @@
1
+ require 'kaminari'
2
+
3
+ module PaperTrailViewer::DataSource
4
+ # The default data source. Queries version records via ActiveRecord.
5
+ # See DataSource::Bigquery::Adapter for how to implement another source.
6
+ class ActiveRecord
7
+ def initialize(model: 'PaperTrail::Version')
8
+ @model = model
9
+ end
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')
14
+
15
+ if 'object_changes'.in?(versions.column_names)
16
+ # Ignore blank versions that only touch updated_at or untracked fields.
17
+ versions = versions.where.not(object_changes: '')
18
+ versions = versions.where('object_changes LIKE ?', "%#{filter}%") if filter.present?
19
+ elsif 'object'.in?(versions.column_names)
20
+ versions = versions.where('object LIKE ?', "%#{filter}%") if filter.present?
21
+ end
22
+
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?
26
+
27
+ versions.page(page).per(per_page)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,45 @@
1
+ module PaperTrailViewer::DataSource
2
+ class Bigquery
3
+ def initialize(project_id:, credentials:, table:)
4
+ require 'google/cloud/bigquery'
5
+
6
+ @bigquery = Google::Cloud::Bigquery.new(
7
+ project_id: project_id,
8
+ credentials: credentials,
9
+ )
10
+ @table = table
11
+ end
12
+
13
+ def call(item_type: nil, item_id: nil, event: nil, filter: nil, page: 1, per_page: 50)
14
+ # https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax
15
+ bigquery_result = @bigquery.query(<<~SQL, max: per_page)
16
+ SELECT *
17
+ FROM `#{@table}`
18
+ # Ignore blank versions that only touch updated_at or untracked fields.
19
+ 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?}
24
+ ORDER BY created_at DESC, id DESC
25
+ # Paginate via OFFSET.
26
+ # LIMIT must be greater than `max:` or result#next? is always false.
27
+ LIMIT #{per_page + 1} OFFSET #{(page - 1) * per_page}
28
+ SQL
29
+
30
+ Adapter.new(bigquery_result)
31
+ end
32
+
33
+ Adapter = Struct.new(:bigquery_result) do
34
+ # PaperTrail::Version::ActiveRecord_Relation compatibility method
35
+ def map
36
+ bigquery_result.map { |row| yield PaperTrail::Version.new(row) }
37
+ end
38
+
39
+ # Kaminari compatibility method
40
+ def last_page?
41
+ !bigquery_result.next?
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,5 @@
1
+ require 'rails'
2
+
3
+ class PaperTrailViewer::Engine < Rails::Engine
4
+ isolate_namespace PaperTrailViewer
5
+ end
@@ -0,0 +1,3 @@
1
+ module PaperTrailViewer
2
+ VERSION = '1.0.0'
3
+ end
@@ -0,0 +1,11 @@
1
+ require 'paper_trail'
2
+
3
+ require_relative 'paper_trail_viewer/data_source/active_record'
4
+ require_relative 'paper_trail_viewer/data_source/bigquery'
5
+ require_relative 'paper_trail_viewer/engine'
6
+ require_relative 'paper_trail_viewer/version'
7
+
8
+ module PaperTrailViewer
9
+ mattr_accessor(:data_source) { DataSource::ActiveRecord.new }
10
+ mattr_accessor(:user_path_method) { :user_path }
11
+ end