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.
- 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
|
|