paper_trail_viewer 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/test.yml +43 -0
- data/.gitignore +41 -0
- data/Appraisals +13 -0
- data/CHANGELOG.md +9 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +23 -0
- data/README.md +53 -0
- data/Rakefile +45 -0
- data/app/controllers/paper_trail_viewer/js_controller.rb +14 -0
- data/app/controllers/paper_trail_viewer/versions_controller.rb +119 -0
- data/app/controllers/paper_trail_viewer/viewer_controller.rb +4 -0
- data/app/views/paper_trail_viewer/viewer/index.html.erb +5 -0
- data/bin/setup +8 -0
- data/config/routes.rb +5 -0
- data/gemfiles/rails_6.0_paper_trail_11.1.gemfile +10 -0
- data/gemfiles/rails_6.0_paper_trail_12.2.gemfile +10 -0
- data/gemfiles/rails_7.0_paper_trail_12.2.gemfile +10 -0
- data/javascript/compiled.js +2 -0
- data/javascript/src/app.tsx +100 -0
- data/javascript/src/components/change_diff.tsx +57 -0
- data/javascript/src/components/config_modal.tsx +22 -0
- data/javascript/src/components/controls.tsx +62 -0
- data/javascript/src/components/full_object_modal.tsx +21 -0
- data/javascript/src/components/index.ts +3 -0
- data/javascript/src/components/modal.tsx +34 -0
- data/javascript/src/components/pagination.tsx +53 -0
- data/javascript/src/components/versions_list.tsx +142 -0
- data/javascript/src/index.ts +1 -0
- data/javascript/src/types.ts +42 -0
- data/lib/paper_trail_viewer/data_source/active_record.rb +30 -0
- data/lib/paper_trail_viewer/data_source/bigquery.rb +45 -0
- data/lib/paper_trail_viewer/engine.rb +5 -0
- data/lib/paper_trail_viewer/version.rb +3 -0
- data/lib/paper_trail_viewer.rb +11 -0
- data/package.json +43 -0
- data/paper_trail_viewer.gemspec +34 -0
- data/spec/app_template.rb +19 -0
- data/spec/rails_helper.rb +18 -0
- data/spec/support/factories.rb +13 -0
- data/spec/system/paper_trail_viewer_spec.rb +114 -0
- data/tsconfig.json +13 -0
- data/webpack.config.js +26 -0
- 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,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
|
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
|
+
|
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,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
|