mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-12 17:28:31 +03:00
feat(seeds): diffing, reading remotes, everything else
This commit is contained in:
@@ -2,6 +2,7 @@ BROWSER="none"
|
||||
REACT_APP_TCHIC_MODE="omni"
|
||||
REACT_APP_SERVER_URL="https://127.0.0.1:8080"
|
||||
REACT_APP_CDN_URL="https://127.0.0.1:8080/cdn"
|
||||
REACT_APP_GIT_REPO="GitHub:TNG-dev/Tachi"
|
||||
REACT_APP_IMGSCORE_URL=""
|
||||
REACT_APP_EAG_CLIENT_ID=""
|
||||
REACT_APP_MIN_CLIENT_ID=""
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"deepmerge": "4.2.2",
|
||||
"dotenv": "6.2.0",
|
||||
"dotenv-expand": "5.1.0",
|
||||
"fast-json-stable-hash": "^1.0.3",
|
||||
"file-loader": "3.0.1",
|
||||
"formik": "2.2.9",
|
||||
"fs-extra": "7.0.1",
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import { FormatTime } from "util/time";
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { LoadSeeds } from "util/seeds";
|
||||
import { StrSOV } from "util/sorts";
|
||||
import useSetSubheader from "components/layout/header/useSetSubheader";
|
||||
import SeedsPicker from "components/seeds/SeedsPicker";
|
||||
import SeedsStateViewer from "components/seeds/SeedsStateViewer";
|
||||
import Divider from "components/util/Divider";
|
||||
import ExternalLink from "components/util/ExternalLink";
|
||||
import Loading from "components/util/Loading";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import Select from "components/util/Select";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Badge, Button, Col, Modal, Row } from "react-bootstrap";
|
||||
import Divider from "components/util/Divider";
|
||||
import Icon from "components/util/Icon";
|
||||
import { SetState } from "types/react";
|
||||
import { TachiConfig } from "lib/config";
|
||||
import ExternalLink from "components/util/ExternalLink";
|
||||
import Card from "components/layout/page/Card";
|
||||
import { GitCommit, Revision } from "types/git";
|
||||
import { AllDatabaseSeeds, DatabaseSeedNames } from "tachi-common";
|
||||
import SelectButton from "components/util/SelectButton";
|
||||
import FormInput from "components/util/FormInput";
|
||||
import SeedsTable from "components/tables/seeds/SeedsTable";
|
||||
import { TachiConfig } from "lib/config";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Col, Row } from "react-bootstrap";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { GitCommit, Revision } from "types/git";
|
||||
|
||||
export default function SeedsViewer() {
|
||||
useSetSubheader(["Developer Utils", "Database Seeds Management"]);
|
||||
@@ -70,6 +63,93 @@ function InnerSeedsViewer({ hasLocalAPI }: { hasLocalAPI: boolean }) {
|
||||
// the base, but no order is enforced.
|
||||
const [baseRev, setBaseRev] = useState<Revision | null>(null);
|
||||
const [headRev, setHeadRev] = useState<Revision | null>(null);
|
||||
const [urlLoading, setURLLoading] = useState(false);
|
||||
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||
|
||||
const params = useParams<{ repo: string | undefined; sha: string | undefined }>();
|
||||
|
||||
console.log(params);
|
||||
|
||||
const { repo, sha } = params;
|
||||
|
||||
const [view, setView] = useState<"DIFF" | "FULL">("DIFF");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFirstLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if we've been given stuff in the URL and don't already have a baseRev
|
||||
if (sha && repo && !baseRev) {
|
||||
setURLLoading(true);
|
||||
|
||||
(async () => {
|
||||
if (repo === "local") {
|
||||
const params = new URLSearchParams({ sha });
|
||||
|
||||
const res = await APIFetchV1<GitCommit>(`/seeds/commit?${params.toString()}`);
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(
|
||||
`Failed to fetch info about commit ${sha}: ${res.description}.`
|
||||
);
|
||||
}
|
||||
|
||||
setBaseRev({
|
||||
c: res.body,
|
||||
repo,
|
||||
});
|
||||
|
||||
setURLLoading(false);
|
||||
|
||||
setIsFirstLoad(false);
|
||||
} else if (repo.startsWith("GitHub:")) {
|
||||
const repoName = repo.slice("GitHub:".length);
|
||||
|
||||
const res: GitCommit = await fetch(
|
||||
`https://github.com/${repoName}/seeds/commit/${sha}}`
|
||||
).then((r) => r.json());
|
||||
|
||||
setBaseRev({
|
||||
c: res,
|
||||
repo,
|
||||
});
|
||||
|
||||
setURLLoading(false);
|
||||
|
||||
setIsFirstLoad(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [isFirstLoad]);
|
||||
|
||||
useEffect(() => {
|
||||
if (baseRev) {
|
||||
const params = new URLSearchParams({
|
||||
repo: baseRev.repo,
|
||||
sha: baseRev.c.sha,
|
||||
});
|
||||
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
} else {
|
||||
window.history.replaceState(null, "", "");
|
||||
}
|
||||
}, [baseRev]);
|
||||
|
||||
// if sha XOR repo, this should not be allowed.
|
||||
if (!!sha !== !!repo) {
|
||||
return <div>Invalid reference, both a repo and a sha must be provided in the URL.</div>;
|
||||
}
|
||||
|
||||
if (urlLoading) {
|
||||
return (
|
||||
<div className="w-100 d-flex justify-content-center">
|
||||
<Loading />
|
||||
<br />
|
||||
<span>Fetching information from the provided URL...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -87,7 +167,8 @@ function InnerSeedsViewer({ hasLocalAPI }: { hasLocalAPI: boolean }) {
|
||||
<SeedsPicker
|
||||
hasLocalAPI={hasLocalAPI}
|
||||
header="Compare Commit"
|
||||
message="Optionally, pick another commit to compare against. This commit should be newer than the base commit."
|
||||
message="Optionally, pick another arbitary commit to show all the changes between.
|
||||
This commit should be newer than the base commit!"
|
||||
setRev={setHeadRev}
|
||||
rev={headRev}
|
||||
/>
|
||||
@@ -98,522 +179,27 @@ function InnerSeedsViewer({ hasLocalAPI }: { hasLocalAPI: boolean }) {
|
||||
<Row>
|
||||
<Col xs={12}>
|
||||
<Divider />
|
||||
{!headRev && (
|
||||
<div className="w-100 d-flex justify-content-center">
|
||||
<SelectButton
|
||||
className="mx-2"
|
||||
value={view}
|
||||
setValue={setView}
|
||||
id="DIFF"
|
||||
>
|
||||
View Changes
|
||||
</SelectButton>
|
||||
<SelectButton value={view} setValue={setView} id="FULL">
|
||||
View Full State
|
||||
</SelectButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
</Col>
|
||||
<SeedsLoaderViewer baseRev={baseRev} headRev={headRev} />
|
||||
<SeedsStateViewer baseRev={baseRev} headRev={headRev} view={view} />
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SeedsLoaderViewer({ baseRev, headRev }: { baseRev: Revision; headRev: Revision | null }) {
|
||||
const [baseData, setBaseData] = useState<Partial<AllDatabaseSeeds> | null>(null);
|
||||
const [headData, setHeadData] = useState<Partial<AllDatabaseSeeds> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
// enter loading...
|
||||
setBaseData(null);
|
||||
const data = await LoadSeeds(baseRev.repo, baseRev.c.sha);
|
||||
|
||||
setBaseData(data);
|
||||
})();
|
||||
}, [baseRev]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
// enter loading...
|
||||
setHeadData(null);
|
||||
|
||||
if (headRev === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await LoadSeeds(headRev.repo, headRev.c.sha);
|
||||
|
||||
setHeadData(data);
|
||||
})();
|
||||
}, [headRev]);
|
||||
|
||||
if (baseData === null) {
|
||||
return (
|
||||
<Col xs={12}>
|
||||
<div className="d-flex justify-content-center">
|
||||
<div>
|
||||
<Loading />
|
||||
<br />
|
||||
Reading data...
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
if (headData !== null) {
|
||||
return <>{Object.keys(headData)}</>;
|
||||
}
|
||||
|
||||
return <SeedsAbsoluteState seedsData={baseData} />;
|
||||
}
|
||||
|
||||
function SeedsAbsoluteState({ seedsData }: { seedsData: Partial<AllDatabaseSeeds> }) {
|
||||
const files = Object.keys(seedsData).sort(StrSOV((k) => k)) as Array<keyof AllDatabaseSeeds>;
|
||||
|
||||
const [file, setFile] = useState(files[0]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Col xs={12}>
|
||||
<Card header="Collections">
|
||||
<div className="d-flex flex-wrap" style={{ justifyContent: "space-around" }}>
|
||||
{files.map((e) => (
|
||||
<div key={e} className="my-2">
|
||||
<SelectButton id={e} setValue={setFile} value={file}>
|
||||
{e} ({seedsData[e]?.length ?? 0})
|
||||
</SelectButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<Divider />
|
||||
<h1 className="text-center">{file}</h1>
|
||||
<Divider />
|
||||
<SeedsTable data={seedsData} file={file} />
|
||||
</Col>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Branch = { name: string; sha: string };
|
||||
|
||||
function SeedsPicker({
|
||||
hasLocalAPI,
|
||||
header,
|
||||
rev,
|
||||
setRev,
|
||||
message,
|
||||
}: {
|
||||
hasLocalAPI: boolean;
|
||||
header: string;
|
||||
rev: Revision | null;
|
||||
setRev: SetState<Revision | null>;
|
||||
message?: string;
|
||||
}) {
|
||||
// a repo is one of the following:
|
||||
// null - nothing has been selected yet
|
||||
// "local" - we're referring to the files on the local development disk
|
||||
// "GitHub:NAME/REPO" - we're referring to a repository on github, like GitHub:TNG-Dev/Tachi
|
||||
const [repo, setRepo] = useState<string | null>(null);
|
||||
|
||||
// to list commits, we need to know what branch we're looking at.
|
||||
// we also need to know the set of all available branches
|
||||
const [branch, setBranch] = useState<string | null>(null);
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
|
||||
const [shaBranchLookup, setSHABranchLookup] = useState<Record<string, string[]>>({});
|
||||
|
||||
// When repo changes, refetch the available branches.
|
||||
useEffect(() => {
|
||||
if (repo === null) {
|
||||
return setBranches(null);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (repo === "local") {
|
||||
const res = await APIFetchV1<{ branches: Branch[]; current: Branch | null }>(
|
||||
`/seeds/branches`
|
||||
);
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(
|
||||
`Failed to fetch branches for your local repo. ${res.description}.`
|
||||
);
|
||||
}
|
||||
|
||||
setBranches(res.body.branches.map((e) => e.name));
|
||||
|
||||
// can't be bothered getting this to work
|
||||
// if (res.body.current) {
|
||||
// setBranch(res.body.current.name);
|
||||
// }
|
||||
|
||||
// to get pretty UI stuff, we keep track of the HEADs of all branches
|
||||
// so we can render what branch a commit is the HEAD of in the UI.
|
||||
const lookup: Record<string, string[]> = {};
|
||||
|
||||
for (const branch of res.body.branches) {
|
||||
if (branch.sha in lookup) {
|
||||
lookup[branch.sha].push(branch.name);
|
||||
} else {
|
||||
lookup[branch.sha] = [branch.name];
|
||||
}
|
||||
}
|
||||
|
||||
setSHABranchLookup(lookup);
|
||||
} else if (repo.startsWith("GitHub:")) {
|
||||
throw new Error("TODO");
|
||||
}
|
||||
})();
|
||||
}, [repo]);
|
||||
|
||||
// if branches change, re-prompt for users to select a branch
|
||||
useEffect(() => {
|
||||
setBranch(null);
|
||||
}, [branches]);
|
||||
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const [subject, body] = useMemo(() => {
|
||||
if (rev === null) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const [subject, _gap, ...maybeBodies] = rev.c.commit.message.split("\n");
|
||||
|
||||
return [subject, maybeBodies?.join("\n") ?? null];
|
||||
}, [rev]);
|
||||
|
||||
const [showBody, setShowBody] = useState(false);
|
||||
|
||||
const authorNotCommitter = rev?.c.commit.author.email !== rev?.c.commit.committer.email;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card header={header}>
|
||||
{rev ? (
|
||||
<div className="mr-3" style={{ textAlign: "left" }}>
|
||||
<span style={{ fontSize: "1.15rem" }}>
|
||||
<code>
|
||||
{rev.repo}/{rev.c.sha}
|
||||
</code>
|
||||
: {subject}
|
||||
</span>
|
||||
{body && (
|
||||
<span
|
||||
className="ml-4 badge badge-secondary"
|
||||
onClick={() => setShowBody(!showBody)}
|
||||
>
|
||||
{showBody ? (
|
||||
<Icon style={{ fontSize: "0.7rem" }} type="chevron-down" />
|
||||
) : (
|
||||
<Icon style={{ fontSize: "0.7rem" }} type="chevron-left" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{showBody && <div className="ml-6 mt-1">{body}</div>}
|
||||
<Divider />
|
||||
<div style={{ width: "100%", display: "flex" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{authorNotCommitter ? (
|
||||
<>
|
||||
Authored by <b>{rev.c.commit.author.name}</b>, Committed by{" "}
|
||||
<b>{rev.c.commit.committer.name}</b>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Authored by <b>{rev.c.commit.author.name}</b>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right" style={{ flex: 1 }}>
|
||||
{FormatTime(Date.parse(rev.c.commit.author.date))}
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="text-center">
|
||||
<span
|
||||
className="text-muted underline-on-hover"
|
||||
onClick={() => setShow(true)}
|
||||
>
|
||||
Change Commit...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{message && (
|
||||
<>
|
||||
<span>{message}</span>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="secondary" onClick={() => setShow(true)}>
|
||||
Pick Commit...
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<Modal show={show} size="xl" onHide={() => setShow(false)}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Pick {header}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Row>
|
||||
<Col xs={12} className="text-center">
|
||||
<div>
|
||||
<span style={{ fontSize: "large" }}>Repository:</span>
|
||||
<Select
|
||||
style={{ display: "inline", width: "unset" }}
|
||||
className="mx-2"
|
||||
value={repo}
|
||||
setValue={setRepo}
|
||||
allowNull
|
||||
>
|
||||
{hasLocalAPI && <option value="local">Your Local Repo</option>}
|
||||
</Select>
|
||||
</div>
|
||||
{branches && (
|
||||
<div className="mt-2">
|
||||
<span style={{ fontSize: "large" }}>Branch:</span>
|
||||
<Select
|
||||
style={{ display: "inline", width: "unset" }}
|
||||
className="mx-2"
|
||||
value={branch}
|
||||
setValue={setBranch}
|
||||
allowNull
|
||||
>
|
||||
{branches.map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{e}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
<Col xs={12} lg={10} className="offset-lg-1 text-center">
|
||||
{repo && branch && (
|
||||
<>
|
||||
<Divider />
|
||||
<RevSelector
|
||||
shaBranchLookup={shaBranchLookup}
|
||||
repo={repo}
|
||||
branch={branch}
|
||||
onSelect={(commit) => {
|
||||
setRev({ c: commit, repo });
|
||||
setShow(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RevSelector({
|
||||
repo,
|
||||
onSelect,
|
||||
branch,
|
||||
shaBranchLookup,
|
||||
}: {
|
||||
repo: string;
|
||||
onSelect: (g: GitCommit) => void;
|
||||
branch: string;
|
||||
shaBranchLookup: Record<string, string[]>;
|
||||
}) {
|
||||
const [revs, setRevs] = useState<Array<GitCommit>>([]);
|
||||
const [filteredRevs, setFilteredRevs] = useState<Array<GitCommit>>([]);
|
||||
|
||||
// We have the option of filtering returned revisions according to only those that
|
||||
// affect a specific file. Sounds useful.
|
||||
const [collection, setCollection] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (repo.startsWith("GitHub:")) {
|
||||
throw new Error("Unsupported...");
|
||||
} else {
|
||||
// startLoading
|
||||
setRevs([]);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("branch", branch);
|
||||
|
||||
if (collection) {
|
||||
params.set("file", collection);
|
||||
}
|
||||
|
||||
// local
|
||||
const res = await APIFetchV1<Array<GitCommit>>(
|
||||
`/seeds/commits?${params.toString()}`
|
||||
);
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Failed to fetch commits? ${res.description}`);
|
||||
}
|
||||
|
||||
const hasUncommittedRes = await APIFetchV1<boolean>(
|
||||
"/seeds/has-uncommitted-changes"
|
||||
);
|
||||
|
||||
if (hasUncommittedRes.success && hasUncommittedRes.body) {
|
||||
const LOCAL_COMMIT: GitCommit = {
|
||||
sha: "WORKING_DIRECTORY",
|
||||
commit: {
|
||||
author: {
|
||||
name: "Not Committed Yet",
|
||||
date: "1970-01-01",
|
||||
email: "null@example.com",
|
||||
},
|
||||
committer: {
|
||||
name: "Not Committed Yet",
|
||||
date: "1970-01-01",
|
||||
email: "null@example.com",
|
||||
},
|
||||
message: "Uncommitted changes on your local disk.",
|
||||
},
|
||||
};
|
||||
|
||||
setRevs([LOCAL_COMMIT, ...res.body]);
|
||||
} else {
|
||||
setRevs(res.body);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [repo, branch, collection]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredRevs(revs);
|
||||
setSearch("");
|
||||
}, [revs]);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (search === "") {
|
||||
return setFilteredRevs(revs);
|
||||
}
|
||||
|
||||
// remarkably lazy search implementation, but convenient.
|
||||
return setFilteredRevs(
|
||||
revs.filter(
|
||||
(r) =>
|
||||
r.sha.includes(search) ||
|
||||
r.commit.message.includes(search) ||
|
||||
r.commit.author.name.includes(search) ||
|
||||
r.commit.committer.name.includes(search)
|
||||
)
|
||||
);
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<span>Show changes that affect: </span>
|
||||
<Select
|
||||
value={collection}
|
||||
setValue={setCollection}
|
||||
unselectedName="Any Collection"
|
||||
allowNull
|
||||
className="mx-2"
|
||||
style={{ width: "unset", display: "inline" }}
|
||||
>
|
||||
{DatabaseSeedNames.map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{e}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<Divider />
|
||||
<FormInput
|
||||
fieldName="Search"
|
||||
placeholder="Search commits..."
|
||||
value={search}
|
||||
setValue={setSearch}
|
||||
/>
|
||||
<Divider />
|
||||
<div className="timeline timeline-2">
|
||||
<div className="timeline-bar"></div>
|
||||
{filteredRevs.map((r) => (
|
||||
<Revision
|
||||
tags={shaBranchLookup[r.sha] ?? []}
|
||||
key={r.sha}
|
||||
rev={r}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Revision({
|
||||
rev,
|
||||
tags = [],
|
||||
onSelect: onSelect,
|
||||
}: {
|
||||
rev: GitCommit;
|
||||
tags?: string[];
|
||||
onSelect: (g: GitCommit) => void;
|
||||
}) {
|
||||
const authorNotCommitter = rev.commit.author.email !== rev.commit.committer.email;
|
||||
|
||||
// if there's a body then there's two newlines.
|
||||
const [subject, _gap, ...maybeBodies] = rev.commit.message.split("\n");
|
||||
|
||||
const [showBody, setShowBody] = useState(false);
|
||||
|
||||
const body: string | undefined = maybeBodies?.join("\n");
|
||||
|
||||
return (
|
||||
<div className="timeline-item">
|
||||
<span className="timeline-badge bg-primary"></span>
|
||||
<div className="timeline-content d-flex align-items-center justify-content-between">
|
||||
<div className="mr-3" style={{ width: "70%", textAlign: "left" }}>
|
||||
<span style={{ fontSize: "1.15rem" }}>
|
||||
<code>{rev.sha}</code>:{" "}
|
||||
<a className="gentle-link" onClick={() => onSelect(rev)}>
|
||||
{subject}
|
||||
</a>
|
||||
</span>
|
||||
{body && (
|
||||
<span
|
||||
className="ml-4 badge badge-secondary"
|
||||
onClick={() => setShowBody(!showBody)}
|
||||
>
|
||||
{showBody ? (
|
||||
<Icon style={{ fontSize: "0.7rem" }} type="chevron-down" />
|
||||
) : (
|
||||
<Icon style={{ fontSize: "0.7rem" }} type="chevron-left" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{tags.length !== 0 && (
|
||||
<span className="ml-2">
|
||||
{tags.map((e) => (
|
||||
<Badge className="ml-2" variant="primary" key={e}>
|
||||
{e}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{showBody && <div className="ml-6 mt-1">{body}</div>}
|
||||
<div>
|
||||
{authorNotCommitter ? (
|
||||
<>
|
||||
Authored by <b>{rev.commit.author.name}</b>, Committed by{" "}
|
||||
<b>{rev.commit.committer.name}</b>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Authored by <b>{rev.commit.author.name}</b>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-muted font-italic text-right">
|
||||
{FormatTime(Date.parse(rev.commit.author.date))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { FormatTime } from "util/time";
|
||||
import { EscapeStringRegexp, TruncateString } from "util/misc";
|
||||
import Divider from "components/util/Divider";
|
||||
import FormInput from "components/util/FormInput";
|
||||
import Icon from "components/util/Icon";
|
||||
import Select from "components/util/Select";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Button, Modal, Row, Col, Badge } from "react-bootstrap";
|
||||
import { DatabaseSeedNames } from "tachi-common";
|
||||
import { Branch, GitCommit, Revision } from "types/git";
|
||||
import { SetState } from "types/react";
|
||||
import Card from "components/layout/page/Card";
|
||||
|
||||
export default function SeedsPicker({
|
||||
hasLocalAPI,
|
||||
header,
|
||||
rev,
|
||||
setRev,
|
||||
message,
|
||||
}: {
|
||||
hasLocalAPI: boolean;
|
||||
header: string;
|
||||
rev: Revision | null;
|
||||
setRev: SetState<Revision | null>;
|
||||
message?: string;
|
||||
}) {
|
||||
// a repo is one of the following:
|
||||
// null - nothing has been selected yet
|
||||
// "local" - we're referring to the files on the local development disk
|
||||
// "GitHub:NAME/REPO" - we're referring to a repository on github, like GitHub:TNG-Dev/Tachi
|
||||
const [repo, setRepo] = useState<string | null>(null);
|
||||
|
||||
// to list commits, we need to know what branch we're looking at.
|
||||
// we also need to know the set of all available branches
|
||||
const [branch, setBranch] = useState<string | null>(null);
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
|
||||
const [shaBranchLookup, setSHABranchLookup] = useState<Record<string, string[]>>({});
|
||||
|
||||
// When repo changes, refetch the available branches.
|
||||
useEffect(() => {
|
||||
if (repo === null) {
|
||||
return setBranches(null);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (repo === "local") {
|
||||
const res = await APIFetchV1<{ branches: Branch[]; current: Branch | null }>(
|
||||
`/seeds/branches`
|
||||
);
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(
|
||||
`Failed to fetch branches for your local repo. ${res.description}.`
|
||||
);
|
||||
}
|
||||
|
||||
setBranches(res.body.branches.map((e) => e.name));
|
||||
|
||||
// can't be bothered getting this to work
|
||||
// if (res.body.current) {
|
||||
// setBranch(res.body.current.name);
|
||||
// }
|
||||
|
||||
// to get pretty UI stuff, we keep track of the HEADs of all branches
|
||||
// so we can render what branch a commit is the HEAD of in the UI.
|
||||
const lookup: Record<string, string[]> = {};
|
||||
|
||||
for (const branch of res.body.branches) {
|
||||
if (branch.sha in lookup) {
|
||||
lookup[branch.sha].push(branch.name);
|
||||
} else {
|
||||
lookup[branch.sha] = [branch.name];
|
||||
}
|
||||
}
|
||||
|
||||
setSHABranchLookup(lookup);
|
||||
} else if (repo.startsWith("GitHub:")) {
|
||||
const repoName = repo.slice("GitHub:".length);
|
||||
|
||||
console.log(repoName);
|
||||
|
||||
const res: Array<{ name: string; commit: { sha: string } }> = await fetch(
|
||||
`https://api.github.com/repos/${repoName}/branches`
|
||||
).then((r) => r.json());
|
||||
|
||||
setBranches(res.map((e) => e.name));
|
||||
|
||||
const lookup: Record<string, string[]> = {};
|
||||
|
||||
for (const branch of res) {
|
||||
if (branch.commit.sha in lookup) {
|
||||
lookup[branch.commit.sha].push(branch.name);
|
||||
} else {
|
||||
lookup[branch.commit.sha] = [branch.name];
|
||||
}
|
||||
}
|
||||
|
||||
setSHABranchLookup(lookup);
|
||||
}
|
||||
})();
|
||||
}, [repo]);
|
||||
|
||||
// if branches change, re-prompt for users to select a branch
|
||||
useEffect(() => {
|
||||
setBranch(null);
|
||||
}, [branches]);
|
||||
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const [subject, body] = useMemo(() => {
|
||||
if (rev === null) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const [subject, _gap, ...maybeBodies] = rev.c.commit.message.split("\n");
|
||||
|
||||
return [subject, maybeBodies?.join("\n") ?? null];
|
||||
}, [rev]);
|
||||
|
||||
const [showBody, setShowBody] = useState(false);
|
||||
|
||||
const authorNotCommitter = rev?.c.commit.author.email !== rev?.c.commit.committer.email;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card header={header}>
|
||||
{rev ? (
|
||||
<div className="mr-3" style={{ textAlign: "left" }}>
|
||||
<span style={{ fontSize: "1.15rem" }}>
|
||||
<code>
|
||||
{rev.repo}/{rev.c.sha}
|
||||
</code>
|
||||
: {subject}
|
||||
</span>
|
||||
{body && (
|
||||
<span
|
||||
className="ml-4 badge badge-secondary"
|
||||
onClick={() => setShowBody(!showBody)}
|
||||
>
|
||||
{showBody ? (
|
||||
<Icon style={{ fontSize: "0.7rem" }} type="chevron-down" />
|
||||
) : (
|
||||
<Icon style={{ fontSize: "0.7rem" }} type="chevron-left" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{showBody && <div className="ml-6 mt-1">{body}</div>}
|
||||
<Divider />
|
||||
<div style={{ width: "100%", display: "flex" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{authorNotCommitter ? (
|
||||
<>
|
||||
Authored by <b>{rev.c.commit.author.name}</b>, Committed by{" "}
|
||||
<b>{rev.c.commit.committer.name}</b>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Authored by <b>{rev.c.commit.author.name}</b>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right" style={{ flex: 1 }}>
|
||||
{FormatTime(Date.parse(rev.c.commit.author.date))}
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="w-100 d-flex">
|
||||
<Button variant="secondary" onClick={() => setShow(true)}>
|
||||
Change Commit...
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{message && (
|
||||
<>
|
||||
<span>{message}</span>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="secondary" onClick={() => setShow(true)}>
|
||||
Pick Commit...
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<Modal show={show} size="xl" onHide={() => setShow(false)}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Pick {header}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Row>
|
||||
<Col xs={12} className="text-center">
|
||||
<div>
|
||||
<span style={{ fontSize: "large" }}>Repository:</span>
|
||||
<Select
|
||||
style={{ display: "inline", width: "unset" }}
|
||||
className="mx-2"
|
||||
value={repo}
|
||||
setValue={setRepo}
|
||||
allowNull
|
||||
>
|
||||
{hasLocalAPI && <option value="local">Your Local Repo</option>}
|
||||
{process.env.REACT_APP_GIT_REPO && (
|
||||
<option value={process.env.REACT_APP_GIT_REPO}>
|
||||
{process.env.REACT_APP_GIT_REPO}
|
||||
</option>
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
{branches && (
|
||||
<div className="mt-2">
|
||||
<span style={{ fontSize: "large" }}>Branch:</span>
|
||||
<Select
|
||||
style={{ display: "inline", width: "unset" }}
|
||||
className="mx-2"
|
||||
value={branch}
|
||||
setValue={setBranch}
|
||||
allowNull
|
||||
>
|
||||
{branches.map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{e}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
<Col xs={12} lg={10} className="offset-lg-1 text-center">
|
||||
{repo && branch && (
|
||||
<>
|
||||
<Divider />
|
||||
<RevSelector
|
||||
shaBranchLookup={shaBranchLookup}
|
||||
repo={repo}
|
||||
branch={branch}
|
||||
onSelect={(commit) => {
|
||||
setRev({ c: commit, repo });
|
||||
setShow(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RevSelector({
|
||||
repo,
|
||||
onSelect,
|
||||
branch,
|
||||
shaBranchLookup,
|
||||
}: {
|
||||
repo: string;
|
||||
onSelect: (g: GitCommit) => void;
|
||||
branch: string;
|
||||
shaBranchLookup: Record<string, string[]>;
|
||||
}) {
|
||||
const [revs, setRevs] = useState<Array<GitCommit>>([]);
|
||||
const [filteredRevs, setFilteredRevs] = useState<Array<GitCommit>>([]);
|
||||
|
||||
// We have the option of filtering returned revisions according to only those that
|
||||
// affect a specific file. Sounds useful.
|
||||
const [collection, setCollection] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (repo.startsWith("GitHub:")) {
|
||||
setRevs([]);
|
||||
const repoName = repo.slice("GitHub:".length);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (collection) {
|
||||
params.set("path", `database-seeds/collections/${collection}`);
|
||||
} else {
|
||||
params.set("path", "database-seeds/collections");
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.github.com/repos/${repoName}/commits?${params.toString()}`
|
||||
).then((r) => r.json());
|
||||
|
||||
setRevs(res);
|
||||
} else {
|
||||
setRevs([]);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("branch", branch);
|
||||
|
||||
if (collection) {
|
||||
params.set("file", collection);
|
||||
}
|
||||
|
||||
// local
|
||||
const res = await APIFetchV1<Array<GitCommit>>(
|
||||
`/seeds/commits?${params.toString()}`
|
||||
);
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Failed to fetch commits? ${res.description}`);
|
||||
}
|
||||
|
||||
const hasUncommittedRes = await APIFetchV1<boolean>(
|
||||
"/seeds/has-uncommitted-changes"
|
||||
);
|
||||
|
||||
if (hasUncommittedRes.success && hasUncommittedRes.body) {
|
||||
const LOCAL_COMMIT: GitCommit = {
|
||||
sha: "WORKING_DIRECTORY",
|
||||
commit: {
|
||||
author: {
|
||||
name: "Not Committed Yet",
|
||||
date: "1970-01-01",
|
||||
email: "null@example.com",
|
||||
},
|
||||
committer: {
|
||||
name: "Not Committed Yet",
|
||||
date: "1970-01-01",
|
||||
email: "null@example.com",
|
||||
},
|
||||
message: "Uncommitted changes on your local disk.",
|
||||
},
|
||||
parents: res.body[0] ? [{ sha: res.body[0].sha }] : [],
|
||||
};
|
||||
|
||||
setRevs([LOCAL_COMMIT, ...res.body]);
|
||||
} else {
|
||||
setRevs(res.body);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [repo, branch, collection]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredRevs(revs);
|
||||
setSearch("");
|
||||
}, [revs]);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (search === "") {
|
||||
return setFilteredRevs(revs);
|
||||
}
|
||||
|
||||
const regexpSearch = new RegExp(EscapeStringRegexp(search), "ui");
|
||||
|
||||
// remarkably lazy search implementation, but convenient.
|
||||
return setFilteredRevs(
|
||||
revs.filter(
|
||||
(r) =>
|
||||
r.sha.match(regexpSearch) ||
|
||||
r.commit.message.match(regexpSearch) ||
|
||||
r.commit.author.name.match(regexpSearch) ||
|
||||
r.commit.committer.name.match(regexpSearch)
|
||||
)
|
||||
);
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<span>Show changes that affect: </span>
|
||||
<Select
|
||||
value={collection}
|
||||
setValue={setCollection}
|
||||
unselectedName="Any Collection"
|
||||
allowNull
|
||||
className="mx-2"
|
||||
style={{ width: "unset", display: "inline" }}
|
||||
>
|
||||
{DatabaseSeedNames.map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{e}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<Divider />
|
||||
<FormInput
|
||||
fieldName="Search"
|
||||
placeholder="Search commits..."
|
||||
value={search}
|
||||
setValue={setSearch}
|
||||
/>
|
||||
<Divider />
|
||||
<div className="timeline timeline-2">
|
||||
<div className="timeline-bar"></div>
|
||||
{filteredRevs.map((r) => (
|
||||
<Revision
|
||||
tags={shaBranchLookup[r.sha] ?? []}
|
||||
key={r.sha}
|
||||
rev={r}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Revision({
|
||||
rev,
|
||||
tags = [],
|
||||
onSelect: onSelect,
|
||||
}: {
|
||||
rev: GitCommit;
|
||||
tags?: string[];
|
||||
onSelect: (g: GitCommit) => void;
|
||||
}) {
|
||||
const authorNotCommitter = rev.commit.author.email !== rev.commit.committer.email;
|
||||
|
||||
// if there's a body then there's two newlines.
|
||||
const [subject, _gap, ...maybeBodies] = rev.commit.message.split("\n");
|
||||
|
||||
const [showBody, setShowBody] = useState(false);
|
||||
|
||||
const body: string | undefined = maybeBodies?.join("\n");
|
||||
|
||||
return (
|
||||
<div className="timeline-item">
|
||||
<span className="timeline-badge bg-primary"></span>
|
||||
<div className="timeline-content d-flex align-items-center justify-content-between">
|
||||
<div className="mr-3" style={{ width: "70%", textAlign: "left" }}>
|
||||
<span style={{ fontSize: "1.15rem" }}>
|
||||
<code>{TruncateString(rev.sha, 9)}</code>:{" "}
|
||||
<a className="gentle-link" onClick={() => onSelect(rev)}>
|
||||
{subject}
|
||||
</a>
|
||||
</span>
|
||||
{body && (
|
||||
<span
|
||||
className="ml-4 badge badge-secondary"
|
||||
onClick={() => setShowBody(!showBody)}
|
||||
>
|
||||
{showBody ? (
|
||||
<Icon style={{ fontSize: "0.7rem" }} type="chevron-down" />
|
||||
) : (
|
||||
<Icon style={{ fontSize: "0.7rem" }} type="chevron-left" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{tags.length !== 0 && (
|
||||
<span className="ml-2">
|
||||
{tags.map((e) => (
|
||||
<Badge className="ml-2" variant="primary" key={e}>
|
||||
{e}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{showBody && <div className="ml-6 mt-1">{body}</div>}
|
||||
<div>
|
||||
{authorNotCommitter ? (
|
||||
<>
|
||||
Authored by <b>{rev.commit.author.name}</b>, Committed by{" "}
|
||||
<b>{rev.commit.committer.name}</b>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Authored by <b>{rev.commit.author.name}</b>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-muted font-italic text-right">
|
||||
{FormatTime(Date.parse(rev.commit.author.date))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,263 @@
|
||||
import React from "react";
|
||||
import { ChangeOpacity } from "util/color-opacity";
|
||||
import { JoinJSX } from "util/misc";
|
||||
import { LoadSeeds, DiffSeeds } from "util/seeds";
|
||||
import { StrSOV } from "util/sorts";
|
||||
import SeedsTable, { SeedsDiffTable } from "components/tables/seeds/SeedsTable";
|
||||
import Loading from "components/util/Loading";
|
||||
import SelectButton from "components/util/SelectButton";
|
||||
import { ColourConfig } from "lib/config";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Col, Button } from "react-bootstrap";
|
||||
import Card from "components/layout/page/Card";
|
||||
import { AllDatabaseSeeds } from "tachi-common";
|
||||
import { Revision } from "types/git";
|
||||
import Divider from "components/util/Divider";
|
||||
|
||||
export default function SeedsStateViewer() {
|
||||
return <div>SeedsStateViewer</div>;
|
||||
export default function SeedsStateViewer({
|
||||
baseRev,
|
||||
headRev,
|
||||
view,
|
||||
}: {
|
||||
baseRev: Revision;
|
||||
headRev: Revision | null;
|
||||
view: "DIFF" | "FULL";
|
||||
}) {
|
||||
const [baseData, setBaseData] = useState<Partial<AllDatabaseSeeds> | null>(null);
|
||||
|
||||
// typescript lets me get away with insane enums like this, so damnit i will.
|
||||
// "LOADING" is different to headData being null (not picked), unlike baseRev, which cannot be null.
|
||||
const [headData, setHeadData] = useState<Partial<AllDatabaseSeeds> | null | "LOADING">(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
// enter loading...
|
||||
setBaseData(null);
|
||||
const data = await LoadSeeds(baseRev.repo, baseRev.c.sha);
|
||||
|
||||
setBaseData(data);
|
||||
})();
|
||||
}, [baseRev]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
// enter loading...
|
||||
setHeadData("LOADING");
|
||||
|
||||
if (headRev === null) {
|
||||
setHeadData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await LoadSeeds(headRev.repo, headRev.c.sha);
|
||||
|
||||
setHeadData(data);
|
||||
})();
|
||||
}, [headRev]);
|
||||
|
||||
if (baseData === null || headData === "LOADING") {
|
||||
return (
|
||||
<Col xs={12}>
|
||||
<div className="d-flex justify-content-center">
|
||||
<div>
|
||||
<Loading />
|
||||
<br />
|
||||
Reading data...
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
if (headData !== null) {
|
||||
return <SeedsDiffState baseData={baseData} headData={headData} />;
|
||||
}
|
||||
|
||||
if (view === "DIFF") {
|
||||
if (baseRev.c.parents.length === 0) {
|
||||
// no parents, diff against the empty set.
|
||||
return <SeedsDiffState baseData={baseData} headData={{}} />;
|
||||
} else if (baseRev.c.parents.length > 1) {
|
||||
// more than one parent, i have no idea what to do?
|
||||
return (
|
||||
<div>
|
||||
This commit has multiple parents. Diffing against this isn't supported yet,
|
||||
sorry.
|
||||
<code>{baseRev.c.parents.join(", ")}</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SeedsDiffParentState seedsData={baseData} baseRev={baseRev} />;
|
||||
}
|
||||
|
||||
return <SeedsAbsoluteState seedsData={baseData} />;
|
||||
}
|
||||
|
||||
function SeedsDiffParentState({
|
||||
seedsData,
|
||||
baseRev,
|
||||
}: {
|
||||
seedsData: Partial<AllDatabaseSeeds>;
|
||||
baseRev: Revision;
|
||||
}) {
|
||||
const [parentData, setParentData] = useState<Partial<AllDatabaseSeeds> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
// loading...
|
||||
setParentData(null);
|
||||
|
||||
const data = await LoadSeeds(baseRev.repo, baseRev.c.parents[0].sha);
|
||||
|
||||
setParentData(data);
|
||||
})();
|
||||
}, [baseRev]);
|
||||
|
||||
if (parentData === null) {
|
||||
return (
|
||||
<Col xs={12}>
|
||||
<div className="d-flex justify-content-center">
|
||||
<div>
|
||||
<Loading />
|
||||
<br />
|
||||
Comparing against previous state... (This takes a bit.)
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
return <SeedsDiffState baseData={parentData} headData={seedsData} />;
|
||||
}
|
||||
|
||||
function SeedsAbsoluteState({ seedsData }: { seedsData: Partial<AllDatabaseSeeds> }) {
|
||||
const files = Object.keys(seedsData).sort(StrSOV((k) => k)) as Array<keyof AllDatabaseSeeds>;
|
||||
|
||||
const [file, setFile] = useState(files[0]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Col xs={12}>
|
||||
<Card header="Collections">
|
||||
<div className="d-flex flex-wrap" style={{ justifyContent: "space-around" }}>
|
||||
{files.map((e) => (
|
||||
<div key={e} className="my-2">
|
||||
<SelectButton id={e} setValue={setFile} value={file}>
|
||||
{e} ({seedsData[e]?.length ?? 0})
|
||||
</SelectButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<Divider />
|
||||
<h1 className="text-center">{file}</h1>
|
||||
<Divider />
|
||||
<SeedsTable data={seedsData} file={file} />
|
||||
</Col>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SeedsDiffState({
|
||||
baseData,
|
||||
headData,
|
||||
}: {
|
||||
baseData: Partial<AllDatabaseSeeds>;
|
||||
headData: Partial<AllDatabaseSeeds>;
|
||||
}) {
|
||||
const seedsDiff = useMemo(() => DiffSeeds(baseData, headData), [baseData, headData]);
|
||||
|
||||
const files = Object.keys(seedsDiff).sort(StrSOV((k) => k)) as Array<keyof AllDatabaseSeeds>;
|
||||
|
||||
// don't display anything with 0 diffs.
|
||||
const filesWithDiffs = files.filter((e) => seedsDiff[e]?.length !== 0);
|
||||
|
||||
const [file, setFile] = useState<keyof AllDatabaseSeeds | null>(filesWithDiffs[0] ?? null);
|
||||
|
||||
return (
|
||||
<Col xs={12}>
|
||||
<Card header="Changes">
|
||||
<div className="d-flex flex-wrap" style={{ justifyContent: "space-around" }}>
|
||||
{filesWithDiffs.length > 0 ? (
|
||||
filesWithDiffs.map((e) => {
|
||||
let added = 0;
|
||||
let modified = 0;
|
||||
let deleted = 0;
|
||||
|
||||
for (const diff of seedsDiff[e]!) {
|
||||
switch (diff.type) {
|
||||
case "ADDED":
|
||||
added++;
|
||||
break;
|
||||
case "DELETED":
|
||||
deleted++;
|
||||
break;
|
||||
case "MODIFIED":
|
||||
modified++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const display = [];
|
||||
|
||||
if (added) {
|
||||
display.push(<span className="text-success">+{added}</span>);
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
display.push(<span className="text-warning">~{modified}</span>);
|
||||
}
|
||||
|
||||
if (deleted) {
|
||||
display.push(<span className="text-danger">-{deleted}</span>);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={e} className="my-2">
|
||||
<Button
|
||||
variant={file === e ? "primary" : "secondary"}
|
||||
onClick={() => setFile(e)}
|
||||
style={
|
||||
file === e
|
||||
? {
|
||||
backgroundColor: ChangeOpacity(
|
||||
ColourConfig.primary,
|
||||
0.6
|
||||
),
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{e}
|
||||
{display.length > 0 && (
|
||||
<>
|
||||
<br />({JoinJSX(display, <span> | </span>)})
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div>There are no changes between these two commits.</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
{file && (
|
||||
<Col xs={12}>
|
||||
<Divider />
|
||||
<h1 className="text-center">{file}</h1>
|
||||
<Divider />
|
||||
<SeedsDiffTable
|
||||
file={file}
|
||||
baseData={baseData}
|
||||
headData={headData}
|
||||
diffs={seedsDiff[file] ?? []}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ export default function ObjCell({ data }: { data: unknown }) {
|
||||
return (
|
||||
<td className="text-left">
|
||||
{/* this kinda sucks. have we got a better way to do this? */}
|
||||
{FlattenValue(data).map((e) => (
|
||||
<>
|
||||
<code>{StringifyKeyChain(e.keychain)}</code>: {String(e.value)}
|
||||
<br />
|
||||
</>
|
||||
))}
|
||||
{FlattenValue(data)
|
||||
.filter((e) => e.value !== null)
|
||||
.map((e) => (
|
||||
<>
|
||||
<code>{StringifyKeyChain(e.keychain)}</code>: {JSON.stringify(e.value)}
|
||||
<br />
|
||||
</>
|
||||
))}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { JSONAttributeDiff, StringifyKeyChain } from "util/misc";
|
||||
import React from "react";
|
||||
|
||||
export default function SeedsDiffCell({ diffs }: { diffs: JSONAttributeDiff[] }) {
|
||||
return (
|
||||
<td>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: "200px",
|
||||
maxWidth: "600px",
|
||||
overflow: "scroll",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{diffs.map((e) => (
|
||||
<div key={StringifyKeyChain(e.keychain)}>
|
||||
<code>{StringifyKeyChain(e.keychain)}</code>
|
||||
<br />
|
||||
<DiffRow diff={e} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffRow({ diff }: { diff: JSONAttributeDiff }) {
|
||||
// added
|
||||
if (diff.beforeVal === undefined) {
|
||||
return <span className="text-success">+{JSON.stringify(diff.afterVal)}</span>;
|
||||
}
|
||||
|
||||
// deleted
|
||||
if (diff.afterVal === undefined) {
|
||||
return <span className="text-danger">-{JSON.stringify(diff.beforeVal)}</span>;
|
||||
}
|
||||
|
||||
// modified
|
||||
|
||||
return (
|
||||
<span>
|
||||
<div>
|
||||
<span className="text-danger">-{JSON.stringify(diff.beforeVal)}</span>
|
||||
<br />
|
||||
<span className="text-success">+{JSON.stringify(diff.afterVal)}</span>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ChangeOpacity } from "util/color-opacity";
|
||||
import React from "react";
|
||||
import { COLOUR_SET } from "tachi-common";
|
||||
import { ChangeIndicator } from "types/seeds";
|
||||
|
||||
export default function SeedsIndicatorCell({ indicate }: { indicate: ChangeIndicator }) {
|
||||
if (indicate === "ADDED") {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
backgroundColor: ChangeOpacity(COLOUR_SET.green, 0.2),
|
||||
borderRight: "1px black solid",
|
||||
}}
|
||||
>
|
||||
<strong>+</strong>
|
||||
</td>
|
||||
);
|
||||
} else if (indicate === "REMOVED") {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
backgroundColor: ChangeOpacity(COLOUR_SET.red, 0.2),
|
||||
borderRight: "1px black solid",
|
||||
}}
|
||||
>
|
||||
<strong>-</strong>
|
||||
</td>
|
||||
);
|
||||
} else if (indicate === "MODIFIED") {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
backgroundColor: ChangeOpacity(COLOUR_SET.orange, 0.2),
|
||||
borderRight: "1px black solid",
|
||||
}}
|
||||
>
|
||||
<strong>~</strong>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
return <td />;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GetValueGetter, ValueGetterOrHybrid } from "util/ztable/search";
|
||||
import { GetValueGetter, SearchFunctions } from "util/ztable/search";
|
||||
import Divider from "components/util/Divider";
|
||||
import ExternalLink from "components/util/ExternalLink";
|
||||
import HoverText from "components/util/HoverText";
|
||||
@@ -45,7 +45,7 @@ export default function FilterDirectivesIndicator<D>({
|
||||
searchFunctions,
|
||||
doc,
|
||||
}: {
|
||||
searchFunctions: Record<string, ValueGetterOrHybrid<D>>;
|
||||
searchFunctions: SearchFunctions<D>;
|
||||
doc?: D;
|
||||
}) {
|
||||
const [modalShow, setModalShow] = useState(false);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { CopyToClipboard } from "util/misc";
|
||||
import { ComposeSearchFunction, ValueGetterOrHybrid } from "util/ztable/search";
|
||||
import { ComposeSearchFunction, SearchFunctions } from "util/ztable/search";
|
||||
import Icon from "components/util/Icon";
|
||||
import SmallText from "components/util/SmallText";
|
||||
import { useZTable, ZTableSortFn } from "components/util/table/useZTable";
|
||||
import { UserSettingsContext } from "context/UserSettingsContext";
|
||||
import { json2csvAsync } from "json-2-csv";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { integer } from "tachi-common";
|
||||
@@ -91,7 +90,7 @@ export default function TachiTable<D>({
|
||||
noTopDisplayStr?: boolean;
|
||||
defaultSortMode?: string;
|
||||
defaultReverseSort?: boolean;
|
||||
searchFunctions?: Record<string, ValueGetterOrHybrid<D>>;
|
||||
searchFunctions?: SearchFunctions<D>;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { HeadersToDiffHeaders, SearchFnsToDiffSearchFns } from "util/seeds";
|
||||
import { SearchFunctions } from "util/ztable/search";
|
||||
import Muted from "components/util/Muted";
|
||||
import React from "react";
|
||||
import { Col } from "react-bootstrap";
|
||||
import { CellsRenderFN, DiffSeedsCollection } from "types/seeds";
|
||||
import SeedsDiffCell from "../cells/SeedsDiffCell";
|
||||
import SeedsIndicatorCell from "../cells/SeedsIndicatorCell";
|
||||
import DropdownRow from "../components/DropdownRow";
|
||||
import TachiTable, { Header } from "../components/TachiTable";
|
||||
|
||||
/**
|
||||
* Given all the necessary parts of a normal seeds table, render a table showing
|
||||
* its diffs instead.
|
||||
*/
|
||||
export default function MakeSeedsDiffTable<T>({
|
||||
headers,
|
||||
dataset,
|
||||
searchFns,
|
||||
entryName,
|
||||
Cells,
|
||||
}: {
|
||||
headers: Header<T>[];
|
||||
dataset: DiffSeedsCollection<T>[];
|
||||
searchFns: SearchFunctions<T>;
|
||||
entryName: string;
|
||||
Cells: CellsRenderFN<T>;
|
||||
}) {
|
||||
return (
|
||||
<TachiTable
|
||||
dataset={dataset}
|
||||
headers={HeadersToDiffHeaders(headers)}
|
||||
entryName={entryName}
|
||||
searchFunctions={SearchFnsToDiffSearchFns(searchFns)}
|
||||
rowFunction={(x) => <DiffRow data={x} Cells={Cells} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffRow<T>({ data, Cells }: { data: DiffSeedsCollection<T>; Cells: CellsRenderFN<T> }) {
|
||||
return (
|
||||
<DropdownRow
|
||||
dropdown={
|
||||
<div className="row">
|
||||
<Col xs={12} className="mt-2">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td colSpan={100}>Old Entry</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<Cells data={data.base} />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<br />
|
||||
<Muted>
|
||||
(This is what the row looked like before changes were applied.)
|
||||
</Muted>
|
||||
</Col>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SeedsIndicatorCell indicate="MODIFIED" />
|
||||
<Cells data={data.head} compress />
|
||||
<SeedsDiffCell diffs={data.diff} />
|
||||
</DropdownRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { SearchFunctions } from "util/ztable/search";
|
||||
import DebugContent from "components/util/DebugContent";
|
||||
import React from "react";
|
||||
import { CellsRenderFN, ChangeIndicator } from "types/seeds";
|
||||
import SeedsIndicatorCell from "../cells/SeedsIndicatorCell";
|
||||
import DropdownRow from "../components/DropdownRow";
|
||||
import TachiTable, { Header } from "../components/TachiTable";
|
||||
import { EmptyHeader } from "../headers/IndicatorHeader";
|
||||
|
||||
export default function MakeSeedsTable<T>({
|
||||
dataset,
|
||||
headers: _headers,
|
||||
Cells,
|
||||
indicate = null,
|
||||
searchFns,
|
||||
entryName,
|
||||
}: {
|
||||
headers: Header<T>[];
|
||||
dataset: T[];
|
||||
indicate?: ChangeIndicator;
|
||||
searchFns: SearchFunctions<T>;
|
||||
Cells: CellsRenderFN<T>;
|
||||
entryName: string;
|
||||
}) {
|
||||
// clone headers so as to not mutate global state
|
||||
const headers = _headers.slice(0);
|
||||
|
||||
if (indicate) {
|
||||
headers.unshift(EmptyHeader);
|
||||
}
|
||||
|
||||
return (
|
||||
<TachiTable
|
||||
dataset={dataset}
|
||||
headers={headers}
|
||||
entryName={entryName}
|
||||
searchFunctions={searchFns}
|
||||
rowFunction={(data) => (
|
||||
<DropdownRow dropdown={<DebugContent data={{ ...data, __related: undefined }} />}>
|
||||
{indicate && <SeedsIndicatorCell indicate={indicate} />}
|
||||
<Cells data={data} />
|
||||
</DropdownRow>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +1,76 @@
|
||||
import { StrSOV } from "util/sorts";
|
||||
import { SearchFunctions } from "util/ztable/search";
|
||||
import React from "react";
|
||||
import { FormatChart } from "tachi-common";
|
||||
import { BMSCourseWithRelated } from "types/seeds";
|
||||
import DebugContent from "components/util/DebugContent";
|
||||
import TachiTable, { Header } from "../components/TachiTable";
|
||||
import DropdownRow from "../components/DropdownRow";
|
||||
import { BMSCourseWithRelated, CellsRenderFN } from "types/seeds";
|
||||
import { Header } from "../components/TachiTable";
|
||||
|
||||
export default function SeedsBMSCourseLookupTable({
|
||||
dataset,
|
||||
}: {
|
||||
dataset: BMSCourseWithRelated[];
|
||||
}) {
|
||||
const headers: Header<BMSCourseWithRelated>[] = [
|
||||
["Title", "Title", StrSOV((x) => x.title)],
|
||||
["Charts", "Charts", StrSOV((x) => x.title)],
|
||||
["MD5s", "MD5s", StrSOV((x) => x.title)],
|
||||
[
|
||||
"Set (Index)",
|
||||
"Set (Idx)",
|
||||
(a, b) => {
|
||||
const a2 = `${a.playtype} ${a.set}`;
|
||||
const b2 = `${b.playtype} ${b.set}`;
|
||||
export const SeedsBMSCourseLookupHeaders: Header<BMSCourseWithRelated>[] = [
|
||||
["Title", "Title", StrSOV((x) => x.title)],
|
||||
["Charts", "Charts", StrSOV((x) => x.title)],
|
||||
[
|
||||
"Set (Index)",
|
||||
"Set (Idx)",
|
||||
(a, b) => {
|
||||
const a2 = `${a.playtype} ${a.set}`;
|
||||
const b2 = `${b.playtype} ${b.set}`;
|
||||
|
||||
if (a2 === b2) {
|
||||
return a.value - b.value;
|
||||
if (a2 === b2) {
|
||||
return a.value - b.value;
|
||||
}
|
||||
|
||||
return a2.localeCompare(b2);
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
export const SeedsBMSCourseLookupSearchFns: SearchFunctions<BMSCourseWithRelated> = {
|
||||
title: (x) => x.title,
|
||||
set: (x) => `${x.playtype} ${x.set}`,
|
||||
md5: (x) => x.md5sums,
|
||||
value: (x) => x.value,
|
||||
playtype: (x) => x.playtype,
|
||||
// don't ask
|
||||
// it gets all the songtitles and joins them
|
||||
song: (x) =>
|
||||
x.__related.entries
|
||||
.map((e) => {
|
||||
if (typeof e === "string") {
|
||||
return `UNKNOWN CHART (${e})`;
|
||||
}
|
||||
|
||||
return a2.localeCompare(b2);
|
||||
},
|
||||
],
|
||||
];
|
||||
return FormatChart("bms", e.song, e.chart);
|
||||
})
|
||||
.join("\n"),
|
||||
};
|
||||
|
||||
return (
|
||||
<TachiTable
|
||||
dataset={dataset}
|
||||
headers={headers}
|
||||
entryName="BMS Courses"
|
||||
searchFunctions={{
|
||||
title: (x) => x.title,
|
||||
set: (x) => `${x.playtype} ${x.set}`,
|
||||
md5: (x) => x.md5sums,
|
||||
value: (x) => x.value,
|
||||
playtype: (x) => x.playtype,
|
||||
// don't ask
|
||||
// it gets all the songtitles and joins them
|
||||
song: (x) =>
|
||||
x.__related.entries
|
||||
.map((e) => {
|
||||
if (typeof e === "string") {
|
||||
return `UNKNOWN CHART (${e})`;
|
||||
}
|
||||
export const SeedsBMSCourseLookupCells: CellsRenderFN<BMSCourseWithRelated> = ({
|
||||
data,
|
||||
compress,
|
||||
}) => (
|
||||
<>
|
||||
<td>
|
||||
<strong>{data.title}</strong>
|
||||
</td>
|
||||
<td className="text-left">
|
||||
{data.__related.entries.map((e, i) => (
|
||||
<div key={i} className="d-flex w-100">
|
||||
{typeof e === "string" ? (
|
||||
<span className="text-danger">UNKNOWN CHART</span>
|
||||
) : (
|
||||
<span>{FormatChart("bms", e.song, e.chart)} </span>
|
||||
)}
|
||||
|
||||
return FormatChart("bms", e.song, e.chart);
|
||||
})
|
||||
.join("\n"),
|
||||
}}
|
||||
rowFunction={(x) => <Row data={x} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ data }: { data: BMSCourseWithRelated }) {
|
||||
return (
|
||||
<DropdownRow dropdown={<DebugContent data={{ ...data, __related: undefined }} />}>
|
||||
<td>
|
||||
<strong>{data.title}</strong>
|
||||
</td>
|
||||
<td className="text-left">
|
||||
{data.__related.entries.map((e, i) => (
|
||||
<div key={i} className="d-flex w-100">
|
||||
{typeof e === "string" ? (
|
||||
<span className="text-danger">UNKNOWN CHART</span>
|
||||
) : (
|
||||
<span>{FormatChart("bms", e.song, e.chart)} </span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
<td>
|
||||
{data.__related.entries.map((e, i) => (
|
||||
<div key={i}>
|
||||
<code>{typeof e === "string" ? e : e.chart.data.hashMD5}</code>
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
<td>
|
||||
{data.playtype} {data.set} ({data.value})
|
||||
</td>
|
||||
</DropdownRow>
|
||||
);
|
||||
}
|
||||
{!compress && (
|
||||
<div className="ml-auto" key={i}>
|
||||
<code>{typeof e === "string" ? e : e.chart.data.hashMD5}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
<td>
|
||||
{data.playtype} {data.set} ({data.value})
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,75 +1,62 @@
|
||||
import { ChangeOpacity } from "util/color-opacity";
|
||||
import { FlattenValue, StringifyKeyChain } from "util/misc";
|
||||
import { StrSOV } from "util/sorts";
|
||||
import { SearchFunctions } from "util/ztable/search";
|
||||
import QuickTooltip from "components/layout/misc/QuickTooltip";
|
||||
import DebugContent from "components/util/DebugContent";
|
||||
import Icon from "components/util/Icon";
|
||||
import React from "react";
|
||||
import { COLOUR_SET, Game } from "tachi-common";
|
||||
import { ChartWithRelated } from "types/seeds";
|
||||
import { CellsRenderFN, ChartWithRelated } from "types/seeds";
|
||||
import DifficultyCell from "../cells/DifficultyCell";
|
||||
import ObjCell from "../cells/ObjCell";
|
||||
import TitleCell from "../cells/TitleCell";
|
||||
import DropdownRow from "../components/DropdownRow";
|
||||
import TachiTable, { Header } from "../components/TachiTable";
|
||||
import ChartHeader from "../headers/ChartHeader";
|
||||
import { Header } from "../components/TachiTable";
|
||||
|
||||
export default function SeedsChartsTable({
|
||||
dataset,
|
||||
game,
|
||||
}: {
|
||||
dataset: ChartWithRelated[];
|
||||
game: Game;
|
||||
}) {
|
||||
const headers: Header<ChartWithRelated>[] = [
|
||||
ChartHeader(game, (k) => k),
|
||||
["Title", "Title", StrSOV((x) => x.__related.song?.title ?? "")],
|
||||
["Data", "Data"],
|
||||
];
|
||||
export function MakeSeedsChartsControls(game: Game): {
|
||||
headers: Header<ChartWithRelated>[];
|
||||
searchFns: SearchFunctions<ChartWithRelated>;
|
||||
Cells: CellsRenderFN<ChartWithRelated>;
|
||||
} {
|
||||
return {
|
||||
headers: [
|
||||
ChartHeader(game, (k) => k),
|
||||
["Title", "Title", StrSOV((x) => x.__related.song?.title ?? "")],
|
||||
["Data", "Data"],
|
||||
],
|
||||
searchFns: {
|
||||
artist: (x) => x.__related.song?.artist ?? null,
|
||||
title: (x) => x.__related.song?.title ?? null,
|
||||
songID: (x) => x.__related.song?.id ?? null,
|
||||
searchTerms: (x) => x.__related.song?.searchTerms.join(", ") ?? null,
|
||||
altTitles: (x) => x.__related.song?.altTitles.join(", ") ?? null,
|
||||
difficulty: (x) => x.difficulty,
|
||||
level: (x) => x.levelNum,
|
||||
data: (x) =>
|
||||
FlattenValue(x.data)
|
||||
.map((e) => `${StringifyKeyChain(e.keychain)} ${e.value}`)
|
||||
.join("\n"),
|
||||
},
|
||||
Cells: ({ data }) => (
|
||||
<>
|
||||
<DifficultyCell chart={data} game={game} />
|
||||
|
||||
return (
|
||||
<TachiTable
|
||||
dataset={dataset}
|
||||
headers={headers}
|
||||
entryName="Charts"
|
||||
searchFunctions={{
|
||||
artist: (x) => x.__related.song?.artist ?? null,
|
||||
title: (x) => x.__related.song?.title ?? null,
|
||||
songID: (x) => x.__related.song?.id ?? null,
|
||||
searchTerms: (x) => x.__related.song?.searchTerms.join(", ") ?? null,
|
||||
altTitles: (x) => x.__related.song?.altTitles.join(", ") ?? null,
|
||||
difficulty: (x) => x.difficulty,
|
||||
level: (x) => x.levelNum,
|
||||
data: (x) =>
|
||||
FlattenValue(x.data)
|
||||
.map((e) => `${StringifyKeyChain(e.keychain)} ${e.value}`)
|
||||
.join("\n"),
|
||||
}}
|
||||
rowFunction={(x) => <Row data={x} game={game} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
{/* if we have a song, render the title cell. Otherwise, this chart has no parent song, and is invalid. */}
|
||||
{data.__related.song ? (
|
||||
<TitleCell game={game} song={data.__related.song} chart={data} />
|
||||
) : (
|
||||
<td style={{ backgroundColor: ChangeOpacity(COLOUR_SET.red, 0.3) }}>
|
||||
INVALID SONG ({data.songID})
|
||||
<QuickTooltip tooltipContent="This chart points to a songID that does not exist at the time of this commit. This is a fatal error, and this commit doesn't pass tests.">
|
||||
<div>
|
||||
<Icon type="info" />
|
||||
</div>
|
||||
</QuickTooltip>
|
||||
</td>
|
||||
)}
|
||||
|
||||
function Row({ data, game }: { data: ChartWithRelated; game: Game }) {
|
||||
return (
|
||||
<DropdownRow dropdown={<DebugContent data={{ ...data, __related: undefined }} />}>
|
||||
<DifficultyCell chart={data} game={game} />
|
||||
|
||||
{/* if we have a song, render the title cell. Otherwise, this chart has no parent song, and is invalid. */}
|
||||
{data.__related.song ? (
|
||||
<TitleCell game={game} song={data.__related.song} chart={data} />
|
||||
) : (
|
||||
<td style={{ backgroundColor: ChangeOpacity(COLOUR_SET.red, 0.3) }}>
|
||||
INVALID SONG ({data.songID}
|
||||
<QuickTooltip tooltipContent="This chart points to a songID that does not exist at the time of this commit. This is a fatal error, and this commit doesn't pass tests.">
|
||||
<div>
|
||||
<Icon type="info" />
|
||||
</div>
|
||||
</QuickTooltip>
|
||||
</td>
|
||||
)}
|
||||
|
||||
<ObjCell data={data.data} />
|
||||
</DropdownRow>
|
||||
);
|
||||
<ObjCell data={data.data} />
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,79 +1,71 @@
|
||||
import { FlattenValue, StringifyKeyChain } from "util/misc";
|
||||
import { StrSOV } from "util/sorts";
|
||||
import DebugContent from "components/util/DebugContent";
|
||||
import { SearchFunctions } from "util/ztable/search";
|
||||
import Divider from "components/util/Divider";
|
||||
import Muted from "components/util/Muted";
|
||||
import React from "react";
|
||||
import { Badge } from "react-bootstrap";
|
||||
import { FolderDocument, FormatGame } from "tachi-common";
|
||||
import Divider from "components/util/Divider";
|
||||
import DropdownRow from "../components/DropdownRow";
|
||||
import TachiTable, { Header } from "../components/TachiTable";
|
||||
import { CellsRenderFN } from "types/seeds";
|
||||
import { Header } from "../components/TachiTable";
|
||||
|
||||
export default function SeedsFolderTable({ dataset }: { dataset: FolderDocument[] }) {
|
||||
const headers: Header<FolderDocument>[] = [
|
||||
["ID", "ID", StrSOV((x) => x.folderID)],
|
||||
["Name", "Name", StrSOV((x) => x.title)],
|
||||
["GPT", "GPT", StrSOV((x) => `${x.game} ${x.playtype}`)],
|
||||
["Query", "Query", StrSOV((x) => x.title)],
|
||||
];
|
||||
export const SeedsFolderHeaders: Header<FolderDocument>[] = [
|
||||
["ID", "ID", StrSOV((x) => x.folderID)],
|
||||
["Name", "Name", StrSOV((x) => x.title)],
|
||||
["GPT", "GPT", StrSOV((x) => `${x.game} ${x.playtype}`)],
|
||||
["Query", "Query", StrSOV((x) => x.title)],
|
||||
];
|
||||
|
||||
return (
|
||||
<TachiTable
|
||||
dataset={dataset}
|
||||
headers={headers}
|
||||
entryName="Folders"
|
||||
searchFunctions={{
|
||||
title: (x) => x.title,
|
||||
folderID: (x) => x.folderID,
|
||||
inactive: (x) => x.inactive,
|
||||
game: (x) => x.game,
|
||||
playtype: (x) => x.playtype,
|
||||
gpt: (x) => FormatGame(x.game, x.playtype),
|
||||
type: (x) => x.type,
|
||||
query: (x) =>
|
||||
FlattenValue(x.data)
|
||||
.map((e) => `${StringifyKeyChain(e.keychain)} ${e.value}`)
|
||||
.join("\n"),
|
||||
}}
|
||||
rowFunction={(x) => <Row data={x} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export const SeedsFolderSearchFns: SearchFunctions<FolderDocument> = {
|
||||
title: (x) => x.title,
|
||||
folderID: (x) => x.folderID,
|
||||
inactive: (x) => x.inactive,
|
||||
game: (x) => x.game,
|
||||
playtype: (x) => x.playtype,
|
||||
gpt: (x) => FormatGame(x.game, x.playtype),
|
||||
type: (x) => x.type,
|
||||
query: (x) =>
|
||||
FlattenValue(x.data)
|
||||
.map((e) => `${StringifyKeyChain(e.keychain)} ${e.value}`)
|
||||
.join("\n"),
|
||||
};
|
||||
|
||||
function Row({ data }: { data: FolderDocument }) {
|
||||
return (
|
||||
<DropdownRow dropdown={<DebugContent data={{ ...data, __related: undefined }} />}>
|
||||
<td>
|
||||
<code>{data.folderID}</code>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{data.title}</strong>
|
||||
{data.searchTerms.length !== 0 && (
|
||||
export const SeedsFolderCells: CellsRenderFN<FolderDocument> = ({
|
||||
data,
|
||||
}: {
|
||||
data: FolderDocument;
|
||||
}) => (
|
||||
<>
|
||||
<td>
|
||||
<code>{data.folderID}</code>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{data.title}</strong>
|
||||
{data.searchTerms && data.searchTerms.length !== 0 && (
|
||||
<>
|
||||
<br />
|
||||
<Muted>{data.searchTerms.join(", ")}</Muted>
|
||||
</>
|
||||
)}
|
||||
{data.inactive && (
|
||||
<>
|
||||
<br />
|
||||
<Badge variant="warning">INACTIVE</Badge>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td>{FormatGame(data.game, data.playtype)}</td>
|
||||
<td>
|
||||
TYPE: <b>{data.type}</b>
|
||||
<Divider />
|
||||
<div className="text-left">
|
||||
{FlattenValue(data.data).map((e) => (
|
||||
<>
|
||||
{StringifyKeyChain(e.keychain)} = {String(e.value)}
|
||||
<br />
|
||||
<Muted>{data.searchTerms.join(", ")}</Muted>
|
||||
</>
|
||||
)}
|
||||
{data.inactive && (
|
||||
<>
|
||||
<br />
|
||||
<Badge variant="warning">INACTIVE</Badge>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td>{FormatGame(data.game, data.playtype)}</td>
|
||||
<td>
|
||||
TYPE: <b>{data.type}</b>
|
||||
<Divider />
|
||||
<div className="text-left">
|
||||
{FlattenValue(data.data).map((e) => (
|
||||
<>
|
||||
{StringifyKeyChain(e.keychain)} = {String(e.value)}
|
||||
<br />
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</DropdownRow>
|
||||
);
|
||||
}
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,53 +1,41 @@
|
||||
import { NumericSOV, StrSOV } from "util/sorts";
|
||||
import { FlattenValue, StringifyKeyChain } from "util/misc";
|
||||
import { NumericSOV, StrSOV } from "util/sorts";
|
||||
import { SearchFunctions } from "util/ztable/search";
|
||||
import React from "react";
|
||||
import { Game, SongDocument } from "tachi-common";
|
||||
import DebugContent from "components/util/DebugContent";
|
||||
import TachiTable, { Header } from "../components/TachiTable";
|
||||
import DropdownRow from "../components/DropdownRow";
|
||||
import TitleCell from "../cells/TitleCell";
|
||||
import { CellsRenderFN } from "types/seeds";
|
||||
import ObjCell from "../cells/ObjCell";
|
||||
import TitleCell from "../cells/TitleCell";
|
||||
import { Header } from "../components/TachiTable";
|
||||
|
||||
export default function SeedsSongsTable({
|
||||
dataset,
|
||||
game,
|
||||
}: {
|
||||
dataset: SongDocument[];
|
||||
game: Game;
|
||||
}) {
|
||||
const headers: Header<SongDocument>[] = [
|
||||
["ID", "ID", NumericSOV((x) => x.id)],
|
||||
["Title", "Title", StrSOV((x) => x.title)],
|
||||
["Data", "Data"],
|
||||
];
|
||||
|
||||
return (
|
||||
<TachiTable
|
||||
dataset={dataset}
|
||||
headers={headers}
|
||||
entryName="Songs"
|
||||
searchFunctions={{
|
||||
artist: (x) => x.artist,
|
||||
title: (x) => x.title,
|
||||
songID: (x) => x.id,
|
||||
searchTerms: (x) => x.searchTerms.join(", "),
|
||||
altTitles: (x) => x.altTitles.join(", "),
|
||||
data: (x) =>
|
||||
FlattenValue(x.data)
|
||||
.map((e) => `${StringifyKeyChain(e.keychain)} ${e.value}`)
|
||||
.join("\n"),
|
||||
}}
|
||||
rowFunction={(x) => <Row data={x} game={game} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ data, game }: { data: SongDocument; game: Game }) {
|
||||
return (
|
||||
<DropdownRow dropdown={<DebugContent data={{ ...data, __related: undefined }} />}>
|
||||
<td>{data.id}</td>
|
||||
<TitleCell game={game} song={data} showAltTitles showSearchTerms />
|
||||
<ObjCell data={data.data} />
|
||||
</DropdownRow>
|
||||
);
|
||||
export function MakeSeedsSongsControls(game: Game): {
|
||||
headers: Header<SongDocument>[];
|
||||
searchFns: SearchFunctions<SongDocument>;
|
||||
Cells: CellsRenderFN<SongDocument>;
|
||||
} {
|
||||
return {
|
||||
headers: [
|
||||
["ID", "ID", NumericSOV((x) => x.id)],
|
||||
["Title", "Title", StrSOV((x) => x.title)],
|
||||
["Data", "Data"],
|
||||
],
|
||||
searchFns: {
|
||||
artist: (x) => x.artist,
|
||||
title: (x) => x.title,
|
||||
songID: (x) => x.id,
|
||||
searchTerms: (x) => x.searchTerms.join(", "),
|
||||
altTitles: (x) => x.altTitles.join(", "),
|
||||
data: (x) =>
|
||||
FlattenValue(x.data)
|
||||
.map((e) => `${StringifyKeyChain(e.keychain)} ${e.value}`)
|
||||
.join("\n"),
|
||||
},
|
||||
Cells: ({ data }) => (
|
||||
<>
|
||||
<td>{data.id}</td>
|
||||
<TitleCell game={game} song={data} showAltTitles showSearchTerms />
|
||||
<ObjCell data={data.data} />
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,50 +1,263 @@
|
||||
import { ChangeOpacity } from "util/color-opacity";
|
||||
import { ExtractGameFromFile } from "util/misc";
|
||||
import { MakeDataset } from "util/seeds";
|
||||
import React, { useMemo } from "react";
|
||||
import { AllDatabaseSeeds, Game } from "tachi-common";
|
||||
import SeedsBMSCourseLookupTable from "./SeedsBMSCourseLookupTable";
|
||||
import SeedsChartsTable from "./SeedsChartsTable";
|
||||
import SeedsFolderTable from "./SeedsFolderTable";
|
||||
import SeedsSongsTable from "./SeedsSongsTable";
|
||||
import SeedsTableTable from "./SeedsTableTable";
|
||||
import {
|
||||
DBSeedsDiff,
|
||||
DBSeedsDiffDeleted,
|
||||
DBSeedsDiffModified,
|
||||
DBSeedsDiffNew,
|
||||
MakeDataset,
|
||||
} from "util/seeds";
|
||||
import Divider from "components/util/Divider";
|
||||
import SelectButton from "components/util/SelectButton";
|
||||
import { ColourConfig } from "lib/config";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Col, Row } from "react-bootstrap";
|
||||
import { AllDatabaseSeeds } from "tachi-common";
|
||||
import { ChangeIndicator, DiffSeedsCollection } from "types/seeds";
|
||||
import MakeSeedsDiffTable from "./MakeSeedsDiffTable";
|
||||
import MakeSeedsTable from "./MakeSeedsTable";
|
||||
import {
|
||||
SeedsBMSCourseLookupCells,
|
||||
SeedsBMSCourseLookupHeaders,
|
||||
SeedsBMSCourseLookupSearchFns,
|
||||
} from "./SeedsBMSCourseLookupTable";
|
||||
import { SeedsTableCells, SeedsTableHeaders, SeedsTableSearchFns } from "./SeedsTableTable";
|
||||
import { SeedsFolderCells, SeedsFolderHeaders, SeedsFolderSearchFns } from "./SeedsFolderTable";
|
||||
import { MakeSeedsChartsControls } from "./SeedsChartsTable";
|
||||
import { MakeSeedsSongsControls } from "./SeedsSongsTable";
|
||||
|
||||
export default function SeedsTable({
|
||||
data,
|
||||
file,
|
||||
indicate = null,
|
||||
}: {
|
||||
data: Partial<AllDatabaseSeeds>;
|
||||
file: keyof AllDatabaseSeeds;
|
||||
indicate?: ChangeIndicator;
|
||||
}) {
|
||||
const dataset: any = useMemo(() => MakeDataset(file, data), [file, data]);
|
||||
|
||||
const tableControls = useMemo(() => GetTableControls(file), [file]);
|
||||
|
||||
if (!tableControls) {
|
||||
return <>This collection does not have render controls.</>;
|
||||
}
|
||||
|
||||
return <MakeSeedsTable {...(tableControls as any)} dataset={dataset} indicate={indicate} />;
|
||||
}
|
||||
|
||||
export function SeedsDiffTable<T extends keyof AllDatabaseSeeds>({
|
||||
baseData,
|
||||
headData,
|
||||
file,
|
||||
diffs,
|
||||
}: {
|
||||
baseData: Partial<AllDatabaseSeeds>;
|
||||
headData: Partial<AllDatabaseSeeds>;
|
||||
file: T;
|
||||
|
||||
// disgusting types
|
||||
diffs: DBSeedsDiff<AllDatabaseSeeds[T][0]>[];
|
||||
}) {
|
||||
// filter all these into their separate camps + disgusting type assertions
|
||||
const addedDiffs = diffs.filter((e) => e.type === "ADDED") as DBSeedsDiffNew<
|
||||
AllDatabaseSeeds[T][0]
|
||||
>[];
|
||||
const deletedDiffs = diffs.filter((e) => e.type === "DELETED") as DBSeedsDiffDeleted<
|
||||
AllDatabaseSeeds[T][0]
|
||||
>[];
|
||||
const modifiedDiffs = diffs.filter((e) => e.type === "MODIFIED") as DBSeedsDiffModified<
|
||||
AllDatabaseSeeds[T][0]
|
||||
>[];
|
||||
|
||||
// Ok, this is kind of some insane hackery, but follow with me on this one.
|
||||
// We "make" a dataset, but with the collection replaced with the documents
|
||||
// affected in the diff, in this way, we only create a dataset of - say -
|
||||
// three modified folder documents, instead of the whole collection.
|
||||
const addedData = useMemo(
|
||||
() => ({
|
||||
...headData,
|
||||
[file]: addedDiffs.map((e) => e.head),
|
||||
}),
|
||||
[addedDiffs, file, headData]
|
||||
);
|
||||
|
||||
// see above
|
||||
const deletedData = useMemo(
|
||||
() => ({
|
||||
...baseData,
|
||||
[file]: deletedDiffs.map((e) => e.base),
|
||||
}),
|
||||
[deletedDiffs, file, baseData]
|
||||
);
|
||||
|
||||
// Overwhelmingly complex.
|
||||
// We split our diffs into separate arrays HEAD_DATA[] and BASE_DATA[]
|
||||
// then we make datasets from those.
|
||||
// we then rejoin the datasets in the same position, such that
|
||||
// B, DIFF, H
|
||||
// becomes
|
||||
// DATASET(B), DIFF, DATASET(H)
|
||||
const modifiedData = useMemo(() => {
|
||||
const head = [];
|
||||
const base = [];
|
||||
|
||||
for (const diff of modifiedDiffs) {
|
||||
head.push(diff.head);
|
||||
base.push(diff.base);
|
||||
}
|
||||
|
||||
const headDSet = MakeDataset(file, {
|
||||
...headData,
|
||||
[file]: head,
|
||||
});
|
||||
const baseDSet = MakeDataset(file, {
|
||||
...baseData,
|
||||
[file]: base,
|
||||
});
|
||||
|
||||
const dataset: DiffSeedsCollection<any>[] = [];
|
||||
|
||||
for (let i = 0; i < modifiedDiffs.length; i++) {
|
||||
const diff = modifiedDiffs[i]!;
|
||||
const head = headDSet[i]!;
|
||||
const base = baseDSet[i];
|
||||
|
||||
dataset.push({
|
||||
head,
|
||||
base,
|
||||
diff: diff.diff,
|
||||
});
|
||||
}
|
||||
|
||||
return dataset;
|
||||
}, [baseData, headData, file, modifiedDiffs]);
|
||||
|
||||
// not handling the case where there's no diffs because that ain't possible
|
||||
const defaultView = modifiedDiffs.length ? "MODIFIED" : addedDiffs.length ? "ADDED" : "DELETED";
|
||||
|
||||
const [view, setView] = useState<"ADDED" | "DELETED" | "MODIFIED">(defaultView);
|
||||
|
||||
useEffect(() => {
|
||||
setView(modifiedDiffs.length ? "MODIFIED" : addedDiffs.length ? "ADDED" : "DELETED");
|
||||
}, [modifiedDiffs.length, addedDiffs.length]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<Col xs={12}>
|
||||
<div className="d-flex flex-wrap" style={{ justifyContent: "space-evenly" }}>
|
||||
{addedDiffs.length > 0 && (
|
||||
<SelectButton
|
||||
id="ADDED"
|
||||
value={view}
|
||||
setValue={setView}
|
||||
onStyle={{
|
||||
backgroundColor: ChangeOpacity(ColourConfig.primary, 0.6),
|
||||
}}
|
||||
>
|
||||
Added <span className="text-success">(+{addedDiffs.length})</span>
|
||||
</SelectButton>
|
||||
)}
|
||||
{modifiedDiffs.length > 0 && (
|
||||
<SelectButton
|
||||
id="MODIFIED"
|
||||
value={view}
|
||||
setValue={setView}
|
||||
onStyle={{
|
||||
backgroundColor: ChangeOpacity(ColourConfig.primary, 0.6),
|
||||
}}
|
||||
>
|
||||
Modified{" "}
|
||||
<span className="text-warning">(~{modifiedDiffs.length})</span>
|
||||
</SelectButton>
|
||||
)}
|
||||
{deletedDiffs.length > 0 && (
|
||||
<SelectButton
|
||||
id="DELETED"
|
||||
value={view}
|
||||
setValue={setView}
|
||||
onStyle={{
|
||||
backgroundColor: ChangeOpacity(ColourConfig.primary, 0.6),
|
||||
}}
|
||||
>
|
||||
Deleted{" "}
|
||||
<span className="text-danger">(-{deletedDiffs.length})</span>
|
||||
</SelectButton>
|
||||
)}
|
||||
</div>
|
||||
<Divider />
|
||||
</Col>
|
||||
|
||||
{view === "ADDED" ? (
|
||||
<SeedsTable data={addedData} file={file} indicate="ADDED" />
|
||||
) : view === "DELETED" ? (
|
||||
<SeedsTable data={deletedData} file={file} indicate="REMOVED" />
|
||||
) : (
|
||||
<InnerSeedsDifftable dataset={modifiedData} file={file} />
|
||||
)}
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InnerSeedsDifftable({
|
||||
dataset,
|
||||
file,
|
||||
}: {
|
||||
// any here is a funny hack because TS doesn't really have nice match statements
|
||||
// ah well.
|
||||
dataset: DiffSeedsCollection<any>[];
|
||||
file: keyof AllDatabaseSeeds;
|
||||
}) {
|
||||
const tableControls = useMemo(() => GetTableControls(file), [file]);
|
||||
|
||||
if (!tableControls) {
|
||||
return <>This collection does not have render controls.</>;
|
||||
}
|
||||
|
||||
return <MakeSeedsDiffTable {...(tableControls as any)} dataset={dataset} />;
|
||||
}
|
||||
|
||||
function GetTableControls(file: keyof AllDatabaseSeeds) {
|
||||
if (file.startsWith("songs-")) {
|
||||
const game = ExtractGameFromFile(file);
|
||||
|
||||
return <SeedsSongsTable dataset={dataset} game={game as Game} />;
|
||||
return {
|
||||
...MakeSeedsSongsControls(game),
|
||||
entryName: "Songs",
|
||||
};
|
||||
} else if (file.startsWith("charts-")) {
|
||||
const game = ExtractGameFromFile(file);
|
||||
|
||||
if (file === "charts-iidx.json") {
|
||||
return (
|
||||
<>
|
||||
<span>
|
||||
Tip: Use a filter of <code>level:!0</code> to filter out 2DXTra charts.
|
||||
</span>
|
||||
<SeedsChartsTable dataset={dataset} game={game as Game} />;
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <SeedsChartsTable dataset={dataset} game={game as Game} />;
|
||||
return {
|
||||
...MakeSeedsChartsControls(game),
|
||||
entryName: "Charts",
|
||||
};
|
||||
}
|
||||
|
||||
if (file === "bms-course-lookup.json") {
|
||||
return <SeedsBMSCourseLookupTable dataset={dataset} />;
|
||||
return {
|
||||
Cells: SeedsBMSCourseLookupCells,
|
||||
headers: SeedsBMSCourseLookupHeaders,
|
||||
searchFns: SeedsBMSCourseLookupSearchFns,
|
||||
entryName: "BMS Courses",
|
||||
};
|
||||
} else if (file === "tables.json") {
|
||||
return <SeedsTableTable dataset={dataset} />;
|
||||
return {
|
||||
Cells: SeedsTableCells,
|
||||
headers: SeedsTableHeaders,
|
||||
searchFns: SeedsTableSearchFns,
|
||||
entryName: "Tables",
|
||||
};
|
||||
} else if (file === "folders.json") {
|
||||
return <SeedsFolderTable dataset={dataset} />;
|
||||
return {
|
||||
Cells: SeedsFolderCells,
|
||||
headers: SeedsFolderHeaders,
|
||||
searchFns: SeedsFolderSearchFns,
|
||||
entryName: "Folders",
|
||||
};
|
||||
}
|
||||
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,80 +1,67 @@
|
||||
import { StrSOV } from "util/sorts";
|
||||
import DebugContent from "components/util/DebugContent";
|
||||
import { SearchFunctions } from "util/ztable/search";
|
||||
import Muted from "components/util/Muted";
|
||||
import React from "react";
|
||||
import { Badge } from "react-bootstrap";
|
||||
import { FormatGame } from "tachi-common";
|
||||
import { TableWithRelated } from "types/seeds";
|
||||
import DropdownRow from "../components/DropdownRow";
|
||||
import TachiTable, { Header } from "../components/TachiTable";
|
||||
import { CellsRenderFN, TableWithRelated } from "types/seeds";
|
||||
import { Header } from "../components/TachiTable";
|
||||
|
||||
export default function SeedsTableTable({ dataset }: { dataset: TableWithRelated[] }) {
|
||||
const headers: Header<TableWithRelated>[] = [
|
||||
["ID", "ID", StrSOV((x) => x.tableID)],
|
||||
["Name", "Name", StrSOV((x) => x.title)],
|
||||
["GPT", "GPT", StrSOV((x) => `${x.game} ${x.playtype}`)],
|
||||
["Folders", "Folders", StrSOV((x) => x.title)],
|
||||
];
|
||||
export const SeedsTableHeaders: Header<TableWithRelated>[] = [
|
||||
["ID", "ID", StrSOV((x) => x.tableID)],
|
||||
["Name", "Name", StrSOV((x) => x.title)],
|
||||
["GPT", "GPT", StrSOV((x) => `${x.game} ${x.playtype}`)],
|
||||
["Folders", "Folders", StrSOV((x) => x.title)],
|
||||
];
|
||||
|
||||
return (
|
||||
<TachiTable
|
||||
dataset={dataset}
|
||||
headers={headers}
|
||||
entryName="Tables"
|
||||
searchFunctions={{
|
||||
title: (x) => x.title,
|
||||
tableID: (x) => x.tableID,
|
||||
inactive: (x) => x.inactive,
|
||||
default: (x) => x.default,
|
||||
description: (x) => x.description,
|
||||
game: (x) => x.game,
|
||||
playtype: (x) => x.playtype,
|
||||
gpt: (x) => FormatGame(x.game, x.playtype),
|
||||
folder: (x) =>
|
||||
Object.values(x.__related.folders)
|
||||
.filter((e) => e !== undefined)
|
||||
.map((e) => e!.title)
|
||||
.join("\n"),
|
||||
}}
|
||||
rowFunction={(x) => <Row data={x} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export const SeedsTableSearchFns: SearchFunctions<TableWithRelated> = {
|
||||
title: (x) => x.title,
|
||||
tableID: (x) => x.tableID,
|
||||
inactive: (x) => x.inactive,
|
||||
default: (x) => x.default,
|
||||
description: (x) => x.description,
|
||||
game: (x) => x.game,
|
||||
playtype: (x) => x.playtype,
|
||||
gpt: (x) => FormatGame(x.game, x.playtype),
|
||||
folder: (x) =>
|
||||
Object.values(x.__related.folders)
|
||||
.filter((e) => e !== undefined)
|
||||
.map((e) => e!.title)
|
||||
.join("\n"),
|
||||
};
|
||||
|
||||
function Row({ data }: { data: TableWithRelated }) {
|
||||
return (
|
||||
<DropdownRow dropdown={<DebugContent data={{ ...data, __related: undefined }} />}>
|
||||
<td>
|
||||
<code>{data.tableID}</code>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{data.title}</strong>
|
||||
<br />
|
||||
<Muted>{data.description}</Muted>
|
||||
{(data.inactive || data.default) && <br />}
|
||||
{data.default && <Badge variant="success">DEFAULT</Badge>}
|
||||
{data.inactive && <Badge variant="warning">INACTIVE</Badge>}
|
||||
</td>
|
||||
<td>{FormatGame(data.game, data.playtype)}</td>
|
||||
<td className="text-left">
|
||||
<div style={{ maxHeight: "200px", overflowY: "scroll" }}>
|
||||
{data.folders.map((e) => {
|
||||
const folder = data.__related.folders[e];
|
||||
export const SeedsTableCells: CellsRenderFN<TableWithRelated> = ({ data }) => (
|
||||
<>
|
||||
<td>
|
||||
<code>{data.tableID}</code>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{data.title}</strong>
|
||||
<br />
|
||||
<Muted>{data.description}</Muted>
|
||||
{(data.inactive || data.default) && <br />}
|
||||
{data.default && <Badge variant="success">DEFAULT</Badge>}
|
||||
{data.inactive && <Badge variant="warning">INACTIVE</Badge>}
|
||||
</td>
|
||||
<td>{FormatGame(data.game, data.playtype)}</td>
|
||||
<td className="text-left">
|
||||
<div style={{ maxHeight: "200px", overflowY: "scroll" }}>
|
||||
{data.folders.map((e) => {
|
||||
const folder = data.__related.folders[e];
|
||||
|
||||
return (
|
||||
<div key={e}>
|
||||
{folder ? (
|
||||
<span>
|
||||
{folder.title} ({folder.playtype})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-danger">UNKNOWN FOLDER {e}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
</DropdownRow>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={e}>
|
||||
{folder ? (
|
||||
<span>
|
||||
{folder.title} ({folder.playtype})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-danger">UNKNOWN FOLDER {e}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,10 @@ export default function SelectButton<T>({
|
||||
onVariant = "primary",
|
||||
offVariant = "outline-secondary",
|
||||
disabled = false,
|
||||
style,
|
||||
onStyle = {},
|
||||
offStyle = {},
|
||||
style = {},
|
||||
className = "",
|
||||
}: {
|
||||
id: T;
|
||||
value: T;
|
||||
@@ -20,13 +23,17 @@ export default function SelectButton<T>({
|
||||
offVariant?: ButtonVariant;
|
||||
disabled?: boolean;
|
||||
style?: CSSProperties;
|
||||
onStyle?: CSSProperties;
|
||||
offStyle?: CSSProperties;
|
||||
className?: string;
|
||||
} & JustChildren) {
|
||||
return (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
variant={id === value ? onVariant : offVariant}
|
||||
onClick={() => setValue(id)}
|
||||
style={style}
|
||||
style={id === value ? Object.assign(style, onStyle) : Object.assign(style, offStyle)}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface GitCommit {
|
||||
};
|
||||
message: string;
|
||||
};
|
||||
parents: Array<{ sha: string }>;
|
||||
}
|
||||
|
||||
export type Revision = { c: GitCommit; repo: string };
|
||||
|
||||
export type Branch = { name: string; sha: string };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { JSONAttributeDiff } from "util/misc";
|
||||
import {
|
||||
BMSCourseDocument,
|
||||
ChartDocument,
|
||||
@@ -106,3 +107,13 @@ export type DatabaseSeedsWithRelated = {
|
||||
"milestones.json": Array<MilestoneWithRelated>;
|
||||
} & SongSeedsWithRelated &
|
||||
ChartSeedsWithRelated;
|
||||
|
||||
export type ChangeIndicator = "ADDED" | "REMOVED" | "MODIFIED" | null;
|
||||
|
||||
export type DiffSeedsCollection<T> = {
|
||||
head: T;
|
||||
diff: JSONAttributeDiff[];
|
||||
base: T;
|
||||
};
|
||||
|
||||
export type CellsRenderFN<T> = (d: { data: T; compress?: boolean }) => JSX.Element;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SessionDocument,
|
||||
} from "tachi-common";
|
||||
import { Playtype } from "types/tachi";
|
||||
import fjsh from "fast-json-stable-hash";
|
||||
|
||||
export function RFA<T>(arr: T[]): T {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
@@ -426,3 +427,108 @@ export function ExtractGameFromFile(file: string): Game {
|
||||
|
||||
return game as Game;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an array, remove all duplicates in it.
|
||||
*/
|
||||
export function Dedupe<T>(arr: Array<T>): Array<T> {
|
||||
return [...new Set(arr)];
|
||||
}
|
||||
|
||||
export function IsRecord(maybeRec: unknown): maybeRec is Record<string, unknown> {
|
||||
return typeof maybeRec === "object" && maybeRec !== null && !Array.isArray(maybeRec);
|
||||
}
|
||||
|
||||
export type JSONAttributeDiff = {
|
||||
keychain: string[];
|
||||
beforeVal: unknown;
|
||||
afterVal: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given two JSON objects, diff them.
|
||||
*
|
||||
* This returns an array of keychains (stringpaths to the value)
|
||||
* and their state before and after. `undefined` implies that the property does not
|
||||
* exist anymore (or didn't exist).
|
||||
*
|
||||
* @note A and B **must** both be json OBJECTS or ARRAYS. They cannot be primitives.
|
||||
*/
|
||||
export function JSONCompare(before: object, after: object, keychain: string[] = []) {
|
||||
const diffs: JSONAttributeDiff[] = [];
|
||||
|
||||
const bKeys = Object.keys(before);
|
||||
const aKeys = Object.keys(after);
|
||||
const allKeys = Dedupe([...bKeys, ...aKeys]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
const newKeychain = [...keychain, key];
|
||||
|
||||
// @ts-expect-error JS ABUSE WARNING
|
||||
const bef = before[key];
|
||||
// @ts-expect-error JS ABUSE here
|
||||
// you can index arrays with strings as if they were numbers
|
||||
// this is because arrays are secretly objects.
|
||||
// lol!
|
||||
const aft = after[key];
|
||||
|
||||
// -- OBJECT CASES
|
||||
// if both sides are objects, recurse.
|
||||
if (IsRecord(bef) && IsRecord(aft)) {
|
||||
diffs.push(...JSONCompare(bef, aft, newKeychain));
|
||||
} else if (IsRecord(bef) && !IsRecord(aft)) {
|
||||
// if one is an object and the other isn't
|
||||
// compare against the empty object so we still get nice diffs
|
||||
// instead of blubber like key went from 1 to -> {foo: "bar"}
|
||||
|
||||
diffs.push(...JSONCompare(bef, {}, newKeychain));
|
||||
} else if (!IsRecord(bef) && IsRecord(aft)) {
|
||||
// converse of previous case
|
||||
diffs.push(...JSONCompare({}, aft, newKeychain));
|
||||
}
|
||||
// -- ARRAY CASES
|
||||
// we only need to handle the case of ARRAY->ARRAY.
|
||||
// otherwise, it's a guaranteed diff.
|
||||
else if (Array.isArray(bef) && Array.isArray(aft)) {
|
||||
if (fjsh.stringify(bef) !== fjsh.stringify(aft)) {
|
||||
diffs.push({
|
||||
afterVal: aft,
|
||||
beforeVal: bef,
|
||||
keychain: newKeychain,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -- PRIMITIVE CASES
|
||||
else if (Object.is(bef, aft)) {
|
||||
// are these two things the same? if so, don't do anything.
|
||||
continue;
|
||||
} else {
|
||||
// these things are different, it's a diff.
|
||||
diffs.push({
|
||||
keychain: newKeychain,
|
||||
beforeVal: bef,
|
||||
afterVal: aft,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
export function JoinJSX(elements: Array<JSX.Element>, joiner: JSX.Element): Array<JSX.Element> {
|
||||
const newArray: Array<JSX.Element> = [];
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i]!;
|
||||
|
||||
// if this is the last element, don't suffix with a joiner.
|
||||
if (i === elements.length - 1) {
|
||||
newArray.push(element);
|
||||
} else {
|
||||
newArray.push(element, joiner);
|
||||
}
|
||||
}
|
||||
|
||||
return newArray;
|
||||
}
|
||||
|
||||
+276
-1
@@ -1,19 +1,29 @@
|
||||
import { Header } from "components/tables/components/TachiTable";
|
||||
import { EmptyHeader } from "components/tables/headers/IndicatorHeader";
|
||||
import { ZTableSortFn } from "components/util/table/useZTable";
|
||||
import {
|
||||
AllDatabaseSeeds,
|
||||
BMSCourseDocument,
|
||||
ChartDocument,
|
||||
CreateSongMap,
|
||||
DatabaseSeedNames,
|
||||
FolderDocument,
|
||||
Game,
|
||||
GoalDocument,
|
||||
MilestoneDocument,
|
||||
MilestoneSetDocument,
|
||||
SongDocument,
|
||||
TableDocument,
|
||||
} from "tachi-common";
|
||||
import {
|
||||
BMSCourseWithRelated,
|
||||
ChartWithRelated,
|
||||
DatabaseSeedsWithRelated,
|
||||
DiffSeedsCollection as SeedsDiff,
|
||||
TableWithRelated,
|
||||
} from "types/seeds";
|
||||
import { APIFetchV1 } from "./api";
|
||||
import { Dedupe, JSONAttributeDiff, JSONCompare } from "./misc";
|
||||
import { ValueGetterOrHybrid } from "./ztable/search";
|
||||
|
||||
/**
|
||||
* Given a repo and a reference return the status of database-seeds/collections
|
||||
@@ -208,3 +218,268 @@ function RelateCharts(data: Partial<AllDatabaseSeeds>, file: string): ChartWithR
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export type DBSeedsCollection = AllDatabaseSeeds[keyof AllDatabaseSeeds][0];
|
||||
|
||||
export interface DBSeedsDiffModified<T extends DBSeedsCollection> {
|
||||
type: "MODIFIED";
|
||||
base: T;
|
||||
diff: JSONAttributeDiff[];
|
||||
head: T;
|
||||
}
|
||||
|
||||
export interface DBSeedsDiffNew<T extends DBSeedsCollection> {
|
||||
type: "ADDED";
|
||||
head: T;
|
||||
}
|
||||
|
||||
export interface DBSeedsDiffDeleted<T extends DBSeedsCollection> {
|
||||
type: "DELETED";
|
||||
base: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* A diff may be any of three states:
|
||||
*
|
||||
* ADDED -> This did not exist in base, but now exists in HEAD
|
||||
* DELETED -> This existed in base, but does not exist in HEAD
|
||||
* MODIFIED -> This exists in both base and HEAD, but does not have equivalent checksums.
|
||||
*/
|
||||
export type DBSeedsDiff<T extends DBSeedsCollection> =
|
||||
| DBSeedsDiffDeleted<T>
|
||||
| DBSeedsDiffNew<T>
|
||||
| DBSeedsDiffModified<T>;
|
||||
|
||||
export type DBSeedsDiffs = Partial<{
|
||||
[K in keyof AllDatabaseSeeds]: Array<DBSeedsDiff<DBSeedsCollection>>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Given two full states of seeds, Return all the changes in each collection.
|
||||
*
|
||||
*
|
||||
*/
|
||||
export function DiffSeeds(
|
||||
base: Partial<AllDatabaseSeeds>,
|
||||
head: Partial<AllDatabaseSeeds>
|
||||
): DBSeedsDiffs {
|
||||
const diffs: DBSeedsDiffs = {};
|
||||
|
||||
const baseCollections = Object.keys(base) as (keyof AllDatabaseSeeds)[];
|
||||
const headCollections = Object.keys(head) as (keyof AllDatabaseSeeds)[];
|
||||
|
||||
// Base and HEAD might add new/remove collections. This allows us to iterate
|
||||
// over the union of both sets
|
||||
const allCollections = Dedupe([...baseCollections, ...headCollections]);
|
||||
|
||||
for (const collection of allCollections) {
|
||||
const baseState = base[collection] as DBSeedsCollection[];
|
||||
const headState = head[collection] as DBSeedsCollection[];
|
||||
|
||||
const colDiff = DiffCollection(collection, baseState, headState);
|
||||
|
||||
diffs[collection] = colDiff;
|
||||
}
|
||||
|
||||
return diffs as DBSeedsDiffs;
|
||||
}
|
||||
|
||||
function DiffCollection<T extends DBSeedsCollection>(
|
||||
collection: keyof AllDatabaseSeeds,
|
||||
baseState: T[] | undefined,
|
||||
headState: T[] | undefined
|
||||
): Array<DBSeedsDiff<T>> {
|
||||
if (!headState && !baseState) {
|
||||
// this is a collection that exists,
|
||||
// but, neither head or base reports that it exists?
|
||||
// this should **never** happen, but we can recover.
|
||||
console.warn(
|
||||
`Collection ${collection} exists, but neither HEAD nor base have it as existing?`
|
||||
);
|
||||
|
||||
// return early with 0 diffs I guess? null to null is no diffs.
|
||||
return [];
|
||||
} else if (baseState && !headState) {
|
||||
// if this collection was deleted in head, then everything is a removal.
|
||||
|
||||
// so, typescript has a bit of a moment here where it thinks that an
|
||||
// Array (A) of type T mapped to A' means that T and T'
|
||||
// - although being the same union type -
|
||||
// might be different members of that union via the mapping.
|
||||
// it's right. I know it's right, but god damnit it's wrong.
|
||||
return baseState.map((e) => ({
|
||||
type: "DELETED",
|
||||
base: e as any,
|
||||
}));
|
||||
} else if (!baseState && headState) {
|
||||
// if this collection didn't exist in base, but exists in head
|
||||
// then everything is an addition.
|
||||
|
||||
return headState.map((e) => ({
|
||||
type: "ADDED",
|
||||
head: e as any,
|
||||
}));
|
||||
}
|
||||
|
||||
// the above fail cases mean that both headState and baseState must be defined
|
||||
// however, TS cannot infer this.
|
||||
if (!headState || !baseState) {
|
||||
throw new Error(
|
||||
`INVARIANT FAILED: headState or baseState was undefined. This is not possible at this point in the program due to basic boolean logic, and only exists to prevent a TS error. How did this *ever* happen?`
|
||||
);
|
||||
}
|
||||
|
||||
// otherwise. lets get diffing...
|
||||
const diffs: Array<DBSeedsDiff<T>> = [];
|
||||
|
||||
// create lookup tables on UNIQ(T) -> T for the entire set.
|
||||
const headMap = new Map<string, T>();
|
||||
for (const d of headState) {
|
||||
headMap.set(GetUniqID(collection, d), d);
|
||||
}
|
||||
|
||||
const baseMap = new Map<string, T>();
|
||||
for (const d of baseState) {
|
||||
baseMap.set(GetUniqID(collection, d), d);
|
||||
}
|
||||
|
||||
const allIds = Dedupe([...headMap.keys(), ...baseMap.keys()]);
|
||||
|
||||
for (const id of allIds) {
|
||||
const base = baseMap.get(id);
|
||||
const head = headMap.get(id);
|
||||
|
||||
// not possible. we check it anyway.
|
||||
if (!head && !base) {
|
||||
console.warn(`ID ${id} exists, but neither HEAD nor base have it as existing?`);
|
||||
continue;
|
||||
} else if (base && !head) {
|
||||
// not in head, but was in base.
|
||||
diffs.push({
|
||||
type: "DELETED",
|
||||
base,
|
||||
});
|
||||
} else if (head && !base) {
|
||||
// wasn't in base, but was in head
|
||||
diffs.push({
|
||||
type: "ADDED",
|
||||
head,
|
||||
});
|
||||
} else if (head && base) {
|
||||
// existed in both. We need to compare these two deeply.
|
||||
|
||||
// these things *are* different. time to find out what.
|
||||
const objDiffs = JSONCompare(base, head);
|
||||
|
||||
// shouldn't be possible, but we mightaswell handle it.
|
||||
if (objDiffs.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
diffs.push({
|
||||
type: "MODIFIED",
|
||||
diff: objDiffs,
|
||||
base,
|
||||
head,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a value and what collection it's from, return it's unique identifier --
|
||||
* so songID, chartID, etc.
|
||||
*
|
||||
* We do this to detect when things have been modified.
|
||||
*/
|
||||
function GetUniqID<K extends keyof AllDatabaseSeeds>(collection: K, value: AllDatabaseSeeds[K][0]) {
|
||||
if (collection.startsWith("songs-")) {
|
||||
return (value as SongDocument).id.toString();
|
||||
} else if (collection.startsWith("charts-")) {
|
||||
return (value as ChartDocument).chartID;
|
||||
}
|
||||
|
||||
const c = collection as NotSongsChartsSeeds;
|
||||
|
||||
switch (c) {
|
||||
case "bms-course-lookup.json": {
|
||||
const v = value as BMSCourseDocument;
|
||||
return `${v.set}-${v.playtype}-${v.value}`;
|
||||
}
|
||||
case "folders.json": {
|
||||
const v = value as FolderDocument;
|
||||
return v.folderID;
|
||||
}
|
||||
case "tables.json": {
|
||||
const v = value as TableDocument;
|
||||
return v.tableID;
|
||||
}
|
||||
case "goals.json": {
|
||||
const v = value as GoalDocument;
|
||||
return v.goalID;
|
||||
}
|
||||
case "milestones.json": {
|
||||
const v = value as MilestoneDocument;
|
||||
return v.milestoneID;
|
||||
}
|
||||
case "milestone-sets.json": {
|
||||
const v = value as MilestoneSetDocument;
|
||||
return v.setID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an array of headers upon T, return the headers for Diff<T>.
|
||||
*/
|
||||
export function HeadersToDiffHeaders<T>(headers: Header<T>[]): Header<SeedsDiff<T>>[] {
|
||||
const headHeaders = headers.map((e) => {
|
||||
const sortFn = e[2];
|
||||
|
||||
if (sortFn) {
|
||||
// turn the sort function into sort(a.head, b.head);
|
||||
const newSortFn: ZTableSortFn<SeedsDiff<T>> = (a, b) => sortFn(a.head, b.head);
|
||||
|
||||
return [e[0], e[1], newSortFn, e[3]];
|
||||
}
|
||||
|
||||
return e;
|
||||
}) as Header<SeedsDiff<T>>[];
|
||||
|
||||
const newHeaders: Header<SeedsDiff<T>>[] = [
|
||||
EmptyHeader, // diff indicator goes here
|
||||
...headHeaders, // new state
|
||||
["Diff", "Diff"], // raw diff showing
|
||||
];
|
||||
|
||||
return newHeaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a record of search functions, convert them into ${x}_new and ${x}_old functions.
|
||||
*/
|
||||
export function SearchFnsToDiffSearchFns<T>(
|
||||
searchFns: Record<string, ValueGetterOrHybrid<T>>
|
||||
): Record<string, ValueGetterOrHybrid<SeedsDiff<T>>> {
|
||||
const newSearches: Record<string, ValueGetterOrHybrid<SeedsDiff<T>>> = {};
|
||||
|
||||
for (const [key, fn] of Object.entries(searchFns)) {
|
||||
if (typeof fn === "function") {
|
||||
newSearches[`${key}_old`] = (x) => fn(x.base);
|
||||
newSearches[key] = (x) => fn(x.head);
|
||||
} else {
|
||||
newSearches[`${key}_old`] = {
|
||||
strToNum: fn.strToNum,
|
||||
valueGetter: (x) => fn.valueGetter(x.base),
|
||||
};
|
||||
newSearches[key] = {
|
||||
strToNum: fn.strToNum,
|
||||
valueGetter: (x) => fn.valueGetter(x.head),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return newSearches;
|
||||
}
|
||||
|
||||
@@ -194,6 +194,8 @@ export type ValueGetterOrHybrid<D> =
|
||||
}
|
||||
| ValueGetter<D>;
|
||||
|
||||
export type SearchFunctions<T> = Record<string, ValueGetterOrHybrid<T>>;
|
||||
|
||||
export function GetValueGetter<D>(v: ValueGetterOrHybrid<D>) {
|
||||
if (typeof v === "function") {
|
||||
return v;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[
|
||||
{
|
||||
"md5sums": "98fa5d9e8e9ddca1dc78ba15d231bd279a25380cede7740b2f61d03dc5480daf50a21694c6d36650104f07e5ecde5a316c77ca5053489a4857ed4f6d53ff0bda",
|
||||
"md5sums": "98fa5d9e8e9ddca1dc78ba15d231bd279a25380cede7740b2f61d03dc5480daf50a21694c6d36650104f07e5ecde5a016c77ca5053489a4857ed4f6d53ff0bda",
|
||||
"playtype": "14K",
|
||||
"set": "genocideDan",
|
||||
"title": "GENOSIDE 2018 DP段位認定 初段",
|
||||
"title": "GENOSIDE 2018 DP段位認定 初段 AMONG US",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+2
@@ -111,6 +111,7 @@ importers:
|
||||
dotenv-expand: 5.1.0
|
||||
esbuild-loader: 2.13.1
|
||||
eslint-plugin-react: ^7.30.1
|
||||
fast-json-stable-hash: ^1.0.3
|
||||
file-loader: 3.0.1
|
||||
formik: 2.2.9
|
||||
fs-extra: 7.0.1
|
||||
@@ -171,6 +172,7 @@ importers:
|
||||
deepmerge: 4.2.2
|
||||
dotenv: 6.2.0
|
||||
dotenv-expand: 5.1.0
|
||||
fast-json-stable-hash: 1.0.3
|
||||
file-loader: 3.0.1_webpack@4.41.0
|
||||
formik: 2.2.9_react@17.0.2
|
||||
fs-extra: 7.0.1
|
||||
|
||||
Reference in New Issue
Block a user