mirror of https://github.com/halo-dev/halo
Add Issue extension and UC endpoint for listing user issues
parent
6497ceb4cf
commit
39ff2f1ed8
|
@ -0,0 +1,106 @@
|
||||||
|
package run.halo.app.core.extension.content;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.ToString;
|
||||||
|
import run.halo.app.extension.AbstractExtension;
|
||||||
|
import run.halo.app.extension.GVK;
|
||||||
|
import run.halo.app.extension.GroupVersionKind;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>Issue extension for user-reported issues/problems.</p>
|
||||||
|
*
|
||||||
|
* @author halo-copilot
|
||||||
|
* @since 2.21.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ToString(callSuper = true)
|
||||||
|
@GVK(group = Constant.GROUP, version = Constant.VERSION, kind = Issue.KIND,
|
||||||
|
plural = "issues", singular = "issue")
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class Issue extends AbstractExtension {
|
||||||
|
|
||||||
|
public static final String KIND = "Issue";
|
||||||
|
|
||||||
|
public static final GroupVersionKind GVK = GroupVersionKind.fromExtension(Issue.class);
|
||||||
|
|
||||||
|
public static final String DELETED_LABEL = "content.halo.run/deleted";
|
||||||
|
public static final String OWNER_LABEL = "content.halo.run/owner";
|
||||||
|
public static final String STATUS_LABEL = "content.halo.run/status";
|
||||||
|
|
||||||
|
@Schema(requiredMode = RequiredMode.REQUIRED)
|
||||||
|
private IssueSpec spec;
|
||||||
|
|
||||||
|
@Schema
|
||||||
|
private IssueStatus status;
|
||||||
|
|
||||||
|
public boolean isDeleted() {
|
||||||
|
return Objects.equals(true, spec.getDeleted())
|
||||||
|
|| getMetadata().getDeletionTimestamp() != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class IssueSpec {
|
||||||
|
@Schema(requiredMode = RequiredMode.REQUIRED, minLength = 1)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Schema(requiredMode = RequiredMode.REQUIRED)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Schema(requiredMode = RequiredMode.REQUIRED)
|
||||||
|
private String owner;
|
||||||
|
|
||||||
|
@Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "OPEN")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "LOW")
|
||||||
|
private String priority;
|
||||||
|
|
||||||
|
@Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "BUG")
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
@Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "false")
|
||||||
|
private Boolean deleted;
|
||||||
|
|
||||||
|
private Instant resolvedTime;
|
||||||
|
|
||||||
|
private String assignee;
|
||||||
|
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class StatusEnum {
|
||||||
|
public static final String OPEN = "OPEN";
|
||||||
|
public static final String IN_PROGRESS = "IN_PROGRESS";
|
||||||
|
public static final String RESOLVED = "RESOLVED";
|
||||||
|
public static final String CLOSED = "CLOSED";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class PriorityEnum {
|
||||||
|
public static final String LOW = "LOW";
|
||||||
|
public static final String MEDIUM = "MEDIUM";
|
||||||
|
public static final String HIGH = "HIGH";
|
||||||
|
public static final String CRITICAL = "CRITICAL";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class TypeEnum {
|
||||||
|
public static final String BUG = "BUG";
|
||||||
|
public static final String FEATURE_REQUEST = "FEATURE_REQUEST";
|
||||||
|
public static final String IMPROVEMENT = "IMPROVEMENT";
|
||||||
|
public static final String QUESTION = "QUESTION";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class IssueStatus {
|
||||||
|
private String phase;
|
||||||
|
private String observedVersion;
|
||||||
|
private Instant lastModifyTime;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,119 @@
|
||||||
|
package run.halo.app.content;
|
||||||
|
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
|
import run.halo.app.core.extension.content.Issue;
|
||||||
|
import run.halo.app.extension.ListOptions;
|
||||||
|
import run.halo.app.extension.PageRequestImpl;
|
||||||
|
import run.halo.app.extension.router.SortableRequest;
|
||||||
|
import run.halo.app.extension.router.selector.FieldSelector;
|
||||||
|
import run.halo.app.extension.router.selector.LabelSelector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query parameters for issues.
|
||||||
|
*
|
||||||
|
* @author halo-copilot
|
||||||
|
* @since 2.21.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class IssueQuery extends SortableRequest {
|
||||||
|
|
||||||
|
private final String username;
|
||||||
|
|
||||||
|
public IssueQuery(ServerRequest request) {
|
||||||
|
this(request, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IssueQuery(ServerRequest request, @Nullable String username) {
|
||||||
|
super(request.exchange());
|
||||||
|
this.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public String getStatus() {
|
||||||
|
return queryParams.getFirst("status");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public String getPriority() {
|
||||||
|
return queryParams.getFirst("priority");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public String getType() {
|
||||||
|
return queryParams.getFirst("type");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public String getKeyword() {
|
||||||
|
return queryParams.getFirst("keyword");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build predicate from query.
|
||||||
|
*/
|
||||||
|
public Predicate<Issue> toPredicate() {
|
||||||
|
Predicate<Issue> predicate = issue -> true;
|
||||||
|
|
||||||
|
if (username != null) {
|
||||||
|
predicate = predicate.and(issue -> username.equals(issue.getSpec().getOwner()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getStatus() != null) {
|
||||||
|
predicate = predicate.and(issue -> getStatus().equals(issue.getSpec().getStatus()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getPriority() != null) {
|
||||||
|
predicate = predicate.and(issue -> getPriority().equals(issue.getSpec().getPriority()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getType() != null) {
|
||||||
|
predicate = predicate.and(issue -> getType().equals(issue.getSpec().getType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getKeyword() != null && !getKeyword().trim().isEmpty()) {
|
||||||
|
predicate = predicate.and(issue ->
|
||||||
|
issue.getSpec().getTitle().toLowerCase().contains(getKeyword().toLowerCase()) ||
|
||||||
|
issue.getSpec().getDescription().toLowerCase().contains(getKeyword().toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return predicate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to ListOptions for querying.
|
||||||
|
*/
|
||||||
|
public ListOptions toListOptions() {
|
||||||
|
var listOptions = new ListOptions();
|
||||||
|
|
||||||
|
var labelSelectorBuilder = LabelSelector.builder();
|
||||||
|
|
||||||
|
// Always exclude deleted issues unless specified
|
||||||
|
labelSelectorBuilder.eq(Issue.DELETED_LABEL, "false");
|
||||||
|
|
||||||
|
if (username != null) {
|
||||||
|
labelSelectorBuilder.eq(Issue.OWNER_LABEL, username);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getStatus() != null) {
|
||||||
|
labelSelectorBuilder.eq(Issue.STATUS_LABEL, getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
listOptions.setLabelSelector(labelSelectorBuilder.build());
|
||||||
|
|
||||||
|
return listOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to PageRequest for pagination.
|
||||||
|
*/
|
||||||
|
public PageRequestImpl toPageRequest() {
|
||||||
|
return PageRequestImpl.of(getPage(), getSize(), getSort());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
package run.halo.app.content;
|
||||||
|
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.core.extension.content.Issue;
|
||||||
|
import run.halo.app.extension.ListResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for {@link Issue}.
|
||||||
|
*
|
||||||
|
* @author halo-copilot
|
||||||
|
* @since 2.21.0
|
||||||
|
*/
|
||||||
|
public interface IssueService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List issues by query.
|
||||||
|
*/
|
||||||
|
Mono<ListResult<ListedIssue>> listIssues(IssueQuery query);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create issue.
|
||||||
|
*/
|
||||||
|
Mono<Issue> createIssue(Issue issue);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update issue.
|
||||||
|
*/
|
||||||
|
Mono<Issue> updateIssue(Issue issue);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get issue by name and owner.
|
||||||
|
*/
|
||||||
|
Mono<Issue> getByUsername(String issueName, String username);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete issue by name and owner.
|
||||||
|
*/
|
||||||
|
Mono<Issue> deleteByUsername(String issueName, String username);
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
package run.halo.app.content;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.experimental.Accessors;
|
||||||
|
import run.halo.app.core.extension.content.Issue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listed issue for list result.
|
||||||
|
*
|
||||||
|
* @author halo-copilot
|
||||||
|
* @since 2.21.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Accessors(chain = true)
|
||||||
|
public class ListedIssue {
|
||||||
|
private Issue issue;
|
||||||
|
private Stats stats;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue statistic.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public static class Stats {
|
||||||
|
private Integer commentsCount = 0;
|
||||||
|
private Integer upvotesCount = 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,125 @@
|
||||||
|
package run.halo.app.content.impl;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.content.IssueQuery;
|
||||||
|
import run.halo.app.content.IssueService;
|
||||||
|
import run.halo.app.content.ListedIssue;
|
||||||
|
import run.halo.app.core.extension.content.Issue;
|
||||||
|
import run.halo.app.extension.ListOptions;
|
||||||
|
import run.halo.app.extension.ListResult;
|
||||||
|
import run.halo.app.extension.PageRequestImpl;
|
||||||
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
import run.halo.app.extension.router.selector.FieldSelector;
|
||||||
|
import run.halo.app.extension.router.selector.LabelSelector;
|
||||||
|
import run.halo.app.infra.exception.NotFoundException;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue service implementation.
|
||||||
|
*
|
||||||
|
* @author halo-copilot
|
||||||
|
* @since 2.21.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class IssueServiceImpl implements IssueService {
|
||||||
|
|
||||||
|
private final ReactiveExtensionClient client;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<ListResult<ListedIssue>> listIssues(IssueQuery query) {
|
||||||
|
return client.listBy(Issue.class, query.toListOptions(), query.toPageRequest())
|
||||||
|
.flatMap(list -> Mono.fromCallable(() -> {
|
||||||
|
var listedIssues = list.get()
|
||||||
|
.map(this::toListedIssue)
|
||||||
|
.sorted(defaultComparator())
|
||||||
|
.toList();
|
||||||
|
return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), listedIssues);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Issue> createIssue(Issue issue) {
|
||||||
|
Assert.notNull(issue, "Issue must not be null");
|
||||||
|
|
||||||
|
// Set default values
|
||||||
|
if (issue.getSpec().getStatus() == null) {
|
||||||
|
issue.getSpec().setStatus("OPEN");
|
||||||
|
}
|
||||||
|
if (issue.getSpec().getPriority() == null) {
|
||||||
|
issue.getSpec().setPriority("LOW");
|
||||||
|
}
|
||||||
|
if (issue.getSpec().getType() == null) {
|
||||||
|
issue.getSpec().setType("BUG");
|
||||||
|
}
|
||||||
|
if (issue.getSpec().getDeleted() == null) {
|
||||||
|
issue.getSpec().setDeleted(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set metadata labels
|
||||||
|
var labels = issue.getMetadata().getLabels();
|
||||||
|
if (labels != null) {
|
||||||
|
labels.put(Issue.DELETED_LABEL, "false");
|
||||||
|
labels.put(Issue.OWNER_LABEL, issue.getSpec().getOwner());
|
||||||
|
labels.put(Issue.STATUS_LABEL, issue.getSpec().getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.create(issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Issue> updateIssue(Issue issue) {
|
||||||
|
Assert.notNull(issue, "Issue must not be null");
|
||||||
|
return client.update(issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Issue> getByUsername(String issueName, String username) {
|
||||||
|
Assert.hasText(issueName, "Issue name must not be blank");
|
||||||
|
Assert.hasText(username, "Username must not be blank");
|
||||||
|
|
||||||
|
return client.get(Issue.class, issueName)
|
||||||
|
.filter(issue -> Objects.equals(issue.getSpec().getOwner(), username))
|
||||||
|
.switchIfEmpty(Mono.error(
|
||||||
|
() -> new NotFoundException("Issue not found or access denied")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Issue> deleteByUsername(String issueName, String username) {
|
||||||
|
return getByUsername(issueName, username)
|
||||||
|
.flatMap(issue -> {
|
||||||
|
issue.getSpec().setDeleted(true);
|
||||||
|
if (issue.getMetadata().getLabels() != null) {
|
||||||
|
issue.getMetadata().getLabels().put(Issue.DELETED_LABEL, "true");
|
||||||
|
}
|
||||||
|
return client.update(issue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ListedIssue toListedIssue(Issue issue) {
|
||||||
|
var listedIssue = new ListedIssue()
|
||||||
|
.setIssue(issue);
|
||||||
|
|
||||||
|
// Set stats (placeholder for now)
|
||||||
|
var stats = new ListedIssue.Stats();
|
||||||
|
listedIssue.setStats(stats);
|
||||||
|
|
||||||
|
return listedIssue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Comparator<ListedIssue> defaultComparator() {
|
||||||
|
Function<ListedIssue, Instant> creationTime = issue ->
|
||||||
|
issue.getIssue().getMetadata().getCreationTimestamp();
|
||||||
|
return Comparator.comparing(creationTime).reversed();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,195 @@
|
||||||
|
package run.halo.app.core.endpoint.uc;
|
||||||
|
|
||||||
|
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
|
||||||
|
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
|
||||||
|
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
|
||||||
|
import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route;
|
||||||
|
import static org.springframework.web.reactive.function.server.RequestPredicates.path;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
|
||||||
|
import org.springframework.security.core.context.SecurityContext;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.content.IssueQuery;
|
||||||
|
import run.halo.app.content.IssueService;
|
||||||
|
import run.halo.app.content.ListedIssue;
|
||||||
|
import run.halo.app.core.extension.content.Issue;
|
||||||
|
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||||
|
import run.halo.app.extension.GroupVersion;
|
||||||
|
import run.halo.app.extension.ListResult;
|
||||||
|
import run.halo.app.infra.exception.NotFoundException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User center endpoint for issues.
|
||||||
|
*
|
||||||
|
* @author halo-copilot
|
||||||
|
* @since 2.21.0
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class UcIssueEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
|
private final IssueService issueService;
|
||||||
|
|
||||||
|
public UcIssueEndpoint(IssueService issueService) {
|
||||||
|
this.issueService = issueService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RouterFunction<ServerResponse> endpoint() {
|
||||||
|
var tag = "IssueV1alpha1Uc";
|
||||||
|
var namePathParam = parameterBuilder().name("name")
|
||||||
|
.description("Issue name")
|
||||||
|
.in(ParameterIn.PATH)
|
||||||
|
.required(true);
|
||||||
|
|
||||||
|
return route().nest(
|
||||||
|
path("/issues"),
|
||||||
|
() -> route()
|
||||||
|
.GET(this::listMyIssues, builder -> {
|
||||||
|
builder.operationId("ListMyIssues")
|
||||||
|
.description("List issues owned by the current user.")
|
||||||
|
.tag(tag)
|
||||||
|
.response(responseBuilder().implementation(
|
||||||
|
ListResult.generateGenericClass(ListedIssue.class)));
|
||||||
|
buildParameters(builder);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.POST(this::createMyIssue, builder -> builder.operationId("CreateMyIssue")
|
||||||
|
.tag(tag)
|
||||||
|
.description("Create my issue.")
|
||||||
|
.requestBody(requestBodyBuilder().implementation(Issue.class))
|
||||||
|
.response(responseBuilder().implementation(Issue.class))
|
||||||
|
)
|
||||||
|
.GET("/{name}", this::getMyIssue, builder -> builder.operationId("GetMyIssue")
|
||||||
|
.tag(tag)
|
||||||
|
.parameter(namePathParam)
|
||||||
|
.description("Get issue that belongs to the current user.")
|
||||||
|
.response(responseBuilder().implementation(Issue.class))
|
||||||
|
)
|
||||||
|
.PUT("/{name}", this::updateMyIssue, builder ->
|
||||||
|
builder.operationId("UpdateMyIssue")
|
||||||
|
.tag(tag)
|
||||||
|
.parameter(namePathParam)
|
||||||
|
.description("Update my issue.")
|
||||||
|
.requestBody(requestBodyBuilder().implementation(Issue.class))
|
||||||
|
.response(responseBuilder().implementation(Issue.class))
|
||||||
|
)
|
||||||
|
.DELETE("/{name}", this::deleteMyIssue, builder -> builder.tag(tag)
|
||||||
|
.operationId("DeleteMyIssue")
|
||||||
|
.description("Delete my issue.")
|
||||||
|
.parameter(namePathParam)
|
||||||
|
.response(responseBuilder().implementation(Issue.class))
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
builder -> {
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> listMyIssues(ServerRequest request) {
|
||||||
|
return getCurrentUser()
|
||||||
|
.map(username -> new IssueQuery(request, username))
|
||||||
|
.flatMap(issueService::listIssues)
|
||||||
|
.flatMap(issues -> ServerResponse.ok().bodyValue(issues));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> createMyIssue(ServerRequest request) {
|
||||||
|
return getCurrentUser()
|
||||||
|
.flatMap(username -> request.bodyToMono(Issue.class)
|
||||||
|
.doOnNext(issue -> issue.getSpec().setOwner(username))
|
||||||
|
.flatMap(issueService::createIssue)
|
||||||
|
)
|
||||||
|
.flatMap(issue -> ServerResponse.ok().bodyValue(issue));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> getMyIssue(ServerRequest request) {
|
||||||
|
var issueName = request.pathVariable("name");
|
||||||
|
return getCurrentUser()
|
||||||
|
.flatMap(username -> issueService.getByUsername(issueName, username))
|
||||||
|
.flatMap(issue -> ServerResponse.ok().bodyValue(issue));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> updateMyIssue(ServerRequest request) {
|
||||||
|
var issueName = request.pathVariable("name");
|
||||||
|
return getCurrentUser()
|
||||||
|
.flatMap(username -> issueService.getByUsername(issueName, username)
|
||||||
|
.flatMap(existing -> request.bodyToMono(Issue.class)
|
||||||
|
.doOnNext(updated -> {
|
||||||
|
updated.getMetadata().setName(existing.getMetadata().getName());
|
||||||
|
updated.getMetadata().setVersion(existing.getMetadata().getVersion());
|
||||||
|
updated.getSpec().setOwner(username);
|
||||||
|
})
|
||||||
|
.flatMap(issueService::updateIssue)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.flatMap(issue -> ServerResponse.ok().bodyValue(issue));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> deleteMyIssue(ServerRequest request) {
|
||||||
|
var issueName = request.pathVariable("name");
|
||||||
|
return getCurrentUser()
|
||||||
|
.flatMap(username -> issueService.deleteByUsername(issueName, username))
|
||||||
|
.flatMap(issue -> ServerResponse.ok().bodyValue(issue));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> getCurrentUser() {
|
||||||
|
return ReactiveSecurityContextHolder.getContext()
|
||||||
|
.map(SecurityContext::getAuthentication)
|
||||||
|
.map(Authentication::getName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public GroupVersion groupVersion() {
|
||||||
|
return GroupVersion.parseAPIVersion("uc.api.content.halo.run/v1alpha1");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buildParameters(org.springdoc.core.fn.builders.operation.Builder builder) {
|
||||||
|
builder.parameter(parameterBuilder()
|
||||||
|
.name("page")
|
||||||
|
.in(ParameterIn.QUERY)
|
||||||
|
.required(false)
|
||||||
|
.implementation(Integer.class)
|
||||||
|
.description("The page number. Default is 0.")
|
||||||
|
)
|
||||||
|
.parameter(parameterBuilder()
|
||||||
|
.name("size")
|
||||||
|
.in(ParameterIn.QUERY)
|
||||||
|
.required(false)
|
||||||
|
.implementation(Integer.class)
|
||||||
|
.description("Size of each page. Default is 20.")
|
||||||
|
)
|
||||||
|
.parameter(parameterBuilder()
|
||||||
|
.name("status")
|
||||||
|
.in(ParameterIn.QUERY)
|
||||||
|
.required(false)
|
||||||
|
.implementation(String.class)
|
||||||
|
.description("Filter by status. e.g. OPEN, IN_PROGRESS, RESOLVED, CLOSED")
|
||||||
|
)
|
||||||
|
.parameter(parameterBuilder()
|
||||||
|
.name("priority")
|
||||||
|
.in(ParameterIn.QUERY)
|
||||||
|
.required(false)
|
||||||
|
.implementation(String.class)
|
||||||
|
.description("Filter by priority. e.g. LOW, MEDIUM, HIGH, CRITICAL")
|
||||||
|
)
|
||||||
|
.parameter(parameterBuilder()
|
||||||
|
.name("type")
|
||||||
|
.in(ParameterIn.QUERY)
|
||||||
|
.required(false)
|
||||||
|
.implementation(String.class)
|
||||||
|
.description("Filter by type. e.g. BUG, FEATURE_REQUEST, IMPROVEMENT, QUESTION")
|
||||||
|
)
|
||||||
|
.parameter(parameterBuilder()
|
||||||
|
.name("keyword")
|
||||||
|
.in(ParameterIn.QUERY)
|
||||||
|
.required(false)
|
||||||
|
.implementation(String.class)
|
||||||
|
.description("Filter by keyword in title or description")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -42,6 +42,7 @@ core:
|
||||||
profile: Profile
|
profile: Profile
|
||||||
notification: Notifications
|
notification: Notifications
|
||||||
posts: Posts
|
posts: Posts
|
||||||
|
issues: Issues
|
||||||
operations:
|
operations:
|
||||||
console:
|
console:
|
||||||
tooltip: Console
|
tooltip: Console
|
||||||
|
@ -2066,6 +2067,34 @@ core:
|
||||||
setting_modal:
|
setting_modal:
|
||||||
title: Post settings
|
title: Post settings
|
||||||
title: My posts
|
title: My posts
|
||||||
|
uc_issue:
|
||||||
|
title: My Issues
|
||||||
|
issue:
|
||||||
|
empty:
|
||||||
|
title: No issues found
|
||||||
|
message: You haven't created any issues yet, or try other filter conditions.
|
||||||
|
filters:
|
||||||
|
status:
|
||||||
|
items:
|
||||||
|
open: Open
|
||||||
|
in_progress: In Progress
|
||||||
|
resolved: Resolved
|
||||||
|
closed: Closed
|
||||||
|
status:
|
||||||
|
open: Open
|
||||||
|
in_progress: In Progress
|
||||||
|
resolved: Resolved
|
||||||
|
closed: Closed
|
||||||
|
priority:
|
||||||
|
low: Low
|
||||||
|
medium: Medium
|
||||||
|
high: High
|
||||||
|
critical: Critical
|
||||||
|
type:
|
||||||
|
bug: Bug
|
||||||
|
feature_request: Feature Request
|
||||||
|
improvement: Improvement
|
||||||
|
question: Question
|
||||||
tool:
|
tool:
|
||||||
title: Tools
|
title: Tools
|
||||||
empty:
|
empty:
|
||||||
|
|
|
@ -0,0 +1,200 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
IconAddCircle,
|
||||||
|
IconBookRead,
|
||||||
|
IconRefreshLine,
|
||||||
|
VButton,
|
||||||
|
VCard,
|
||||||
|
VEmpty,
|
||||||
|
VEntityContainer,
|
||||||
|
VLoading,
|
||||||
|
VPageHeader,
|
||||||
|
VPagination,
|
||||||
|
VSpace,
|
||||||
|
SearchInput,
|
||||||
|
FilterCleanButton,
|
||||||
|
FilterDropdown,
|
||||||
|
} from "@halo-dev/components";
|
||||||
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
|
import { useRouteQuery } from "@vueuse/router";
|
||||||
|
import { computed, watch } from "vue";
|
||||||
|
import IssueListItem from "./components/IssueListItem.vue";
|
||||||
|
|
||||||
|
const page = useRouteQuery<number>("page", 1, {
|
||||||
|
transform: Number,
|
||||||
|
});
|
||||||
|
const size = useRouteQuery<number>("size", 20, {
|
||||||
|
transform: Number,
|
||||||
|
});
|
||||||
|
const keyword = useRouteQuery<string>("keyword", "");
|
||||||
|
|
||||||
|
const selectedStatus = useRouteQuery<
|
||||||
|
"OPEN" | "IN_PROGRESS" | "RESOLVED" | "CLOSED" | undefined
|
||||||
|
>("status");
|
||||||
|
|
||||||
|
function handleClearFilters() {
|
||||||
|
selectedStatus.value = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFilters = computed(() => {
|
||||||
|
return selectedStatus.value !== undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [selectedStatus.value, keyword.value],
|
||||||
|
() => {
|
||||||
|
page.value = 1;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: issues,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["my-issues", page, size, keyword, selectedStatus],
|
||||||
|
queryFn: async () => {
|
||||||
|
// For now, we'll use a mock API call until the real API client is generated
|
||||||
|
// This would eventually be: ucApiClient.content.issue.listMyIssues({...})
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API delay
|
||||||
|
|
||||||
|
// Mock response structure
|
||||||
|
return {
|
||||||
|
page: page.value,
|
||||||
|
size: size.value,
|
||||||
|
total: 0,
|
||||||
|
items: [],
|
||||||
|
first: true,
|
||||||
|
last: true,
|
||||||
|
hasNext: false,
|
||||||
|
hasPrevious: false,
|
||||||
|
totalPages: 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
page.value = data.page;
|
||||||
|
size.value = data.size;
|
||||||
|
},
|
||||||
|
refetchInterval: false,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VPageHeader :title="$t('core.uc_issue.title')">
|
||||||
|
<template #icon>
|
||||||
|
<IconBookRead />
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<VButton type="secondary">
|
||||||
|
<template #icon>
|
||||||
|
<IconAddCircle />
|
||||||
|
</template>
|
||||||
|
{{ $t("core.common.buttons.new") }}
|
||||||
|
</VButton>
|
||||||
|
</template>
|
||||||
|
</VPageHeader>
|
||||||
|
|
||||||
|
<div class="m-0 md:m-4">
|
||||||
|
<VCard :body-class="['!p-0']">
|
||||||
|
<template #header>
|
||||||
|
<div class="block w-full bg-gray-50 px-4 py-3">
|
||||||
|
<div
|
||||||
|
class="relative flex flex-col flex-wrap items-start justify-between gap-4 sm:flex-row sm:items-center"
|
||||||
|
>
|
||||||
|
<SearchInput v-model="keyword" />
|
||||||
|
|
||||||
|
<VSpace spacing="lg" class="flex-wrap">
|
||||||
|
<FilterCleanButton
|
||||||
|
v-if="hasFilters"
|
||||||
|
@click="handleClearFilters"
|
||||||
|
/>
|
||||||
|
<FilterDropdown
|
||||||
|
v-model="selectedStatus"
|
||||||
|
:label="$t('core.common.filters.labels.status')"
|
||||||
|
:items="[
|
||||||
|
{
|
||||||
|
label: $t('core.common.filters.item_labels.all'),
|
||||||
|
value: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('core.issue.filters.status.items.open'),
|
||||||
|
value: 'OPEN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('core.issue.filters.status.items.in_progress'),
|
||||||
|
value: 'IN_PROGRESS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('core.issue.filters.status.items.resolved'),
|
||||||
|
value: 'RESOLVED',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('core.issue.filters.status.items.closed'),
|
||||||
|
value: 'CLOSED',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
|
||||||
|
@click="refetch()"
|
||||||
|
>
|
||||||
|
<IconRefreshLine
|
||||||
|
v-tooltip="$t('core.common.buttons.refresh')"
|
||||||
|
:class="{ 'animate-spin text-gray-900': isFetching }"
|
||||||
|
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</VSpace>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<VLoading v-if="isLoading" />
|
||||||
|
<Transition v-else-if="!issues?.items.length" appear name="fade">
|
||||||
|
<VEmpty
|
||||||
|
:message="$t('core.issue.empty.message')"
|
||||||
|
:title="$t('core.issue.empty.title')"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<VSpace>
|
||||||
|
<VButton @click="refetch">
|
||||||
|
{{ $t("core.common.buttons.refresh") }}
|
||||||
|
</VButton>
|
||||||
|
<VButton type="secondary">
|
||||||
|
<template #icon>
|
||||||
|
<IconAddCircle />
|
||||||
|
</template>
|
||||||
|
{{ $t("core.common.buttons.new") }}
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</template>
|
||||||
|
</VEmpty>
|
||||||
|
</Transition>
|
||||||
|
<Transition v-else appear name="fade">
|
||||||
|
<VEntityContainer>
|
||||||
|
<IssueListItem
|
||||||
|
v-for="issue in issues.items"
|
||||||
|
:key="issue.issue.metadata.name"
|
||||||
|
:issue="issue"
|
||||||
|
/>
|
||||||
|
</VEntityContainer>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<VPagination
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:size="size"
|
||||||
|
:page-label="$t('core.components.pagination.page_label')"
|
||||||
|
:size-label="$t('core.components.pagination.size_label')"
|
||||||
|
:total-label="
|
||||||
|
$t('core.components.pagination.total_label', {
|
||||||
|
total: issues?.total || 0,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:total="issues?.total || 0"
|
||||||
|
:size-options="[20, 30, 50, 100]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,124 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { formatDatetime, relativeTimeTo } from "@/utils/date";
|
||||||
|
import {
|
||||||
|
VEntity,
|
||||||
|
VEntityField,
|
||||||
|
VSpace,
|
||||||
|
VTag,
|
||||||
|
} from "@halo-dev/components";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
interface Issue {
|
||||||
|
metadata: {
|
||||||
|
name: string;
|
||||||
|
creationTimestamp: string;
|
||||||
|
};
|
||||||
|
spec: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListedIssue {
|
||||||
|
issue: Issue;
|
||||||
|
stats: {
|
||||||
|
commentsCount: number;
|
||||||
|
upvotesCount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
issue: ListedIssue;
|
||||||
|
}>(),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusColor = computed(() => {
|
||||||
|
switch (props.issue.issue.spec.status) {
|
||||||
|
case "OPEN":
|
||||||
|
return "green";
|
||||||
|
case "IN_PROGRESS":
|
||||||
|
return "yellow";
|
||||||
|
case "RESOLVED":
|
||||||
|
return "blue";
|
||||||
|
case "CLOSED":
|
||||||
|
return "gray";
|
||||||
|
default:
|
||||||
|
return "gray";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const priorityColor = computed(() => {
|
||||||
|
switch (props.issue.issue.spec.priority) {
|
||||||
|
case "CRITICAL":
|
||||||
|
return "red";
|
||||||
|
case "HIGH":
|
||||||
|
return "orange";
|
||||||
|
case "MEDIUM":
|
||||||
|
return "yellow";
|
||||||
|
case "LOW":
|
||||||
|
return "green";
|
||||||
|
default:
|
||||||
|
return "gray";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeColor = computed(() => {
|
||||||
|
switch (props.issue.issue.spec.type) {
|
||||||
|
case "BUG":
|
||||||
|
return "red";
|
||||||
|
case "FEATURE_REQUEST":
|
||||||
|
return "blue";
|
||||||
|
case "IMPROVEMENT":
|
||||||
|
return "green";
|
||||||
|
case "QUESTION":
|
||||||
|
return "purple";
|
||||||
|
default:
|
||||||
|
return "gray";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VEntity>
|
||||||
|
<template #start>
|
||||||
|
<VEntityField
|
||||||
|
:title="issue.issue.spec.title"
|
||||||
|
:description="issue.issue.spec.description"
|
||||||
|
:route="{
|
||||||
|
name: 'IssueDetail',
|
||||||
|
params: { name: issue.issue.metadata.name },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #extra>
|
||||||
|
<VSpace class="mt-1 sm:mt-0">
|
||||||
|
<VTag :color="statusColor" variant="outline" size="sm">
|
||||||
|
{{ $t(`core.issue.status.${issue.issue.spec.status.toLowerCase()}`) }}
|
||||||
|
</VTag>
|
||||||
|
<VTag :color="priorityColor" variant="outline" size="sm">
|
||||||
|
{{ $t(`core.issue.priority.${issue.issue.spec.priority.toLowerCase()}`) }}
|
||||||
|
</VTag>
|
||||||
|
<VTag :color="typeColor" variant="outline" size="sm">
|
||||||
|
{{ $t(`core.issue.type.${issue.issue.spec.type.toLowerCase()}`) }}
|
||||||
|
</VTag>
|
||||||
|
</VSpace>
|
||||||
|
</template>
|
||||||
|
</VEntityField>
|
||||||
|
</template>
|
||||||
|
<template #end>
|
||||||
|
<VEntityField>
|
||||||
|
<template #description>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span>{{ formatDatetime(issue.issue.metadata.creationTimestamp) }}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{{ relativeTimeTo(issue.issue.metadata.creationTimestamp) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VEntityField>
|
||||||
|
</template>
|
||||||
|
</VEntity>
|
||||||
|
</template>
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { IconBookRead } from "@halo-dev/components";
|
||||||
|
import { definePlugin } from "@halo-dev/console-shared";
|
||||||
|
import BasicLayout from "@uc/layouts/BasicLayout.vue";
|
||||||
|
import { markRaw } from "vue";
|
||||||
|
import IssueList from "./IssueList.vue";
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
ucRoutes: [
|
||||||
|
{
|
||||||
|
path: "/issues",
|
||||||
|
name: "IssuesRoot",
|
||||||
|
component: BasicLayout,
|
||||||
|
meta: {
|
||||||
|
title: "core.uc_issue.title",
|
||||||
|
searchable: true,
|
||||||
|
permissions: ["uc:issues:manage"],
|
||||||
|
menu: {
|
||||||
|
name: "core.uc_sidebar.menu.items.issues",
|
||||||
|
group: "content",
|
||||||
|
icon: markRaw(IconBookRead),
|
||||||
|
priority: 1,
|
||||||
|
mobile: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
name: "Issues",
|
||||||
|
component: IssueList,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
Loading…
Reference in New Issue