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