paper_trail_viewer 1.0.0

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