Preferred checkout variant

Add ability to prioritize the repository checkout variants. These are displayed sorted.

Co-authored-by: Eduard Heimbuch<eduard.heimbuch@cloudogu.com>
Pushed-by: Florian Scholdei<florian.scholdei@cloudogu.com>
Co-authored-by: Florian Scholdei<florian.scholdei@cloudogu.com>
Pushed-by: Eduard Heimbuch<eduard.heimbuch@cloudogu.com>
This commit is contained in:
Florian Scholdei
2023-11-15 13:27:48 +01:00
parent 5f53af440f
commit a8c32b10de
7 changed files with 85 additions and 78 deletions

View File

@@ -0,0 +1,2 @@
- type: added
description: Protocol priority order by user preferences

View File

@@ -34,6 +34,7 @@ import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.store.ConfigurationStore; import sonia.scm.store.ConfigurationStore;
import sonia.scm.store.ConfigurationStoreFactory; import sonia.scm.store.ConfigurationStoreFactory;
import javax.annotation.Nullable;
import javax.inject.Provider; import javax.inject.Provider;
import javax.validation.Valid; import javax.validation.Valid;
import javax.validation.constraints.NotNull; import javax.validation.constraints.NotNull;
@@ -143,7 +144,10 @@ public abstract class ConfigurationAdapterBase<DAO, DTO extends HalRepresentatio
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Path("") @Path("")
public DTO get(@Context UriInfo uriInfo) { public DTO get(@Context UriInfo uriInfo) {
getReadPermission().check(); PermissionCheck readPermission = getReadPermission();
if (readPermission != null) {
readPermission.check();
}
return daoToDtoMapper.mapDaoToDto(getConfiguration(), createDtoLinks()); return daoToDtoMapper.mapDaoToDto(getConfiguration(), createDtoLinks());
} }
@@ -163,14 +167,18 @@ public abstract class ConfigurationAdapterBase<DAO, DTO extends HalRepresentatio
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
@Path("") @Path("")
public void update(@NotNull @Valid DTO payload) { public void update(@NotNull @Valid DTO payload) {
getWritePermission().check(); PermissionCheck writePermission = getWritePermission();
if (writePermission != null) {
writePermission.check();
}
getConfigStore().set(dtoToDaoMapper.mapDtoToDao(payload)); getConfigStore().set(dtoToDaoMapper.mapDtoToDao(payload));
} }
private Links.Builder createDtoLinks() { private Links.Builder createDtoLinks() {
Links.Builder builder = Links.linkingTo(); Links.Builder builder = Links.linkingTo();
builder.single(Link.link("self", getReadLink())); builder.single(Link.link("self", getReadLink()));
if (getWritePermission().isPermitted()) { PermissionCheck writePermission = getWritePermission();
if (writePermission == null || writePermission.isPermitted()) {
builder.single(Link.link("update", getUpdateLink())); builder.single(Link.link("update", getUpdateLink()));
} }
@@ -189,15 +197,18 @@ public abstract class ConfigurationAdapterBase<DAO, DTO extends HalRepresentatio
@Override @Override
public final void enrich(HalEnricherContext context, HalAppender appender) { public final void enrich(HalEnricherContext context, HalAppender appender) {
if (getReadPermission().isPermitted()) { PermissionCheck readPermission = getReadPermission();
if (readPermission == null || readPermission.isPermitted()) {
appender.appendLink(getName(), getReadLink()); appender.appendLink(getName(), getReadLink());
} }
} }
@Nullable
protected PermissionCheck getReadPermission() { protected PermissionCheck getReadPermission() {
return ConfigurationPermissions.read(getName()); return ConfigurationPermissions.read(getName());
} }
@Nullable
protected PermissionCheck getWritePermission() { protected PermissionCheck getWritePermission() {
return ConfigurationPermissions.write(getName()); return ConfigurationPermissions.write(getName());
} }

View File

@@ -47,4 +47,14 @@ public interface ScmProtocol {
default boolean isAnonymousEnabled() { default boolean isAnonymousEnabled() {
return true; return true;
} }
/**
* Priority for frontend evaluation order
*
* @return priority number
* @since 2.48.0
*/
default int getPriority() {
return 100;
}
} }

View File

@@ -55,6 +55,11 @@ public abstract class HttpScmProtocol implements ScmProtocol {
return URI.create(basePath + "/").resolve(String.format("repo/%s/%s", HttpUtil.encode(repository.getNamespace()), HttpUtil.encode(repository.getName()))).toASCIIString(); return URI.create(basePath + "/").resolve(String.format("repo/%s/%s", HttpUtil.encode(repository.getNamespace()), HttpUtil.encode(repository.getName()))).toASCIIString();
} }
@Override
public int getPriority() {
return 150;
}
public final void serve(HttpServletRequest request, HttpServletResponse response, ServletConfig config) throws ServletException, IOException { public final void serve(HttpServletRequest request, HttpServletResponse response, ServletConfig config) throws ServletException, IOException {
serve(request, response, repository, config); serve(request, response, repository, config);
} }

View File

@@ -21,21 +21,20 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React from "react"; import React, { FC, useState } from "react";
import classNames from "classnames";
import styled from "styled-components"; import styled from "styled-components";
import { Repository, Link } from "@scm-manager/ui-types"; import { Repository, Link } from "@scm-manager/ui-types";
import { ButtonAddons, Button } from "@scm-manager/ui-components"; import { Button } from "@scm-manager/ui-buttons";
import CloneInformation from "./CloneInformation"; import CloneInformation from "./CloneInformation";
const Switcher = styled(ButtonAddons)` const TopRightContainer = styled.div`
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
`; `;
const SmallButton = styled(Button).attrs((props) => ({ const SmallButton = styled(Button)`
className: "is-small",
}))`
height: inherit; height: inherit;
`; `;
@@ -43,60 +42,25 @@ type Props = {
repository: Repository; repository: Repository;
}; };
type State = { type Protocol = Link & {
selected?: Link; profile: string;
}; };
function selectHttpOrFirst(repository: Repository) { function selectPreferredProtocolFirst(repository: Repository) {
const protocols = (repository._links["protocol"] as Link[]) || []; const protocols = (repository._links["protocol"] as Protocol[]) || [];
for (const protocol of protocols) { if (protocols.length === 0) {
if (protocol.name === "http") {
return protocol;
}
}
if (protocols.length > 0) {
return protocols[0];
}
return undefined; return undefined;
} }
export default class ProtocolInformation extends React.Component<Props, State> { protocols.sort((a, b) => Number(b.profile) - Number(a.profile));
constructor(props: Props) { return protocols[0];
super(props);
this.state = {
selected: selectHttpOrFirst(props.repository),
};
} }
selectProtocol = (protocol: Link) => { const ProtocolInformation: FC<Props> = ({ repository }) => {
this.setState({ const [selected, setSelected] = useState<Protocol | undefined>(selectPreferredProtocolFirst(repository));
selected: protocol,
});
};
renderProtocolButton = (protocol: Link) => { const protocols = repository._links["protocol"] as Protocol[];
const name = protocol.name || "unknown";
let color;
const { selected } = this.state;
if (selected && protocol.name === selected.name) {
color = "link is-selected";
}
return (
<SmallButton color={color} action={() => this.selectProtocol(protocol)}>
{name.toUpperCase()}
</SmallButton>
);
};
render() {
const { repository } = this.props;
const protocols = repository._links["protocol"] as Link[];
if (!protocols || protocols.length === 0) { if (!protocols || protocols.length === 0) {
return null; return null;
} }
@@ -105,17 +69,32 @@ export default class ProtocolInformation extends React.Component<Props, State> {
return <CloneInformation url={protocols[0].href} repository={repository} />; return <CloneInformation url={protocols[0].href} repository={repository} />;
} }
const { selected } = this.state;
let cloneInformation = null; let cloneInformation = null;
if (selected) { if (selected) {
cloneInformation = <CloneInformation repository={repository} url={selected.href} />; cloneInformation = <CloneInformation url={selected?.href} repository={repository} />;
} }
return ( return (
<div className="content is-relative"> <div className="content is-relative">
<Switcher>{protocols.map(this.renderProtocolButton)}</Switcher> <TopRightContainer className="field has-addons">
{protocols.map((protocol, index) => {
return (
<div className="control" key={protocol.name || index}>
<SmallButton
className={classNames("is-small", {
"is-link is-selected": selected && protocol.name === selected.name,
})}
onClick={() => setSelected(protocol)}
>
{(protocol.name || "unknown").toUpperCase()}
</SmallButton>
</div>
);
})}
</TopRightContainer>
{cloneInformation} {cloneInformation}
</div> </div>
); );
} };
}
export default ProtocolInformation;

View File

@@ -215,6 +215,6 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit
} }
private Link createProtocolLink(ScmProtocol protocol) { private Link createProtocolLink(ScmProtocol protocol) {
return Link.linkBuilder("protocol", protocol.getUrl()).withName(protocol.getType()).build(); return Link.linkBuilder("protocol", protocol.getUrl()).withName(protocol.getType()).withProfile(String.valueOf(protocol.getPriority())).build();
} }
} }

View File

@@ -494,7 +494,7 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
assertEquals(SC_OK, response.getStatus()); assertEquals(SC_OK, response.getStatus());
assertTrue(response.getContentAsString().contains("\"protocol\":[{\"href\":\"http://\",\"name\":\"http\"},{\"href\":\"ssh://\",\"name\":\"ssh\"}]")); assertTrue(response.getContentAsString().contains("\"protocol\":[{\"href\":\"http://\",\"name\":\"http\",\"profile\":\"100\"},{\"href\":\"ssh://\",\"name\":\"ssh\",\"profile\":\"100\"}]"));
} }
@Test @Test