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.
- checksums.yaml +4 -4
- data/.github/workflows/{test.yml → tests.yml} +0 -0
- data/CHANGELOG.md +13 -0
- data/README.md +28 -10
- data/app/controllers/paper_trail_viewer/versions_controller.rb +5 -111
- data/app/views/paper_trail_viewer/viewer/index.html.erb +1 -1
- data/javascript/compiled.js +1 -1
- data/javascript/src/app.tsx +24 -13
- data/javascript/src/components/context_menu.tsx +61 -0
- data/javascript/src/components/index.ts +1 -0
- data/javascript/src/components/version_context_menu.tsx +110 -0
- data/javascript/src/components/versions_list.tsx +37 -47
- data/javascript/src/types.ts +9 -1
- data/lib/paper_trail_viewer/data_source/active_record.rb +13 -12
- data/lib/paper_trail_viewer/data_source/base.rb +87 -0
- data/lib/paper_trail_viewer/data_source/bigquery.rb +9 -8
- data/lib/paper_trail_viewer/query.rb +23 -0
- data/lib/paper_trail_viewer/rollback.rb +20 -0
- data/lib/paper_trail_viewer/version.rb +1 -1
- data/lib/paper_trail_viewer.rb +4 -0
- data/paper_trail_viewer.gemspec +0 -1
- data/spec/system/paper_trail_viewer_spec.rb +79 -64
- metadata +9 -5
data/javascript/src/app.tsx
CHANGED
@@ -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
|
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
|
-
|
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
|
-
|
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 = (
|
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"))
|
@@ -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
|
+
|
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
|
1
|
+
import React, {useCallback, useState} from "react"
|
2
|
+
import {Version, stickyStyle, Config} from "../types"
|
3
3
|
import {ChangeDiff} from "./change_diff"
|
4
|
-
import {
|
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
|
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
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
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
|
-
}
|
data/javascript/src/types.ts
CHANGED
@@ -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:
|
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(
|
8
|
-
@
|
6
|
+
class ActiveRecord < Base
|
7
|
+
def initialize(version_class: 'PaperTrail::Version')
|
8
|
+
@version_class = version_class
|
9
9
|
end
|
10
10
|
|
11
|
-
|
12
|
-
|
13
|
-
|
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
|
-
|
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
|
data/lib/paper_trail_viewer.rb
CHANGED
@@ -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
|
data/paper_trail_viewer.gemspec
CHANGED
@@ -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
|
|