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
|
||||
notification: Notifications
|
||||
posts: Posts
|
||||
issues: Issues
|
||||
operations:
|
||||
console:
|
||||
tooltip: Console
|
||||
|
@ -2066,6 +2067,34 @@ core:
|
|||
setting_modal:
|
||||
title: Post settings
|
||||
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:
|
||||
title: Tools
|
||||
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