mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-11 16:05:44 +01:00
scm-ui: new repository layout
This commit is contained in:
28
scm-ui/ui-webapp/src/repos/components/EditRepoNavLink.js
Normal file
28
scm-ui/ui-webapp/src/repos/components/EditRepoNavLink.js
Normal 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);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
28
scm-ui/ui-webapp/src/repos/components/PermissionsNavLink.js
Normal file
28
scm-ui/ui-webapp/src/repos/components/PermissionsNavLink.js
Normal 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);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
30
scm-ui/ui-webapp/src/repos/components/RepositoryDetails.js
Normal file
30
scm-ui/ui-webapp/src/repos/components/RepositoryDetails.js
Normal 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;
|
||||
30
scm-ui/ui-webapp/src/repos/components/RepositoryNavLink.js
Normal file
30
scm-ui/ui-webapp/src/repos/components/RepositoryNavLink.js
Normal 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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
238
scm-ui/ui-webapp/src/repos/components/form/RepositoryForm.js
Normal file
238
scm-ui/ui-webapp/src/repos/components/form/RepositoryForm.js
Normal 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);
|
||||
2
scm-ui/ui-webapp/src/repos/components/form/index.js
Normal file
2
scm-ui/ui-webapp/src/repos/components/form/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import RepositoryForm from "./RepositoryForm";
|
||||
export default RepositoryForm;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
102
scm-ui/ui-webapp/src/repos/components/list/RepositoryEntry.js
Normal file
102
scm-ui/ui-webapp/src/repos/components/list/RepositoryEntry.js
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
28
scm-ui/ui-webapp/src/repos/components/list/RepositoryList.js
Normal file
28
scm-ui/ui-webapp/src/repos/components/list/RepositoryList.js
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
2
scm-ui/ui-webapp/src/repos/components/list/index.js
Normal file
2
scm-ui/ui-webapp/src/repos/components/list/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import RepositoryList from "./RepositoryList";
|
||||
export default RepositoryList;
|
||||
Reference in New Issue
Block a user