scm-ui: new repository layout

This commit is contained in:
Sebastian Sdorra
2019-10-07 10:57:09 +02:00
parent 09c7def874
commit c05798e254
417 changed files with 3620 additions and 52971 deletions

View File

@@ -0,0 +1,28 @@
//@flow
import React from "react";
import type { Repository } from "@scm-manager/ui-types";
import { NavLink } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
type Props = {
repository: Repository,
editUrl: string,
t: string => string
};
class EditRepoNavLink extends React.Component<Props> {
isEditable = () => {
return this.props.repository._links.update;
};
render() {
const { editUrl, t } = this.props;
if (!this.isEditable()) {
return null;
}
return <NavLink to={editUrl} label={t("repositoryRoot.menu.generalNavLink")} />;
}
}
export default translate("repos")(EditRepoNavLink);

View File

@@ -0,0 +1,38 @@
import React from "react";
import { shallow, mount } from "enzyme";
import "../../tests/enzyme";
import "../../tests/i18n";
import ReactRouterEnzymeContext from "react-router-enzyme-context";
import EditRepoNavLink from "./EditRepoNavLink";
describe("GeneralNavLink", () => {
const options = new ReactRouterEnzymeContext();
it("should render nothing, if the modify link is missing", () => {
const repository = {
_links: {}
};
const navLink = shallow(
<EditRepoNavLink repository={repository} editUrl="" />,
options.get()
);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const repository = {
_links: {
update: {
href: "/repositories"
}
}
};
const navLink = mount(
<EditRepoNavLink repository={repository} editUrl="" />,
options.get()
);
expect(navLink.text()).toBe("repositoryRoot.menu.generalNavLink");
});
});

View File

@@ -0,0 +1,28 @@
//@flow
import React from "react";
import { NavLink } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
import type { Repository } from "@scm-manager/ui-types";
type Props = {
permissionUrl: string,
t: string => string,
repository: Repository
};
class PermissionsNavLink extends React.Component<Props> {
hasPermissionsLink = () => {
return this.props.repository._links.permissions;
};
render() {
if (!this.hasPermissionsLink()) {
return null;
}
const { permissionUrl, t } = this.props;
return (
<NavLink to={permissionUrl} label={t("repositoryRoot.menu.permissionsNavLink")} />
);
}
}
export default translate("repos")(PermissionsNavLink);

View File

@@ -0,0 +1,38 @@
import React from "react";
import { mount, shallow } from "enzyme";
import "../../tests/enzyme";
import "../../tests/i18n";
import ReactRouterEnzymeContext from "react-router-enzyme-context";
import PermissionsNavLink from "./PermissionsNavLink";
describe("PermissionsNavLink", () => {
const options = new ReactRouterEnzymeContext();
it("should render nothing, if the modify link is missing", () => {
const repository = {
_links: {}
};
const navLink = shallow(
<PermissionsNavLink repository={repository} permissionUrl="" />,
options.get()
);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const repository = {
_links: {
permissions: {
href: "/permissions"
}
}
};
const navLink = mount(
<PermissionsNavLink repository={repository} permissionUrl="" />,
options.get()
);
expect(navLink.text()).toBe("repositoryRoot.menu.permissionsNavLink");
});
});

View File

@@ -0,0 +1,55 @@
//@flow
import React from "react";
import type { Repository } from "@scm-manager/ui-types";
import { MailLink, DateFromNow } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
type Props = {
repository: Repository,
// context props
t: string => string
};
class RepositoryDetailTable extends React.Component<Props> {
render() {
const { repository, t } = this.props;
return (
<table className="table">
<tbody>
<tr>
<th>{t("repository.name")}</th>
<td>{repository.name}</td>
</tr>
<tr>
<th>{t("repository.type")}</th>
<td>{repository.type}</td>
</tr>
<tr>
<th>{t("repository.contact")}</th>
<td>
<MailLink address={repository.contact} />
</td>
</tr>
<tr>
<th>{t("repository.description")}</th>
<td>{repository.description}</td>
</tr>
<tr>
<th>{t("repository.creationDate")}</th>
<td>
<DateFromNow date={repository.creationDate} />
</td>
</tr>
<tr>
<th>{t("repository.lastModified")}</th>
<td>
<DateFromNow date={repository.lastModified} />
</td>
</tr>
</tbody>
</table>
);
}
}
export default translate("repos")(RepositoryDetailTable);

View File

@@ -0,0 +1,30 @@
//@flow
import React from "react";
import type { Repository } from "@scm-manager/ui-types";
import RepositoryDetailTable from "./RepositoryDetailTable";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
type Props = {
repository: Repository
};
class RepositoryDetails extends React.Component<Props> {
render() {
const { repository } = this.props;
return (
<div>
<RepositoryDetailTable repository={repository}/>
<hr/>
<div className="content">
<ExtensionPoint
name="repos.repository-details.information"
renderAll={true}
props={{ repository }}
/>
</div>
</div>
);
}
}
export default RepositoryDetails;

View File

@@ -0,0 +1,30 @@
//@flow
import React from "react";
import type { Repository } from "@scm-manager/ui-types";
import { NavLink } from "@scm-manager/ui-components";
type Props = {
repository: Repository,
to: string,
label: string,
linkName: string,
activeWhenMatch?: (route: any) => boolean,
activeOnlyWhenExact: boolean
};
/**
* Component renders only if the repository contains the link with the given name.
*/
class RepositoryNavLink extends React.Component<Props> {
render() {
const { repository, linkName } = this.props;
if (!repository._links[linkName]) {
return null;
}
return <NavLink {...this.props} />;
}
}
export default RepositoryNavLink;

View File

@@ -0,0 +1,57 @@
// @flow
import React from "react";
import { shallow, mount } from "enzyme";
import "../../tests/enzyme";
import "../../tests/i18n";
import ReactRouterEnzymeContext from "react-router-enzyme-context";
import RepositoryNavLink from "./RepositoryNavLink";
describe("RepositoryNavLink", () => {
const options = new ReactRouterEnzymeContext();
it("should render nothing, if the sources link is missing", () => {
const repository = {
namespace: "Namespace",
name: "Repo",
type: "GIT",
_links: {}
};
const navLink = shallow(
<RepositoryNavLink
repository={repository}
linkName="sources"
to="/sources"
label="Sources"
activeOnlyWhenExact={true}
/>,
options.get()
);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const repository = {
namespace: "Namespace",
name: "Repo",
type: "GIT",
_links: {
sources: {
href: "/sources"
}
}
};
const navLink = mount(
<RepositoryNavLink
repository={repository}
linkName="sources"
to="/sources"
label="Sources"
activeOnlyWhenExact={true}
/>,
options.get()
);
expect(navLink.text()).toBe("Sources");
});
});

View File

@@ -0,0 +1,129 @@
//@flow
import React from "react";
import type { Changeset, Repository } from "@scm-manager/ui-types";
import { Interpolate, translate } from "react-i18next";
import injectSheet from "react-jss";
import {
DateFromNow,
ChangesetId,
ChangesetTag,
ChangesetAuthor,
ChangesetDiff,
AvatarWrapper,
AvatarImage,
changesets
} from "@scm-manager/ui-components";
import classNames from "classnames";
import type { Tag } from "@scm-manager/ui-types";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
const styles = {
spacing: {
marginRight: "1em"
},
tags: {
"& .tag": {
marginLeft: ".25rem"
}
}
};
type Props = {
changeset: Changeset,
repository: Repository,
t: string => string,
classes: any
};
class ChangesetDetails extends React.Component<Props> {
render() {
const { changeset, repository, classes } = this.props;
const description = changesets.parseDescription(changeset.description);
const id = (
<ChangesetId repository={repository} changeset={changeset} link={false} />
);
const date = <DateFromNow date={changeset.date} />;
return (
<div>
<div className="content">
<h4>
<ExtensionPoint
name="changeset.description"
props={{ changeset, value: description.title }}
renderAll={false}
>
{description.title}
</ExtensionPoint>
</h4>
<article className="media">
<AvatarWrapper>
<p className={classNames("image", "is-64x64", classes.spacing)}>
<AvatarImage person={changeset.author} />
</p>
</AvatarWrapper>
<div className="media-content">
<p>
<ChangesetAuthor changeset={changeset} />
</p>
<p>
<Interpolate
i18nKey="changeset.summary"
id={id}
time={date}
/>
</p>
</div>
<div className="media-right">{this.renderTags()}</div>
</article>
<p>
{description.message.split("\n").map((item, key) => {
return (
<span key={key}>
<ExtensionPoint
name="changeset.description"
props={{ changeset, value: item }}
renderAll={false}
>
{item}
</ExtensionPoint>
<br />
</span>
);
})}
</p>
</div>
<div>
<ChangesetDiff changeset={changeset} />
</div>
</div>
);
}
getTags = () => {
const { changeset } = this.props;
return changeset._embedded.tags || [];
};
renderTags = () => {
const { classes } = this.props;
const tags = this.getTags();
if (tags.length > 0) {
return (
<div className={classNames("level-item", classes.tags)}>
{tags.map((tag: Tag) => {
return <ChangesetTag key={tag.name} tag={tag} />;
})}
</div>
);
}
return null;
};
}
export default injectSheet(styles)(translate("repos")(ChangesetDetails));

View File

@@ -0,0 +1,238 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import {
Subtitle,
InputField,
Select,
SubmitButton,
Textarea
} from "@scm-manager/ui-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import type { Repository, RepositoryType } from "@scm-manager/ui-types";
import * as validator from "./repositoryValidation";
type Props = {
submitForm: Repository => void,
repository?: Repository,
repositoryTypes: RepositoryType[],
namespaceStrategy: string,
loading?: boolean,
t: string => string
};
type State = {
repository: Repository,
namespaceValidationError: boolean,
nameValidationError: boolean,
contactValidationError: boolean
};
const CUSTOM_NAMESPACE_STRATEGY = "CustomNamespaceStrategy";
class RepositoryForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
repository: {
name: "",
namespace: "",
type: "",
contact: "",
description: "",
_links: {}
},
namespaceValidationError: false,
nameValidationError: false,
contactValidationError: false
};
}
componentDidMount() {
const { repository } = this.props;
if (repository) {
this.setState({ repository: { ...repository } });
}
}
isFalsy(value) {
if (!value) {
return true;
}
return false;
}
isValid = () => {
const { namespaceStrategy } = this.props;
const { repository } = this.state;
return !(
this.state.namespaceValidationError ||
this.state.nameValidationError ||
this.state.contactValidationError ||
this.isFalsy(repository.name) ||
(namespaceStrategy === CUSTOM_NAMESPACE_STRATEGY &&
this.isFalsy(repository.namespace))
);
};
submit = (event: Event) => {
event.preventDefault();
if (this.isValid()) {
this.props.submitForm(this.state.repository);
}
};
isCreateMode = () => {
return !this.props.repository;
};
isModifiable = () => {
return !!this.props.repository && !!this.props.repository._links.update;
};
render() {
const { loading, t } = this.props;
const repository = this.state.repository;
const disabled = !this.isModifiable() && !this.isCreateMode();
const submitButton = disabled ? null : (
<SubmitButton
disabled={!this.isValid()}
loading={loading}
label={t("repositoryForm.submit")}
/>
);
let subtitle = null;
if (this.props.repository) {
// edit existing repo
subtitle = <Subtitle subtitle={t("repositoryForm.subtitle")} />;
}
return (
<>
{subtitle}
<form onSubmit={this.submit}>
{this.renderCreateOnlyFields()}
<InputField
label={t("repository.contact")}
onChange={this.handleContactChange}
value={repository ? repository.contact : ""}
validationError={this.state.contactValidationError}
errorMessage={t("validation.contact-invalid")}
helpText={t("help.contactHelpText")}
disabled={disabled}
/>
<Textarea
label={t("repository.description")}
onChange={this.handleDescriptionChange}
value={repository ? repository.description : ""}
helpText={t("help.descriptionHelpText")}
disabled={disabled}
/>
{submitButton}
</form>
</>
);
}
createSelectOptions(repositoryTypes: RepositoryType[]) {
return repositoryTypes.map(repositoryType => {
return {
label: repositoryType.displayName,
value: repositoryType.name
};
});
}
renderNamespaceField = () => {
const { namespaceStrategy, t } = this.props;
const repository = this.state.repository;
const props = {
label: t("repository.namespace"),
helpText: t("help.namespaceHelpText"),
value: repository ? repository.namespace : "",
onChange: this.handleNamespaceChange,
errorMessage: t("validation.namespace-invalid"),
validationError: this.state.namespaceValidationError
};
if (namespaceStrategy === CUSTOM_NAMESPACE_STRATEGY) {
return <InputField {...props} />;
}
return (
<ExtensionPoint
name="repos.create.namespace"
props={props}
renderAll={false}
/>
);
};
renderCreateOnlyFields() {
if (!this.isCreateMode()) {
return null;
}
const { repositoryTypes, t } = this.props;
const repository = this.state.repository;
return (
<>
{this.renderNamespaceField()}
<InputField
label={t("repository.name")}
onChange={this.handleNameChange}
value={repository ? repository.name : ""}
validationError={this.state.nameValidationError}
errorMessage={t("validation.name-invalid")}
helpText={t("help.nameHelpText")}
/>
<Select
label={t("repository.type")}
onChange={this.handleTypeChange}
value={repository ? repository.type : ""}
options={this.createSelectOptions(repositoryTypes)}
helpText={t("help.typeHelpText")}
/>
</>
);
}
handleNamespaceChange = (namespace: string) => {
this.setState({
namespaceValidationError: !validator.isNameValid(namespace),
repository: { ...this.state.repository, namespace }
});
};
handleNameChange = (name: string) => {
this.setState({
nameValidationError: !validator.isNameValid(name),
repository: { ...this.state.repository, name }
});
};
handleTypeChange = (type: string) => {
this.setState({
repository: { ...this.state.repository, type }
});
};
handleContactChange = (contact: string) => {
this.setState({
contactValidationError: !validator.isContactValid(contact),
repository: { ...this.state.repository, contact }
});
};
handleDescriptionChange = (description: string) => {
this.setState({
repository: { ...this.state.repository, description }
});
};
}
export default translate("repos")(RepositoryForm);

View File

@@ -0,0 +1,2 @@
import RepositoryForm from "./RepositoryForm";
export default RepositoryForm;

View File

@@ -0,0 +1,12 @@
// @flow
import { validation } from "@scm-manager/ui-components";
const nameRegex = /(?!^\.\.$)(?!^\.$)(?!.*[\\\[\]])^[A-Za-z0-9\.][A-Za-z0-9\.\-_]*$/;
export const isNameValid = (name: string) => {
return nameRegex.test(name);
};
export function isContactValid(mail: string) {
return "" === mail || validation.isMailValid(mail);
}

View File

@@ -0,0 +1,106 @@
import * as validator from "./repositoryValidation";
describe("repository name validation", () => {
// we don't need rich tests, because they are in validation.test.js
it("should validate the name", () => {
expect(validator.isNameValid("scm-manager")).toBe(true);
});
it("should fail for old nested repository names", () => {
// in v2 this is not allowed
expect(validator.isNameValid("scm/manager")).toBe(false);
expect(validator.isNameValid("scm/ma/nager")).toBe(false);
});
it("should allow same names as the backend", () => {
const validPaths = [
"scm",
"s",
"sc",
".hiddenrepo",
"b.",
"...",
"..c",
"d..",
"a..c"
];
validPaths.forEach((path) =>
expect(validator.isNameValid(path)).toBe(true)
);
});
it("should deny same names as the backend", () => {
const invalidPaths = [
".",
"/",
"//",
"..",
"/.",
"/..",
"./",
"../",
"/../",
"/./",
"/...",
"/abc",
".../",
"/sdf/",
"asdf/",
"./b",
"scm/plugins/.",
"scm/../plugins",
"scm/main/",
"/scm/main/",
"scm/./main",
"scm//main",
"scm\\main",
"scm/main-$HOME",
"scm/main-${HOME}-home",
"scm/main-%HOME-home",
"scm/main-%HOME%-home",
"abc$abc",
"abc%abc",
"abc<abc",
"abc>abc",
"abc#abc",
"abc+abc",
"abc{abc",
"abc}abc",
"abc(abc",
"abc)abc",
"abc[abc",
"abc]abc",
"abc|abc",
"scm/main",
"scm/plugins/git-plugin",
".scm/plugins",
"a/b..",
"a/..b",
"scm/main",
"scm/plugins/git-plugin",
"scm/plugins/git-plugin"
];
invalidPaths.forEach((path) =>
expect(validator.isNameValid(path)).toBe(false)
);
});
});
describe("repository contact validation", () => {
it("should allow empty contact", () => {
expect(validator.isContactValid("")).toBe(true);
});
// we don't need rich tests, because they are in validation.test.js
it("should allow real mail addresses", () => {
expect(validator.isContactValid("trici.mcmillian@hitchhiker.com")).toBe(
true
);
});
it("should fail on invalid mail addresses", () => {
expect(validator.isContactValid("tricia")).toBe(false);
});
});

View File

@@ -0,0 +1,24 @@
//@flow
import React from "react";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import type { Repository } from "@scm-manager/ui-types";
import { Image } from "@scm-manager/ui-components";
type Props = {
repository: Repository
};
class RepositoryAvatar extends React.Component<Props> {
render() {
const { repository } = this.props;
return (
<p className="image is-64x64">
<ExtensionPoint name="repos.repository-avatar" props={{ repository }}>
<Image src="/images/blib.jpg" alt="Logo" />
</ExtensionPoint>
</p>
);
}
}
export default RepositoryAvatar;

View File

@@ -0,0 +1,102 @@
//@flow
import React from "react";
import type { Repository } from "@scm-manager/ui-types";
import { CardColumn, DateFromNow } from "@scm-manager/ui-components";
import RepositoryEntryLink from "./RepositoryEntryLink";
import RepositoryAvatar from "./RepositoryAvatar";
type Props = {
repository: Repository
};
class RepositoryEntry extends React.Component<Props> {
createLink = (repository: Repository) => {
return `/repo/${repository.namespace}/${repository.name}`;
};
renderBranchesLink = (repository: Repository, repositoryLink: string) => {
if (repository._links["branches"]) {
return (
<RepositoryEntryLink
iconClass="fas fa-code-branch fa-lg"
to={repositoryLink + "/branches"}
/>
);
}
return null;
};
renderChangesetsLink = (repository: Repository, repositoryLink: string) => {
if (repository._links["changesets"]) {
return (
<RepositoryEntryLink
iconClass="fas fa-exchange-alt fa-lg"
to={repositoryLink + "/changesets"}
/>
);
}
return null;
};
renderSourcesLink = (repository: Repository, repositoryLink: string) => {
if (repository._links["sources"]) {
return (
<RepositoryEntryLink
iconClass="fa-code fa-lg"
to={repositoryLink + "/sources"}
/>
);
}
return null;
};
renderModifyLink = (repository: Repository, repositoryLink: string) => {
if (repository._links["update"]) {
return (
<RepositoryEntryLink
iconClass="fa-cog fa-lg"
to={repositoryLink + "/settings/general"}
/>
);
}
return null;
};
createFooterLeft = (repository: Repository, repositoryLink: string) => {
return (
<>
{this.renderBranchesLink(repository, repositoryLink)}
{this.renderChangesetsLink(repository, repositoryLink)}
{this.renderSourcesLink(repository, repositoryLink)}
{this.renderModifyLink(repository, repositoryLink)}
</>
);
};
createFooterRight = (repository: Repository) => {
return (
<small className="level-item">
<DateFromNow date={repository.creationDate} />
</small>
);
};
render() {
const { repository } = this.props;
const repositoryLink = this.createLink(repository);
const footerLeft = this.createFooterLeft(repository, repositoryLink);
const footerRight = this.createFooterRight(repository);
return (
<CardColumn
avatar={<RepositoryAvatar repository={repository} />}
title={repository.name}
description={repository.description}
link={repositoryLink}
footerLeft={footerLeft}
footerRight={footerRight}
/>
);
}
}
export default RepositoryEntry;

View File

@@ -0,0 +1,35 @@
//@flow
import React from "react";
import { Link } from "react-router-dom";
import injectSheet from "react-jss";
import classNames from "classnames";
const styles = {
link: {
pointerEvents: "all",
marginRight: "1.25rem !important"
}
};
type Props = {
to: string,
iconClass: string,
// context props
classes: any
};
class RepositoryEntryLink extends React.Component<Props> {
render() {
const { to, iconClass, classes } = this.props;
return (
<Link className={classNames("level-item", classes.link)} to={to}>
<span className="icon is-small">
<i className={classNames("fa", iconClass)} />
</span>
</Link>
);
}
}
export default injectSheet(styles)(RepositoryEntryLink);

View File

@@ -0,0 +1,21 @@
//@flow
import React from "react";
import { CardColumnGroup } from "@scm-manager/ui-components";
import type { RepositoryGroup } from "@scm-manager/ui-types";
import RepositoryEntry from "./RepositoryEntry";
type Props = {
group: RepositoryGroup
};
class RepositoryGroupEntry extends React.Component<Props> {
render() {
const { group } = this.props;
const entries = group.repositories.map((repository, index) => {
return <RepositoryEntry repository={repository} key={index} />;
});
return <CardColumnGroup name={group.name} elements={entries} />;
}
}
export default RepositoryGroupEntry;

View File

@@ -0,0 +1,28 @@
//@flow
import React from "react";
import type { Repository } from "@scm-manager/ui-types";
import groupByNamespace from "./groupByNamespace";
import RepositoryGroupEntry from "./RepositoryGroupEntry";
type Props = {
repositories: Repository[]
};
class RepositoryList extends React.Component<Props> {
render() {
const { repositories } = this.props;
const groups = groupByNamespace(repositories);
return (
<div className="content">
{groups.map(group => {
return <RepositoryGroupEntry group={group} key={group.name} />;
})}
</div>
);
}
}
export default RepositoryList;

View File

@@ -0,0 +1,39 @@
// @flow
import type { Repository, RepositoryGroup } from "@scm-manager/ui-types";
export default function groupByNamespace(
repositories: Repository[]
): RepositoryGroup[] {
let groups = {};
for (let repository of repositories) {
const groupName = repository.namespace;
let group = groups[groupName];
if (!group) {
group = {
name: groupName,
repositories: []
};
groups[groupName] = group;
}
group.repositories.push(repository);
}
let groupArray = [];
for (let groupName in groups) {
const group = groups[groupName];
group.repositories.sort(sortByName);
groupArray.push(groups[groupName]);
}
groupArray.sort(sortByName);
return groupArray;
}
function sortByName(a, b) {
if (a.name < b.name) {
return -1;
} else if (a.name > b.name) {
return 1;
}
return 0;
}

View File

@@ -0,0 +1,74 @@
// @flow
import groupByNamespace from "./groupByNamespace";
const base = {
type: "git",
_links: {}
};
const slartiBlueprintsFjords = {
...base,
namespace: "slarti",
name: "fjords-blueprints"
};
const slartiFjords = {
...base,
namespace: "slarti",
name: "fjords"
};
const hitchhikerRestand = {
...base,
namespace: "hitchhiker",
name: "restand"
};
const hitchhikerPuzzle42 = {
...base,
namespace: "hitchhiker",
name: "puzzle42"
};
const hitchhikerHeartOfGold = {
...base,
namespace: "hitchhiker",
name: "heartOfGold"
};
const zaphodMarvinFirmware = {
...base,
namespace: "zaphod",
name: "marvin-firmware"
};
it("should group the repositories by their namespace", () => {
const repositories = [
zaphodMarvinFirmware,
slartiBlueprintsFjords,
hitchhikerRestand,
slartiFjords,
hitchhikerHeartOfGold,
hitchhikerPuzzle42
];
const expected = [
{
name: "hitchhiker",
repositories: [
hitchhikerHeartOfGold,
hitchhikerPuzzle42,
hitchhikerRestand
]
},
{
name: "slarti",
repositories: [slartiFjords, slartiBlueprintsFjords]
},
{
name: "zaphod",
repositories: [zaphodMarvinFirmware]
}
];
expect(groupByNamespace(repositories)).toEqual(expected);
});

View File

@@ -0,0 +1,2 @@
import RepositoryList from "./RepositoryList";
export default RepositoryList;