Remove the repository select from explorer/ (#988)

Historically, a single cred explorer instance could load many different
repositories. This turned out to be an anti-feature: we'd rather have a
particular url hardlink to exploring the cred for a particular project.

This commit removes the repository select from the explorer, and instead
mandates that the explorer always has the RepoId passed down from above.
Besides providing a better UX, this also greatly simplifies the logic
for the explorer, since we no longer have an "initializing state" that
doesn't have any RepoId.

This builds on the work in #984, and swaps out the old "prototype" page
(which has been rendered non-functional by this change) for the new
"prototypes" page. Note that it stays at the same route, so links to
sourcecred.io/prototype will continue to function.

Test plan: Ran `yarn test --full`, and verified that `yarn start`
produces a working site.
This commit is contained in:
Dandelion Mané 2018-11-01 16:10:01 -07:00 committed by GitHub
parent 738853cd02
commit 29065f44d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 30 additions and 803 deletions

View File

@ -7,23 +7,24 @@ import type {LocalStore} from "../webutil/localStore";
import CheckedLocalStore from "../webutil/checkedLocalStore"; import CheckedLocalStore from "../webutil/checkedLocalStore";
import BrowserLocalStore from "../webutil/browserLocalStore"; import BrowserLocalStore from "../webutil/browserLocalStore";
import Link from "../webutil/Link"; import Link from "../webutil/Link";
import type {RepoId} from "../core/repoId";
import {PagerankTable} from "./pagerankTable/Table"; import {PagerankTable} from "./pagerankTable/Table";
import type {WeightedTypes} from "../analysis/weights"; import type {WeightedTypes} from "../analysis/weights";
import {defaultWeightsForAdapterSet} from "./weights/weights"; import {defaultWeightsForAdapterSet} from "./weights/weights";
import RepositorySelect from "./RepositorySelect";
import {Prefix as GithubPrefix} from "../plugins/github/nodes"; import {Prefix as GithubPrefix} from "../plugins/github/nodes";
import { import {
createStateTransitionMachine, createStateTransitionMachine,
type AppState, type AppState,
type StateTransitionMachineInterface, type StateTransitionMachineInterface,
uninitializedState, initialState,
} from "./state"; } from "./state";
import {StaticAdapterSet} from "./adapters/adapterSet"; import {StaticAdapterSet} from "./adapters/adapterSet";
export class AppPage extends React.Component<{| export class AppPage extends React.Component<{|
+assets: Assets, +assets: Assets,
+adapters: StaticAdapterSet, +adapters: StaticAdapterSet,
+repoId: RepoId,
|}> { |}> {
static _LOCAL_STORE = new CheckedLocalStore( static _LOCAL_STORE = new CheckedLocalStore(
new BrowserLocalStore({ new BrowserLocalStore({
@ -36,6 +37,7 @@ export class AppPage extends React.Component<{|
const App = createApp(createStateTransitionMachine); const App = createApp(createStateTransitionMachine);
return ( return (
<App <App
repoId={this.props.repoId}
assets={this.props.assets} assets={this.props.assets}
adapters={this.props.adapters} adapters={this.props.adapters}
localStore={AppPage._LOCAL_STORE} localStore={AppPage._LOCAL_STORE}
@ -48,6 +50,7 @@ type Props = {|
+assets: Assets, +assets: Assets,
+localStore: LocalStore, +localStore: LocalStore,
+adapters: StaticAdapterSet, +adapters: StaticAdapterSet,
+repoId: RepoId,
|}; |};
type State = {| type State = {|
appState: AppState, appState: AppState,
@ -66,7 +69,7 @@ export function createApp(
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
appState: uninitializedState(), appState: initialState(this.props.repoId),
weightedTypes: defaultWeightsForAdapterSet(props.adapters), weightedTypes: defaultWeightsForAdapterSet(props.adapters),
}; };
this.stateTransitionMachine = createSTM( this.stateTransitionMachine = createSTM(
@ -76,7 +79,6 @@ export function createApp(
} }
render() { render() {
const {localStore} = this.props;
const {appState} = this.state; const {appState} = this.state;
let pagerankTable; let pagerankTable;
if (appState.type === "PAGERANK_EVALUATED") { if (appState.type === "PAGERANK_EVALUATED") {
@ -109,15 +111,6 @@ export function createApp(
feedback feedback
</Link> </Link>
</p> </p>
<div style={{marginBottom: 10}}>
<RepositorySelect
assets={this.props.assets}
localStore={localStore}
onChange={(repoId) =>
this.stateTransitionMachine.setRepoId(repoId)
}
/>
</div>
<button <button
disabled={ disabled={
appState.type === "UNINITIALIZED" || appState.type === "UNINITIALIZED" ||
@ -154,9 +147,6 @@ export class LoadingIndicator extends React.PureComponent<{|
export function loadingText(state: AppState) { export function loadingText(state: AppState) {
switch (state.type) { switch (state.type) {
case "UNINITIALIZED": {
return "Initializing...";
}
case "READY_TO_LOAD_GRAPH": { case "READY_TO_LOAD_GRAPH": {
return { return {
LOADING: "Loading graph...", LOADING: "Loading graph...",

View File

@ -11,10 +11,8 @@ import {DynamicAdapterSet, StaticAdapterSet} from "./adapters/adapterSet";
import {FactorioStaticAdapter} from "../plugins/demo/appAdapter"; import {FactorioStaticAdapter} from "../plugins/demo/appAdapter";
import {defaultWeightsForAdapter} from "./weights/weights"; import {defaultWeightsForAdapter} from "./weights/weights";
import RepositorySelect from "./RepositorySelect";
import {PagerankTable} from "./pagerankTable/Table"; import {PagerankTable} from "./pagerankTable/Table";
import {createApp, LoadingIndicator} from "./App"; import {createApp, LoadingIndicator} from "./App";
import {uninitializedState} from "./state";
import {Prefix as GithubPrefix} from "../plugins/github/nodes"; import {Prefix as GithubPrefix} from "../plugins/github/nodes";
require("../webutil/testUtil").configureEnzyme(); require("../webutil/testUtil").configureEnzyme();
@ -22,7 +20,6 @@ require("../webutil/testUtil").configureEnzyme();
describe("explorer/App", () => { describe("explorer/App", () => {
function example() { function example() {
let setState, getState; let setState, getState;
const setRepoId = jest.fn();
const loadGraph = jest.fn(); const loadGraph = jest.fn();
const runPagerank = jest.fn(); const runPagerank = jest.fn();
const loadGraphAndRunPagerank = jest.fn(); const loadGraphAndRunPagerank = jest.fn();
@ -31,7 +28,6 @@ describe("explorer/App", () => {
setState = _setState; setState = _setState;
getState = _getState; getState = _getState;
return { return {
setRepoId,
loadGraph, loadGraph,
runPagerank, runPagerank,
loadGraphAndRunPagerank, loadGraphAndRunPagerank,
@ -43,6 +39,7 @@ describe("explorer/App", () => {
assets={new Assets("/foo/")} assets={new Assets("/foo/")}
adapters={new StaticAdapterSet([])} adapters={new StaticAdapterSet([])}
localStore={localStore} localStore={localStore}
repoId={makeRepoId("foo", "bar")}
/> />
); );
if (setState == null || getState == null) { if (setState == null || getState == null) {
@ -52,7 +49,6 @@ describe("explorer/App", () => {
el, el,
setState, setState,
getState, getState,
setRepoId,
loadGraph, loadGraph,
runPagerank, runPagerank,
loadGraphAndRunPagerank, loadGraphAndRunPagerank,
@ -62,7 +58,6 @@ describe("explorer/App", () => {
const emptyAdapters = new DynamicAdapterSet(new StaticAdapterSet([]), []); const emptyAdapters = new DynamicAdapterSet(new StaticAdapterSet([]), []);
const exampleStates = { const exampleStates = {
uninitialized: uninitializedState,
readyToLoadGraph: (loadingState) => { readyToLoadGraph: (loadingState) => {
return () => ({ return () => ({
type: "READY_TO_LOAD_GRAPH", type: "READY_TO_LOAD_GRAPH",
@ -95,8 +90,7 @@ describe("explorer/App", () => {
}); });
it("setState is wired properly", () => { it("setState is wired properly", () => {
const {setState, el} = example(); const {setState, el} = example();
expect(uninitializedState()).not.toBe(uninitializedState()); // sanity check const newState = exampleStates.readyToLoadGraph("LOADING")();
const newState = uninitializedState();
setState(newState); setState(newState);
expect(el.state().appState).toBe(newState); expect(el.state().appState).toBe(newState);
}); });
@ -118,18 +112,6 @@ describe("explorer/App", () => {
}); });
describe("when in state:", () => { describe("when in state:", () => {
function testRepositorySelect(stateFn) {
it("creates a working RepositorySelect", () => {
const {el, setRepoId, setState, localStore} = example();
setState(stateFn());
const rs = el.find(RepositorySelect);
const newRepoId = makeRepoId("zoo", "zod");
rs.props().onChange(newRepoId);
expect(setRepoId).toHaveBeenCalledWith(newRepoId);
expect(rs.props().localStore).toBe(localStore);
});
}
function testAnalyzeCredButton(stateFn, {disabled}) { function testAnalyzeCredButton(stateFn, {disabled}) {
const adjective = disabled ? "disabled" : "working"; const adjective = disabled ? "disabled" : "working";
it(`has a ${adjective} analyze cred button`, () => { it(`has a ${adjective} analyze cred button`, () => {
@ -203,17 +185,12 @@ describe("explorer/App", () => {
{analyzeCredDisabled, hasPagerankTable} {analyzeCredDisabled, hasPagerankTable}
) { ) {
describe(suiteName, () => { describe(suiteName, () => {
testRepositorySelect(stateFn);
testAnalyzeCredButton(stateFn, {disabled: analyzeCredDisabled}); testAnalyzeCredButton(stateFn, {disabled: analyzeCredDisabled});
testPagerankTable(stateFn, hasPagerankTable); testPagerankTable(stateFn, hasPagerankTable);
testLoadingIndicator(stateFn); testLoadingIndicator(stateFn);
}); });
} }
stateTestSuite("UNINITIALIZED", exampleStates.uninitialized, {
analyzeCredDisabled: true,
hasPagerankTable: false,
});
describe("READY_TO_LOAD_GRAPH", () => { describe("READY_TO_LOAD_GRAPH", () => {
for (const loadingState of ["LOADING", "NOT_LOADING", "FAILED"]) { for (const loadingState of ["LOADING", "NOT_LOADING", "FAILED"]) {
stateTestSuite( stateTestSuite(
@ -262,11 +239,6 @@ describe("explorer/App", () => {
expect(el.text()).toEqual(expectedText); expect(el.text()).toEqual(expectedText);
}); });
} }
testStatusText(
"initializing",
exampleStates.uninitialized,
"Initializing..."
);
testStatusText( testStatusText(
"ready to load graph", "ready to load graph",
exampleStates.readyToLoadGraph("NOT_LOADING"), exampleStates.readyToLoadGraph("NOT_LOADING"),

View File

@ -1,190 +0,0 @@
// @flow
import React, {type Node} from "react";
import sortBy from "lodash.sortby";
import deepEqual from "lodash.isequal";
import * as NullUtil from "../util/null";
import type {LocalStore} from "../webutil/localStore";
import type {Assets} from "../webutil/assets";
import {fromJSON, REPO_ID_REGISTRY_API} from "./repoIdRegistry";
import {type RepoId, stringToRepoId, repoIdToString} from "../core/repoId";
export const REPO_ID_KEY = "selectedRepository";
export type Status =
| {|+type: "LOADING"|}
| {|
+type: "VALID",
+availableRepoIds: $ReadOnlyArray<RepoId>,
+selectedRepoId: RepoId,
|}
| {|+type: "NO_REPOS"|}
| {|+type: "FAILURE"|};
type Props = {|
+assets: Assets,
+onChange: (x: RepoId) => void,
+localStore: LocalStore,
|};
type State = {|status: Status|};
export default class RepositorySelect extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
status: {type: "LOADING"},
};
}
componentDidMount() {
const {assets, localStore} = this.props;
loadStatus(assets, localStore).then((status) => {
this.setState({status});
if (status.type === "VALID") {
this.props.onChange(status.selectedRepoId);
}
});
}
onChange(selectedRepoId: RepoId) {
const status = this.state.status;
if (status.type === "VALID") {
const newStatus = {...status, selectedRepoId};
this.setState({status: newStatus});
}
this.props.onChange(selectedRepoId);
}
render() {
return (
<LocalStoreRepositorySelect
onChange={(selectedRepoId) => this.onChange(selectedRepoId)}
status={this.state.status}
localStore={this.props.localStore}
>
{({status, onChange}) => (
<PureRepositorySelect onChange={onChange} status={status} />
)}
</LocalStoreRepositorySelect>
);
}
}
export async function loadStatus(
assets: Assets,
localStore: LocalStore
): Promise<Status> {
try {
const response = await fetch(assets.resolve(REPO_ID_REGISTRY_API));
if (response.status === 404) {
return {type: "NO_REPOS"};
}
if (!response.ok) {
console.error(response);
return {type: "FAILURE"};
}
const json = await response.json();
const availableRepoIds = fromJSON(json);
if (availableRepoIds.length === 0) {
return {type: "NO_REPOS"};
}
const localStoreRepoId = localStore.get(REPO_ID_KEY, null);
const selectedRepoId = NullUtil.orElse(
availableRepoIds.find((x) => deepEqual(x, localStoreRepoId)),
availableRepoIds[availableRepoIds.length - 1]
);
const sortedRepoIds = sortBy(
availableRepoIds,
(r) => r.owner,
(r) => r.name
);
return {type: "VALID", availableRepoIds: sortedRepoIds, selectedRepoId};
} catch (e) {
console.error(e);
return {type: "FAILURE"};
}
}
export class LocalStoreRepositorySelect extends React.Component<{|
+status: Status,
+onChange: (repoId: RepoId) => void,
+localStore: LocalStore,
+children: ({
status: Status,
onChange: (selectedRepoId: RepoId) => void,
}) => Node,
|}> {
render() {
return this.props.children({
status: this.props.status,
onChange: (repoId) => {
this.props.onChange(repoId);
this.props.localStore.set(REPO_ID_KEY, repoId);
},
});
}
}
type PureRepositorySelectProps = {|
+onChange: (x: RepoId) => void,
+status: Status,
|};
export class PureRepositorySelect extends React.PureComponent<
PureRepositorySelectProps
> {
renderSelect(
availableRepoIds: $ReadOnlyArray<RepoId>,
selectedRepoId: ?RepoId
) {
return (
<label>
<span>Please choose a repository to inspect:</span>{" "}
{selectedRepoId != null && (
<select
value={repoIdToString(selectedRepoId)}
onChange={(e) => {
const repoId = stringToRepoId(e.target.value);
this.props.onChange(repoId);
}}
>
{availableRepoIds.map((repoId) => {
const repoIdString = repoIdToString(repoId);
return (
<option value={repoIdString} key={repoIdString}>
{repoIdString}
</option>
);
})}
</select>
)}
</label>
);
}
renderError(text: string) {
return <span style={{fontWeight: "bold", color: "red"}}>{text}</span>;
}
render() {
const {status} = this.props;
switch (status.type) {
case "LOADING":
// Just show an empty select while we wait.
return this.renderSelect([], null);
case "VALID":
return this.renderSelect(
status.availableRepoIds,
status.selectedRepoId
);
case "NO_REPOS":
return this.renderError("Error: No repositories found.");
case "FAILURE":
return this.renderError(
"Error: Unable to load repository registry. " +
"See console for details."
);
default:
throw new Error((status.type: empty));
}
}
}

View File

@ -1,411 +0,0 @@
// @flow
import React from "react";
import {shallow, mount} from "enzyme";
import * as NullUtil from "../util/null";
import testLocalStore from "../webutil/testLocalStore";
import RepositorySelect, {
PureRepositorySelect,
LocalStoreRepositorySelect,
loadStatus,
type Status,
REPO_ID_KEY,
} from "./RepositorySelect";
import {Assets} from "../webutil/assets";
import {
toJSON,
type RepoIdRegistry,
REPO_ID_REGISTRY_API,
} from "./repoIdRegistry";
import {makeRepoId} from "../core/repoId";
require("../webutil/testUtil").configureEnzyme();
require("../webutil/testUtil").configureAphrodite();
describe("explorer/RepositorySelect", () => {
beforeEach(() => {
fetch.resetMocks();
});
function mockRegistry(registry: RepoIdRegistry) {
fetch.mockResponseOnce(JSON.stringify(toJSON(registry)));
}
describe("PureRepositorySelect", () => {
it("doesn't render a select while loading", () => {
const e = shallow(
<PureRepositorySelect status={{type: "LOADING"}} onChange={jest.fn()} />
);
const span = e.find("span");
expect(span.text()).toBe("Please choose a repository to inspect:");
const select = e.find("select");
expect(select).toHaveLength(0);
});
it("renders an error message if no repositories are available", () => {
const e = shallow(
<PureRepositorySelect
status={{type: "NO_REPOS"}}
onChange={jest.fn()}
/>
);
const span = e.find("span");
expect(span.text()).toBe("Error: No repositories found.");
});
it("renders an error message if there was an error while loading", () => {
const e = shallow(
<PureRepositorySelect status={{type: "FAILURE"}} onChange={jest.fn()} />
);
const span = e.find("span");
expect(span.text()).toBe(
"Error: Unable to load repository registry. See console for details."
);
});
it("renders a select with all available repoIds as options", () => {
const availableRepoIds = [
makeRepoId("foo", "bar"),
makeRepoId("zod", "zoink"),
];
const selectedRepoId = availableRepoIds[0];
const e = shallow(
<PureRepositorySelect
status={{type: "VALID", availableRepoIds, selectedRepoId}}
onChange={jest.fn()}
/>
);
const options = e.find("option");
expect(options.map((x) => x.text())).toEqual(["foo/bar", "zod/zoink"]);
});
it("the selectedRepoId is selected", () => {
const availableRepoIds = [
makeRepoId("foo", "bar"),
makeRepoId("zod", "zoink"),
];
const selectedRepoId = availableRepoIds[0];
const e = shallow(
<PureRepositorySelect
status={{type: "VALID", availableRepoIds, selectedRepoId}}
onChange={jest.fn()}
/>
);
expect(e.find("select").prop("value")).toBe("foo/bar");
});
it("clicking an option triggers the onChange", () => {
const availableRepoIds = [
makeRepoId("foo", "bar"),
makeRepoId("zod", "zoink"),
];
const onChange = jest.fn();
const e = shallow(
<PureRepositorySelect
status={{
type: "VALID",
availableRepoIds,
selectedRepoId: availableRepoIds[0],
}}
onChange={onChange}
/>
);
e.find("select").simulate("change", {target: {value: "zod/zoink"}});
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenLastCalledWith(availableRepoIds[1]);
});
});
describe("loadStatus", () => {
const assets = new Assets("/my/gateway/");
function expectLoadValidStatus(
localStore,
expectedAvailableRepoIds,
expectedSelectedRepoId
) {
const result = loadStatus(assets, localStore);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith("/my/gateway" + REPO_ID_REGISTRY_API);
expect.assertions(7);
return result.then((status) => {
expect(status.type).toBe("VALID");
if (status.type !== "VALID") {
throw new Error("Impossible");
}
expect(status.availableRepoIds).toEqual(expectedAvailableRepoIds);
expect(status.selectedRepoId).toEqual(expectedSelectedRepoId);
});
}
it("calls fetch and handles a simple success", () => {
const repoId = makeRepoId("foo", "bar");
mockRegistry([repoId]);
return expectLoadValidStatus(testLocalStore(), [repoId], repoId);
});
it("returns repoIds in sorted order, and selects the last repoId", () => {
const repoIds = [
makeRepoId("a", "b"),
makeRepoId("a", "z"),
makeRepoId("foo", "bar"),
];
const nonSortedRepoIds = [repoIds[2], repoIds[0], repoIds[1]];
mockRegistry(nonSortedRepoIds);
return expectLoadValidStatus(testLocalStore(), repoIds, repoIds[1]);
});
it("returns FAILURE on invalid fetch response", () => {
fetch.mockResponseOnce(JSON.stringify(["hello"]));
expect.assertions(4);
return loadStatus(assets, testLocalStore()).then((status) => {
expect(status).toEqual({type: "FAILURE"});
expect(console.error).toHaveBeenCalledTimes(1);
// $ExpectFlowError
console.error = jest.fn();
});
});
it("returns FAILURE on fetch failure", () => {
fetch.mockReject(new Error("some failure"));
expect.assertions(4);
return loadStatus(assets, testLocalStore()).then((status) => {
expect(status).toEqual({type: "FAILURE"});
expect(console.error).toHaveBeenCalledTimes(1);
// $ExpectFlowError
console.error = jest.fn();
});
});
it("returns NO_REPOS on fetch 404", () => {
fetch.mockResponseOnce("irrelevant", {status: 404});
expect.assertions(3);
return loadStatus(assets, testLocalStore()).then((status) => {
expect(status).toEqual({type: "NO_REPOS"});
});
});
it("loads selectedRepoId from localStore, if available", () => {
const repoIds = [
makeRepoId("a", "b"),
makeRepoId("a", "z"),
makeRepoId("foo", "bar"),
];
mockRegistry(repoIds);
const localStore = testLocalStore();
localStore.set(REPO_ID_KEY, {owner: "a", name: "z"});
return expectLoadValidStatus(localStore, repoIds, repoIds[1]);
});
it("ignores selectedRepoId from localStore, if not available", () => {
const repoIds = [
makeRepoId("a", "b"),
makeRepoId("a", "z"),
makeRepoId("foo", "bar"),
];
mockRegistry(repoIds);
const localStore = testLocalStore();
localStore.set(REPO_ID_KEY, {owner: "non", name: "existent"});
return expectLoadValidStatus(localStore, repoIds, repoIds[2]);
});
it("ignores malformed value in localStore", () => {
const repoIds = [
makeRepoId("a", "b"),
makeRepoId("a", "z"),
makeRepoId("foo", "bar"),
];
mockRegistry(repoIds);
const localStore = testLocalStore();
localStore.set(REPO_ID_KEY, 42);
return expectLoadValidStatus(localStore, repoIds, repoIds[2]);
});
});
describe("LocalStoreRepositorySelect", () => {
it("instantiates the child component", () => {
const status = {type: "LOADING"};
const onChange = jest.fn();
const e = shallow(
<LocalStoreRepositorySelect
onChange={onChange}
status={status}
localStore={testLocalStore()}
>
{({status, onChange}) => (
<PureRepositorySelect status={status} onChange={onChange} />
)}
</LocalStoreRepositorySelect>
);
const child = e.find("PureRepositorySelect");
expect(child.props().status).toEqual(status);
});
it("passes onChange result up to parent", () => {
const status = {type: "LOADING"};
const onChange = jest.fn();
let childOnChange;
shallow(
<LocalStoreRepositorySelect
onChange={onChange}
status={status}
localStore={testLocalStore()}
>
{({status, onChange}) => {
childOnChange = onChange;
return <PureRepositorySelect status={status} onChange={onChange} />;
}}
</LocalStoreRepositorySelect>
);
const repoId = {owner: "foo", name: "bar"};
NullUtil.get(childOnChange)(repoId);
expect(onChange).toHaveBeenCalledWith(repoId);
expect(onChange).toHaveBeenCalledTimes(1);
});
it("stores onChange result in localStore", () => {
const status = {type: "LOADING"};
const onChange = jest.fn();
const localStore = testLocalStore();
let childOnChange;
shallow(
<LocalStoreRepositorySelect
onChange={onChange}
status={status}
localStore={localStore}
>
{({status, onChange}) => {
childOnChange = onChange;
return <PureRepositorySelect status={status} onChange={onChange} />;
}}
</LocalStoreRepositorySelect>
);
const repoId = {owner: "foo", name: "bar"};
NullUtil.get(childOnChange)(repoId);
expect(localStore.get(REPO_ID_KEY)).toEqual(repoId);
});
});
describe("RepositorySelect", () => {
const assets = new Assets("/my/gateway/");
it("calls `loadStatus` with the proper assets", () => {
mockRegistry([makeRepoId("irrelevant", "unused")]);
shallow(
<RepositorySelect
assets={assets}
onChange={jest.fn()}
localStore={testLocalStore()}
/>
);
// A bit of overlap with tests for `loadStatus` directly---it'd be
// nicer to spy on `loadStatus`, but that's at module top level,
// so `RepositorySelect` closes over it directly.
expect(fetch).toHaveBeenCalledWith("/my/gateway" + REPO_ID_REGISTRY_API);
});
it("initially renders a LocalStoreRepositorySelect with status LOADING", () => {
mockRegistry([makeRepoId("irrelevant", "unused")]);
const e = shallow(
<RepositorySelect
assets={assets}
onChange={jest.fn()}
localStore={testLocalStore()}
/>
);
const child = e.find(LocalStoreRepositorySelect);
const status = child.props().status;
const onChange = jest.fn();
expect(status).toEqual({type: "LOADING"});
const grandChild = child.props().children({status, onChange});
expect(grandChild.type).toBe(PureRepositorySelect);
});
function waitForUpdate(enzymeWrapper) {
return new Promise((resolve) => {
setImmediate(() => {
enzymeWrapper.update();
resolve();
});
});
}
it("on successful load, sets the status on the child", async () => {
const onChange = jest.fn();
const selectedRepoId = makeRepoId("foo", "bar");
mockRegistry([selectedRepoId]);
const e = shallow(
<RepositorySelect
assets={assets}
onChange={onChange}
localStore={testLocalStore()}
/>
);
await waitForUpdate(e);
const childStatus = e.props().status;
const availableRepoIds = [selectedRepoId];
expect(childStatus).toEqual({
type: "VALID",
selectedRepoId,
availableRepoIds,
});
});
it("on successful load, passes the status to the onChange", async () => {
const onChange = jest.fn();
const repoId = makeRepoId("foo", "bar");
mockRegistry([repoId]);
const e = shallow(
<RepositorySelect
assets={assets}
onChange={onChange}
localStore={testLocalStore()}
/>
);
await waitForUpdate(e);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(repoId);
});
it("on failed load, onChange not called", async () => {
const onChange = jest.fn();
fetch.mockReject(new Error("something bad"));
const e = shallow(
<RepositorySelect
assets={assets}
onChange={onChange}
localStore={testLocalStore()}
/>
);
await waitForUpdate(e);
expect(onChange).toHaveBeenCalledTimes(0);
expect(console.error).toHaveBeenCalledTimes(1);
// $ExpectFlowError
console.error = jest.fn();
});
it("child onChange triggers parent onChange", () => {
const onChange = jest.fn();
const repoId = makeRepoId("foo", "bar");
mockRegistry([repoId]);
const e = mount(
<RepositorySelect
assets={assets}
onChange={onChange}
localStore={testLocalStore()}
/>
);
const child = e.find(PureRepositorySelect);
child.props().onChange(repoId);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(repoId);
});
it("selecting child option updates top-level state", async () => {
const onChange = jest.fn();
const repoIds = [makeRepoId("foo", "bar"), makeRepoId("z", "a")];
mockRegistry(repoIds);
const e = mount(
<RepositorySelect
assets={assets}
onChange={onChange}
localStore={testLocalStore()}
/>
);
await waitForUpdate(e);
const child = e.find(PureRepositorySelect);
child.props().onChange(repoIds[0]);
const status: Status = e.state().status;
expect(status.type).toEqual("VALID");
if (status.type !== "VALID") {
throw new Error("Impossible");
}
expect(status.selectedRepoId).toEqual(repoIds[0]);
});
});
});

View File

@ -26,14 +26,10 @@ import {weightsToEdgeEvaluator} from "../analysis/weightsToEdgeEvaluator";
export type LoadingState = "NOT_LOADING" | "LOADING" | "FAILED"; export type LoadingState = "NOT_LOADING" | "LOADING" | "FAILED";
export type AppState = export type AppState =
| Uninitialized
| ReadyToLoadGraph | ReadyToLoadGraph
| ReadyToRunPagerank | ReadyToRunPagerank
| PagerankEvaluated; | PagerankEvaluated;
export type Uninitialized = {|
+type: "UNINITIALIZED",
|};
export type ReadyToLoadGraph = {| export type ReadyToLoadGraph = {|
+type: "READY_TO_LOAD_GRAPH", +type: "READY_TO_LOAD_GRAPH",
+repoId: RepoId, +repoId: RepoId,
@ -53,6 +49,10 @@ export type PagerankEvaluated = {|
+loading: LoadingState, +loading: LoadingState,
|}; |};
export function initialState(repoId: RepoId): ReadyToLoadGraph {
return {type: "READY_TO_LOAD_GRAPH", repoId, loading: "NOT_LOADING"};
}
export function createStateTransitionMachine( export function createStateTransitionMachine(
getState: () => AppState, getState: () => AppState,
setState: (AppState) => void setState: (AppState) => void
@ -65,13 +65,8 @@ export function createStateTransitionMachine(
); );
} }
export function uninitializedState(): AppState {
return {type: "UNINITIALIZED"};
}
// Exported for testing purposes. // Exported for testing purposes.
export interface StateTransitionMachineInterface { export interface StateTransitionMachineInterface {
+setRepoId: (RepoId) => void;
+loadGraph: (Assets, StaticAdapterSet) => Promise<boolean>; +loadGraph: (Assets, StaticAdapterSet) => Promise<boolean>;
+runPagerank: (WeightedTypes, NodeAddressT) => Promise<void>; +runPagerank: (WeightedTypes, NodeAddressT) => Promise<void>;
+loadGraphAndRunPagerank: ( +loadGraphAndRunPagerank: (
@ -119,15 +114,6 @@ export class StateTransitionMachine implements StateTransitionMachineInterface {
this.pagerank = pagerank; this.pagerank = pagerank;
} }
setRepoId(repoId: RepoId) {
const newState: AppState = {
type: "READY_TO_LOAD_GRAPH",
repoId: repoId,
loading: "NOT_LOADING",
};
this.setState(newState);
}
/** Loads the graph, reports whether it was successful */ /** Loads the graph, reports whether it was successful */
async loadGraph( async loadGraph(
assets: Assets, assets: Assets,
@ -222,9 +208,6 @@ export class StateTransitionMachine implements StateTransitionMachineInterface {
) { ) {
const state = this.getState(); const state = this.getState();
const type = state.type; const type = state.type;
if (type === "UNINITIALIZED") {
throw new Error("Tried to load and run from incorrect state");
}
switch (type) { switch (type) {
case "READY_TO_LOAD_GRAPH": case "READY_TO_LOAD_GRAPH":
const loadedGraph = await this.loadGraph(assets, adapters); const loadedGraph = await this.loadGraph(assets, adapters);

View File

@ -2,7 +2,6 @@
import { import {
StateTransitionMachine, StateTransitionMachine,
uninitializedState,
type AppState, type AppState,
type GraphWithAdapters, type GraphWithAdapters,
} from "./state"; } from "./state";
@ -82,62 +81,12 @@ describe("explorer/state", () => {
return new Map(); return new Map();
} }
function loading(state: AppState) { function loading(state: AppState) {
if (state.type === "UNINITIALIZED") {
throw new Error("Tried to get invalid loading");
}
return state.loading; return state.loading;
} }
function getRepoId(state: AppState) {
if (state.type === "UNINITIALIZED") {
throw new Error("Tried to get invalid repoId");
}
return state.repoId;
}
describe("setRepoId", () => {
describe("in UNINITIALIZED", () => {
it("transitions to READY_TO_LOAD_GRAPH", () => {
const {getState, stm} = example(uninitializedState());
const repoId = makeRepoId("foo", "bar");
stm.setRepoId(repoId);
const state = getState();
expect(state.type).toBe("READY_TO_LOAD_GRAPH");
expect(getRepoId(state)).toEqual(repoId);
});
});
it("stays in READY_TO_LOAD_GRAPH with new repoId", () => {
const {getState, stm} = example(readyToLoadGraph());
const repoId = makeRepoId("zoink", "zod");
stm.setRepoId(repoId);
const state = getState();
expect(state.type).toBe("READY_TO_LOAD_GRAPH");
expect(getRepoId(state)).toEqual(repoId);
});
it("transitions READY_TO_RUN_PAGERANK to READY_TO_LOAD_GRAPH with new repoId", () => {
const {getState, stm} = example(readyToRunPagerank());
const repoId = makeRepoId("zoink", "zod");
stm.setRepoId(repoId);
const state = getState();
expect(state.type).toBe("READY_TO_LOAD_GRAPH");
expect(getRepoId(state)).toEqual(repoId);
});
it("transitions PAGERANK_EVALUATED to READY_TO_LOAD_GRAPH with new repoId", () => {
const {getState, stm} = example(pagerankEvaluated());
const repoId = makeRepoId("zoink", "zod");
stm.setRepoId(repoId);
const state = getState();
expect(state.type).toBe("READY_TO_LOAD_GRAPH");
expect(getRepoId(state)).toEqual(repoId);
});
});
describe("loadGraph", () => { describe("loadGraph", () => {
it("can only be called when READY_TO_LOAD_GRAPH", async () => { it("can only be called when READY_TO_LOAD_GRAPH", async () => {
const badStates = [ const badStates = [readyToRunPagerank(), pagerankEvaluated()];
uninitializedState(),
readyToRunPagerank(),
pagerankEvaluated(),
];
for (const b of badStates) { for (const b of badStates) {
const {stm} = example(b); const {stm} = example(b);
await expect( await expect(
@ -182,26 +131,6 @@ describe("explorer/state", () => {
} }
expect(state.graphWithAdapters).toBe(gwa); expect(state.graphWithAdapters).toBe(gwa);
}); });
it("does not transition if repoId transition happens first", async () => {
const {getState, stm, loadGraphMock} = example(readyToLoadGraph());
const swappedRepoId = makeRepoId("too", "fast");
loadGraphMock.mockImplementation(
() =>
new Promise((resolve) => {
stm.setRepoId(swappedRepoId);
resolve(graphWithAdapters());
})
);
const succeeded = await stm.loadGraph(
new Assets("/my/gateway/"),
new StaticAdapterSet([])
);
expect(succeeded).toBe(false);
const state = getState();
expect(loading(state)).toBe("NOT_LOADING");
expect(state.type).toBe("READY_TO_LOAD_GRAPH");
expect(getRepoId(state)).toEqual(swappedRepoId);
});
it("sets loading state FAILED on reject", async () => { it("sets loading state FAILED on reject", async () => {
const {getState, stm, loadGraphMock} = example(readyToLoadGraph()); const {getState, stm, loadGraphMock} = example(readyToLoadGraph());
const error = new Error("Oh no!"); const error = new Error("Oh no!");
@ -223,13 +152,11 @@ describe("explorer/state", () => {
describe("runPagerank", () => { describe("runPagerank", () => {
it("can only be called when READY_TO_RUN_PAGERANK or PAGERANK_EVALUATED", async () => { it("can only be called when READY_TO_RUN_PAGERANK or PAGERANK_EVALUATED", async () => {
const badStates = [uninitializedState(), readyToLoadGraph()]; const badState = readyToLoadGraph();
for (const b of badStates) { const {stm} = example(badState);
const {stm} = example(b); await expect(
await expect( stm.runPagerank(weightedTypes(), NodeAddress.empty)
stm.runPagerank(weightedTypes(), NodeAddress.empty) ).rejects.toThrow("incorrect state");
).rejects.toThrow("incorrect state");
}
}); });
it("can be run when READY_TO_RUN_PAGERANK or PAGERANK_EVALUATED", async () => { it("can be run when READY_TO_RUN_PAGERANK or PAGERANK_EVALUATED", async () => {
const goodStates = [readyToRunPagerank(), pagerankEvaluated()]; const goodStates = [readyToRunPagerank(), pagerankEvaluated()];
@ -259,22 +186,6 @@ describe("explorer/state", () => {
const args = pagerankMock.mock.calls[0]; const args = pagerankMock.mock.calls[0];
expect(args[2].totalScoreNodePrefix).toBe(foo); expect(args[2].totalScoreNodePrefix).toBe(foo);
}); });
it("does not transition if a repoId change happens first", async () => {
const {getState, stm, pagerankMock} = example(readyToRunPagerank());
const swappedRepoId = makeRepoId("too", "fast");
pagerankMock.mockImplementation(
() =>
new Promise((resolve) => {
stm.setRepoId(swappedRepoId);
resolve(graphWithAdapters());
})
);
await stm.runPagerank(weightedTypes(), NodeAddress.empty);
const state = getState();
expect(loading(state)).toBe("NOT_LOADING");
expect(state.type).toBe("READY_TO_LOAD_GRAPH");
expect(getRepoId(state)).toBe(swappedRepoId);
});
it("sets loading state FAILED on reject", async () => { it("sets loading state FAILED on reject", async () => {
const {getState, stm, pagerankMock} = example(readyToRunPagerank()); const {getState, stm, pagerankMock} = example(readyToRunPagerank());
const error = new Error("Oh no!"); const error = new Error("Oh no!");
@ -291,17 +202,6 @@ describe("explorer/state", () => {
}); });
describe("loadGraphAndRunPagerank", () => { describe("loadGraphAndRunPagerank", () => {
it("errors if called with uninitialized state", async () => {
const {stm} = example(uninitializedState());
await expect(
stm.loadGraphAndRunPagerank(
new Assets("gateway"),
new StaticAdapterSet([]),
weightedTypes(),
NodeAddress.empty
)
).rejects.toThrow("incorrect state");
});
it("when READY_TO_LOAD_GRAPH, loads graph then runs pagerank", async () => { it("when READY_TO_LOAD_GRAPH, loads graph then runs pagerank", async () => {
const {stm} = example(readyToLoadGraph()); const {stm} = example(readyToLoadGraph());
(stm: any).loadGraph = jest.fn(); (stm: any).loadGraph = jest.fn();

View File

@ -4,28 +4,14 @@ import React, {type ComponentType} from "react";
import type {RepoId} from "../core/repoId"; import type {RepoId} from "../core/repoId";
import type {Assets} from "../webutil/assets"; import type {Assets} from "../webutil/assets";
import HomepageExplorer from "./homepageExplorer";
export default function makeProjectPage( export default function makeProjectPage(
repoId: RepoId repoId: RepoId
): ComponentType<{|+assets: Assets|}> { ): ComponentType<{|+assets: Assets|}> {
return class ProjectPage extends React.Component<{|+assets: Assets|}> { return class ProjectPage extends React.Component<{|+assets: Assets|}> {
render() { render() {
return ( return <HomepageExplorer assets={this.props.assets} repoId={repoId} />;
<div
style={{
maxWidth: 900,
margin: "0 auto",
marginBottom: 200,
padding: "0 10px",
lineHeight: 1.5,
}}
>
<p>
<strong>TODO:</strong> Render an explorer for{" "}
{`${repoId.owner}/${repoId.name}`}
</p>.
</div>
);
} }
}; };
} }

View File

@ -8,6 +8,7 @@ import {StaticAppAdapter as GithubAdapter} from "../plugins/github/appAdapter";
import {StaticAppAdapter as GitAdapter} from "../plugins/git/appAdapter"; import {StaticAppAdapter as GitAdapter} from "../plugins/git/appAdapter";
import {GithubGitGateway} from "../plugins/github/githubGitGateway"; import {GithubGitGateway} from "../plugins/github/githubGitGateway";
import {AppPage} from "../explorer/App"; import {AppPage} from "../explorer/App";
import type {RepoId} from "../core/repoId";
function homepageStaticAdapters(): StaticAdapterSet { function homepageStaticAdapters(): StaticAdapterSet {
return new StaticAdapterSet([ return new StaticAdapterSet([
@ -18,10 +19,15 @@ function homepageStaticAdapters(): StaticAdapterSet {
export default class HomepageExplorer extends React.Component<{| export default class HomepageExplorer extends React.Component<{|
+assets: Assets, +assets: Assets,
+repoId: RepoId,
|}> { |}> {
render() { render() {
return ( return (
<AppPage assets={this.props.assets} adapters={homepageStaticAdapters()} /> <AppPage
assets={this.props.assets}
repoId={this.props.repoId}
adapters={homepageStaticAdapters()}
/>
); );
} }
} }

View File

@ -39,13 +39,13 @@ function makeRouteData(registry /*: RepoIdRegistry */) /*: RouteData */ {
navTitle: "Home", navTitle: "Home",
}, },
{ {
path: "/prototypes/", path: "/prototype/",
contents: { contents: {
type: "PAGE", type: "PAGE",
component: () => require("./PrototypesPage").default(registry), component: () => require("./PrototypesPage").default(registry),
}, },
title: "SourceCred prototypes", title: "SourceCred prototype",
navTitle: null, // for now navTitle: "Prototype",
}, },
...registry.map((repo) => ({ ...registry.map((repo) => ({
path: `/prototypes/${repo.owner}/${repo.name}/`, path: `/prototypes/${repo.owner}/${repo.name}/`,
@ -56,15 +56,6 @@ function makeRouteData(registry /*: RepoIdRegistry */) /*: RouteData */ {
title: `${repo.owner}/${repo.name} • SourceCred`, title: `${repo.owner}/${repo.name} • SourceCred`,
navTitle: null, navTitle: null,
})), })),
{
path: "/prototype/",
contents: {
type: "PAGE",
component: () => require("./homepageExplorer").default,
},
title: "SourceCred prototype",
navTitle: "Prototype",
},
{ {
path: "/discord-invite/", path: "/discord-invite/",
contents: { contents: {