Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fcc958460 | ||
|
|
a835e68ac2 |
-61
@@ -1,61 +0,0 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Supplies the classification vocabulary the classify tool sends to the AI engine. The set is a
|
||||
* fixed, built-in list bundled with the application ({@code
|
||||
* classification/classification-labels.json}) and shared by everyone — there is no per-team
|
||||
* customization or database. Loaded once at startup.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ClassificationLabelProvider {
|
||||
|
||||
private static final String RESOURCE = "classification/classification-labels.json";
|
||||
|
||||
private final List<ClassificationLabel> labels;
|
||||
|
||||
// Explicit @Autowired: the class has a second (private) constructor for tests, so Spring
|
||||
// can't infer which to use without it.
|
||||
@Autowired
|
||||
public ClassificationLabelProvider(ObjectMapper objectMapper) {
|
||||
this(load(objectMapper));
|
||||
}
|
||||
|
||||
private ClassificationLabelProvider(List<ClassificationLabel> labels) {
|
||||
this.labels = List.copyOf(labels);
|
||||
}
|
||||
|
||||
/** Build a provider with an explicit label set (tests). */
|
||||
public static ClassificationLabelProvider withLabels(List<ClassificationLabel> labels) {
|
||||
return new ClassificationLabelProvider(labels);
|
||||
}
|
||||
|
||||
/** The built-in vocabulary, in file order. */
|
||||
public List<ClassificationLabel> labels() {
|
||||
return labels;
|
||||
}
|
||||
|
||||
private static List<ClassificationLabel> load(ObjectMapper objectMapper) {
|
||||
try (InputStream in = new ClassPathResource(RESOURCE).getInputStream()) {
|
||||
ClassificationLabels parsed = objectMapper.readValue(in, ClassificationLabels.class);
|
||||
return parsed.labels();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load classification labels from {}", RESOURCE, e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
import stirling.software.proprietary.classification.model.LabelsValidator;
|
||||
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
|
||||
import stirling.software.proprietary.classification.store.TeamLabelsEntity;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/**
|
||||
* Read/write the team's classification label set — the flat vocabulary the document classifier runs
|
||||
* against. Shared and team-scoped exactly like policies: every user reads their own team's labels,
|
||||
* and only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see
|
||||
* {@link PolicyManagementAuthority}) may change it — gated only when login is enabled, since
|
||||
* single-user deployments trust the local operator. A team with no stored labels reads as {@code
|
||||
* 204}; that team has no vocabulary, so its documents are not classified (there is no built-in
|
||||
* default on the backend or the engine — the label data lives only in the frontend).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/classification/labels")
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Classification", description = "Team-scoped document-classification labels")
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class ClassificationLabelsController {
|
||||
|
||||
private final ClassificationLabelStore labelStore;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "Get the team's classification labels",
|
||||
description =
|
||||
"Returns the caller's team label set, or 204 when the team has none (its"
|
||||
+ " documents are then not classified).")
|
||||
public ResponseEntity<ClassificationLabels> getTeamLabels() {
|
||||
return labelStore
|
||||
.findByTeam(currentTeamId())
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@Operation(
|
||||
summary = "Save the team's classification labels",
|
||||
description =
|
||||
"Validates and stores the label set for the caller's team, shared by everyone"
|
||||
+ " on the team. Requires the policy-editor role for the team.")
|
||||
public ResponseEntity<ClassificationLabels> saveTeamLabels(
|
||||
@RequestBody ClassificationLabels labels) {
|
||||
requireEditingAllowed();
|
||||
validate(labels);
|
||||
ClassificationLabels saved = labelStore.save(currentTeamId(), labels, currentUsername());
|
||||
return ResponseEntity.ok(saved);
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@Operation(
|
||||
summary = "Reset the team's classification labels",
|
||||
description =
|
||||
"Removes the team's stored label set; its documents are then not classified"
|
||||
+ " until labels are saved again. Requires the policy-editor role for the"
|
||||
+ " team.")
|
||||
public ResponseEntity<Void> resetTeamLabels() {
|
||||
requireEditingAllowed();
|
||||
labelStore.deleteByTeam(currentTeamId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static void validate(ClassificationLabels labels) {
|
||||
try {
|
||||
LabelsValidator.validate(labels);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing the team labels requires the editor role for the caller's team — the same gate
|
||||
* policies use (team leader on SaaS, global admin self-hosted). Single-user deployments (login
|
||||
* disabled) have no such role, so they trust the local operator.
|
||||
*/
|
||||
private void requireEditingAllowed() {
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return;
|
||||
}
|
||||
if (!policyManagementAuthority.canEditPolicies()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"The team classification labels may only be changed by a team leader");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's team key. With login disabled the single operator owns the {@link
|
||||
* TeamLabelsEntity#NO_TEAM} sentinel row; with login enabled a caller with no resolvable team
|
||||
* is an error rather than being dropped into the shared sentinel bucket (which would let
|
||||
* unteamed users read and overwrite each other's "team" labels).
|
||||
*/
|
||||
private Long currentTeamId() {
|
||||
Long teamId = policyManagementAuthority.currentUserTeamId();
|
||||
if (teamId != null) {
|
||||
return teamId;
|
||||
}
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return TeamLabelsEntity.NO_TEAM;
|
||||
}
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.UNAUTHORIZED, "Could not resolve the current user's team");
|
||||
}
|
||||
|
||||
private String currentUsername() {
|
||||
return userService == null ? null : userService.getCurrentUsername();
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -3,10 +3,10 @@ package stirling.software.proprietary.classification.model;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A flat multi-label classification vocabulary — the set of labels a document may be assigned. The
|
||||
* classifier runs against these label names. The vocabulary is a fixed, built-in set shared by
|
||||
* everyone (see {@link stirling.software.proprietary.classification.ClassificationLabelProvider});
|
||||
* this record is the JSON parse target for that bundled resource.
|
||||
* A flat multi-label classification vocabulary — the set of labels a document may be assigned.
|
||||
* Stored per team (admin-edited, shared by everyone on the team); the classifier runs against these
|
||||
* label names. A team with no stored set has no vocabulary, so its documents are not classified —
|
||||
* neither the backend nor the engine holds a default of its own.
|
||||
*/
|
||||
public record ClassificationLabels(List<ClassificationLabel> labels) {
|
||||
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Structural validation for a user- or admin-supplied label set, run before it is stored so a
|
||||
* malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on:
|
||||
* non-blank ids and names, each unique within the set (ids exactly, names case-insensitively).
|
||||
*/
|
||||
public final class LabelsValidator {
|
||||
|
||||
private LabelsValidator() {}
|
||||
|
||||
// Generous upper bounds so a legitimate label set is never blocked, but a single team or user
|
||||
// can't store an unbounded blob that would bloat the row, balloon the classifier prompt, or
|
||||
// exhaust memory on deserialize.
|
||||
static final int MAX_LABELS = 500;
|
||||
static final int MAX_TEXT_LENGTH = 128;
|
||||
|
||||
// Icon is a Material Symbols key (lowercase, digits, hyphens). Enforce the SHAPE server-side —
|
||||
// the exact allowlist lives in the frontend — so a client bypassing the UI can't store
|
||||
// arbitrary
|
||||
// text that would render as garbage (or worse) in every teammate's sidebar.
|
||||
private static final Pattern ICON_KEY = Pattern.compile("^[a-z0-9-]+$");
|
||||
|
||||
/**
|
||||
* @throws IllegalArgumentException with a human-readable message when the label set is invalid.
|
||||
*/
|
||||
public static void validate(ClassificationLabels labels) {
|
||||
if (labels == null || labels.labels() == null) {
|
||||
throw new IllegalArgumentException("Labels are required");
|
||||
}
|
||||
if (labels.labels().size() > MAX_LABELS) {
|
||||
throw new IllegalArgumentException("Too many labels (max " + MAX_LABELS + ")");
|
||||
}
|
||||
Set<String> ids = new HashSet<>();
|
||||
Set<String> names = new HashSet<>();
|
||||
for (ClassificationLabel label : labels.labels()) {
|
||||
requireText(label.id(), "Label id");
|
||||
requireText(label.name(), "Label name");
|
||||
if (label.icon() != null && !label.icon().isEmpty()) {
|
||||
if (label.icon().length() > MAX_TEXT_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
"Label icon is too long (max " + MAX_TEXT_LENGTH + " characters)");
|
||||
}
|
||||
if (!ICON_KEY.matcher(label.icon()).matches()) {
|
||||
throw new IllegalArgumentException("Invalid label icon: " + label.icon());
|
||||
}
|
||||
}
|
||||
if (!ids.add(label.id().trim())) {
|
||||
throw new IllegalArgumentException("Duplicate label id: " + label.id());
|
||||
}
|
||||
if (!names.add(label.name().trim().toLowerCase(Locale.ROOT))) {
|
||||
throw new IllegalArgumentException("Duplicate label name: " + label.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
if (value.trim().length() > MAX_TEXT_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
field + " is too long (max " + MAX_TEXT_LENGTH + " characters)");
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
/**
|
||||
* Stores one {@link ClassificationLabels} set per team. A {@code null} teamId addresses the
|
||||
* unteamed set (login disabled / no resolvable team), mirroring how the policy store treats a null
|
||||
* team.
|
||||
*/
|
||||
public interface ClassificationLabelStore {
|
||||
|
||||
/** The team's stored labels, or empty when it has none (callers then skip classification). */
|
||||
Optional<ClassificationLabels> findByTeam(Long teamId);
|
||||
|
||||
/** Create or replace the team's labels. Returns the stored value. */
|
||||
ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy);
|
||||
|
||||
/** Remove the team's labels (reset to default). Returns whether a set existed. */
|
||||
boolean deleteByTeam(Long teamId);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
/**
|
||||
* In-memory {@link ClassificationLabelStore} for tests and any future no-database mode. {@link
|
||||
* JpaClassificationLabelStore} is the runtime bean.
|
||||
*/
|
||||
public class InProcessClassificationLabelStore implements ClassificationLabelStore {
|
||||
|
||||
private final Map<Long, ClassificationLabels> byTeam = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Optional<ClassificationLabels> findByTeam(Long teamId) {
|
||||
return Optional.ofNullable(byTeam.get(key(teamId)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
|
||||
byTeam.put(key(teamId), labels);
|
||||
return labels;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByTeam(Long teamId) {
|
||||
return byTeam.remove(key(teamId)) != null;
|
||||
}
|
||||
|
||||
private static long key(Long teamId) {
|
||||
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Durable {@link ClassificationLabelStore} backed by JPA; the runtime store. Gated on {@code
|
||||
* policies.enabled} — stored labels only matter when the Classification policy can run — so it
|
||||
* shares the policy subsystem's on/off switch. Each label set is persisted as JSON via {@link
|
||||
* TeamLabelsEntity}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class JpaClassificationLabelStore implements ClassificationLabelStore {
|
||||
|
||||
private final TeamLabelsRepository teamRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public Optional<ClassificationLabels> findByTeam(Long teamId) {
|
||||
return teamRepository
|
||||
.findById(key(teamId))
|
||||
.flatMap(entity -> parse(entity.getLabelsJson(), "team " + teamId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
|
||||
TeamLabelsEntity entity = new TeamLabelsEntity();
|
||||
entity.setTeamId(key(teamId));
|
||||
entity.setLabelsJson(objectMapper.writeValueAsString(labels));
|
||||
entity.setUpdatedAt(Instant.now());
|
||||
entity.setUpdatedBy(updatedBy);
|
||||
teamRepository.save(entity);
|
||||
return labels;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByTeam(Long teamId) {
|
||||
long id = key(teamId);
|
||||
if (!teamRepository.existsById(id)) {
|
||||
return false;
|
||||
}
|
||||
teamRepository.deleteById(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
private Optional<ClassificationLabels> parse(String json, String owner) {
|
||||
try {
|
||||
return Optional.of(objectMapper.readValue(json, ClassificationLabels.class));
|
||||
} catch (JacksonException e) {
|
||||
// A stored label set that no longer parses (corruption / manual DB edit) must not break
|
||||
// classification: drop it so the caller treats the team as having no labels (and skips
|
||||
// classification) rather than surfacing a 500 on every upload.
|
||||
log.warn("Discarding unparseable stored labels for {}: {}", owner, e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/** Map the nullable team id onto the entity's non-null key (sentinel for the unteamed case). */
|
||||
private static long key(Long teamId) {
|
||||
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* JPA row for a team's classification labels — one row per team. The label set lives as JSON in
|
||||
* {@code labelsJson} (authoritative on read). {@code teamId} is the natural key; the sentinel
|
||||
* {@link #NO_TEAM} stands in for the unteamed (login-disabled / self-hosted single-team) case,
|
||||
* since a primary key can't be null (policies store a nullable {@code team_id}, but this table is
|
||||
* keyed one-per-team). Kept decoupled from the security entities — {@code teamId} is a plain value,
|
||||
* not a foreign key — so classification can be enabled or disabled without touching them.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "classification_labels")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class TeamLabelsEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** Sentinel key for the unteamed label set (login disabled / no resolvable team). */
|
||||
public static final long NO_TEAM = 0L;
|
||||
|
||||
@Id
|
||||
@Column(name = "team_id")
|
||||
private long teamId;
|
||||
|
||||
@Column(name = "labels_json", columnDefinition = "text")
|
||||
private String labelsJson;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@Column(name = "updated_by")
|
||||
private String updatedBy;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface TeamLabelsRepository extends JpaRepository<TeamLabelsEntity, Long> {}
|
||||
+32
-14
@@ -31,9 +31,10 @@ import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.classification.ClassificationLabelProvider;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
|
||||
import stirling.software.proprietary.model.api.ai.AiPageText;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||
|
||||
@@ -45,7 +46,7 @@ import tools.jackson.databind.node.ObjectNode;
|
||||
* Dispatchable tool that classifies a PDF and writes the result into its metadata.
|
||||
*
|
||||
* <p>Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI
|
||||
* engine to classify the document against the built-in label set, and stores the engine's JSON
|
||||
* engine to classify the document against the caller's team label set, and stores the engine's JSON
|
||||
* answer — minus the transport-only {@code outcome} field — in the custom Info-dictionary key
|
||||
* {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. Not intended for direct
|
||||
* client use.
|
||||
@@ -71,9 +72,13 @@ public class ClassifyLabelController {
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
/**
|
||||
* The fixed, built-in vocabulary shared by everyone — see {@link ClassificationLabelProvider}.
|
||||
* Present only when the policy subsystem is enabled ({@code policies.enabled}); the store and
|
||||
* team authority are gated on it. Null otherwise, in which case there are no team labels to
|
||||
* classify against and the document is passed through unlabelled.
|
||||
*/
|
||||
private final ClassificationLabelProvider labelProvider;
|
||||
private final ClassificationLabelStore labelStore;
|
||||
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
|
||||
public ClassifyLabelController(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
@@ -82,16 +87,18 @@ public class ClassifyLabelController {
|
||||
PdfMetadataService pdfMetadataService,
|
||||
AiEngineClient aiEngineClient,
|
||||
ObjectMapper objectMapper,
|
||||
ClassificationLabelProvider labelProvider,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
@Autowired(required = false) ClassificationLabelStore labelStore,
|
||||
@Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.pdfContentExtractor = pdfContentExtractor;
|
||||
this.pdfMetadataService = pdfMetadataService;
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.objectMapper = objectMapper;
|
||||
this.labelProvider = labelProvider;
|
||||
this.userService = userService;
|
||||
this.labelStore = labelStore;
|
||||
this.policyManagementAuthority = policyManagementAuthority;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -109,9 +116,9 @@ public class ClassifyLabelController {
|
||||
|
||||
List<EngineLabel> allowed = resolveAllowedLabels();
|
||||
if (allowed.isEmpty()) {
|
||||
// No vocabulary to classify against: pass the file through unlabelled rather than
|
||||
// ask the engine to classify against nothing.
|
||||
log.debug("[classify-and-label] {} has no labels; skipping", fileName);
|
||||
// No vocabulary to classify against (the team stored no labels): pass the file
|
||||
// through unlabelled rather than ask the engine to classify against nothing.
|
||||
log.debug("[classify-and-label] {} has no team labels; skipping", fileName);
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
|
||||
}
|
||||
|
||||
@@ -168,13 +175,24 @@ public class ClassifyLabelController {
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in vocabulary as {@code {id, name}} pairs, de-duplicated by id. The engine shows
|
||||
* the model the names and returns the ids (icons are presentational and never sent). The engine
|
||||
* holds no default vocabulary of its own, so this bundled set is the only source.
|
||||
* The allowed labels for the caller's team as {@code {id, name}} pairs, de-duplicated by id.
|
||||
* The engine shows the model the names and returns the ids (icons are presentational and never
|
||||
* sent). Returns an empty list — the caller then skips classification — when the policy
|
||||
* subsystem is disabled (no store) or the team has no stored labels. The engine holds no
|
||||
* default vocabulary of its own, so a team's stored labels are the only source.
|
||||
*/
|
||||
private List<EngineLabel> resolveAllowedLabels() {
|
||||
if (labelStore == null) {
|
||||
return List.of();
|
||||
}
|
||||
Long teamId =
|
||||
policyManagementAuthority == null
|
||||
? null
|
||||
: policyManagementAuthority.currentUserTeamId();
|
||||
|
||||
Map<String, EngineLabel> byId = new LinkedHashMap<>();
|
||||
collectLabels(labelProvider.labels(), byId);
|
||||
labelStore.findByTeam(teamId).ifPresent(labels -> collectLabels(labels.labels(), byId));
|
||||
|
||||
return List.copyOf(byId.values());
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -37,7 +37,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.policy.ledger",
|
||||
"stirling.software.proprietary.accountlink",
|
||||
"stirling.software.proprietary.access.repository",
|
||||
"stirling.software.proprietary.integration.repository"
|
||||
"stirling.software.proprietary.integration.repository",
|
||||
"stirling.software.proprietary.classification.store"
|
||||
})
|
||||
@EntityScan({
|
||||
"stirling.software.proprietary.security.model",
|
||||
@@ -49,7 +50,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.policy.ledger",
|
||||
"stirling.software.proprietary.accountlink",
|
||||
"stirling.software.proprietary.access.model",
|
||||
"stirling.software.proprietary.integration.model"
|
||||
"stirling.software.proprietary.integration.model",
|
||||
"stirling.software.proprietary.classification.store"
|
||||
})
|
||||
public class DatabaseConfig {
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+133
@@ -0,0 +1,133 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
|
||||
import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("ClassificationLabelsController")
|
||||
class ClassificationLabelsControllerTest {
|
||||
|
||||
private static final Long TEAM = 7L;
|
||||
|
||||
@Mock private PolicyManagementAuthority policyManagementAuthority;
|
||||
@Mock private UserServiceInterface userService;
|
||||
|
||||
private ClassificationLabelStore store;
|
||||
private ApplicationProperties applicationProperties;
|
||||
private ClassificationLabelsController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
store = new InProcessClassificationLabelStore();
|
||||
applicationProperties = new ApplicationProperties();
|
||||
controller =
|
||||
new ClassificationLabelsController(
|
||||
store, policyManagementAuthority, applicationProperties, userService);
|
||||
}
|
||||
|
||||
private static ClassificationLabels sample() {
|
||||
return new ClassificationLabels(
|
||||
List.of(
|
||||
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
|
||||
new ClassificationLabel("contract", "Contract", null)));
|
||||
}
|
||||
|
||||
private void loginEnabled(boolean enabled) {
|
||||
applicationProperties.getSecurity().setEnableLogin(enabled);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET returns 204 when the team has no labels")
|
||||
void getEmpty() {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
ResponseEntity<ClassificationLabels> response = controller.getTeamLabels();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT then GET round-trips the team's labels (login disabled)")
|
||||
void saveThenGet() {
|
||||
loginEnabled(false);
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
|
||||
controller.saveTeamLabels(sample());
|
||||
ResponseEntity<ClassificationLabels> got = controller.getTeamLabels();
|
||||
|
||||
assertThat(got.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(got.getBody()).isNotNull();
|
||||
assertThat(got.getBody().labels()).hasSize(2);
|
||||
assertThat(got.getBody().labels().getFirst().name()).isEqualTo("Invoice");
|
||||
assertThat(got.getBody().labels().getFirst().icon()).isEqualTo("receipt-long");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT is scoped per team")
|
||||
void perTeam() {
|
||||
loginEnabled(false);
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
controller.saveTeamLabels(sample());
|
||||
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(99L);
|
||||
assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT is rejected for a non-editor when login is enabled")
|
||||
void putForbiddenForNonEditor() {
|
||||
loginEnabled(true);
|
||||
when(policyManagementAuthority.canEditPolicies()).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> controller.saveTeamLabels(sample()))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT rejects an invalid label set with 400")
|
||||
void putInvalid() {
|
||||
loginEnabled(false);
|
||||
ClassificationLabels duplicate =
|
||||
new ClassificationLabels(
|
||||
List.of(
|
||||
new ClassificationLabel("invoice", "Invoice", null),
|
||||
new ClassificationLabel("invoice", "Invoice", null)));
|
||||
|
||||
assertThatThrownBy(() -> controller.saveTeamLabels(duplicate))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DELETE resets the team back to no stored labels")
|
||||
void deleteResets() {
|
||||
loginEnabled(false);
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
controller.saveTeamLabels(sample());
|
||||
|
||||
ResponseEntity<Void> response = controller.resetTeamLabels();
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@DisplayName("LabelsValidator")
|
||||
class LabelsValidatorTest {
|
||||
|
||||
private static ClassificationLabels labels(ClassificationLabel... labels) {
|
||||
return new ClassificationLabels(List.of(labels));
|
||||
}
|
||||
|
||||
private static ClassificationLabel label(String name) {
|
||||
return new ClassificationLabel(slug(name), name, null);
|
||||
}
|
||||
|
||||
private static String slug(String name) {
|
||||
return name.trim()
|
||||
.toLowerCase(Locale.ROOT)
|
||||
.replaceAll("[^a-z0-9]+", "-")
|
||||
.replaceAll("(^-|-$)", "");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accepts a well-formed label set")
|
||||
void acceptsValid() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
|
||||
label("Contract"));
|
||||
assertThatCode(() -> LabelsValidator.validate(set)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accepts an empty label set (reads as: use the default)")
|
||||
void acceptsEmpty() {
|
||||
assertThatCode(() -> LabelsValidator.validate(labels())).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a null label set")
|
||||
void rejectsNull() {
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Labels are required");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects duplicate names (distinct ids)")
|
||||
void rejectsDuplicateNames() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel("invoice-a", "Invoice", null),
|
||||
new ClassificationLabel("invoice-b", "Invoice", null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Duplicate label name");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects duplicate names differing only by case")
|
||||
void rejectsDuplicateNamesCaseInsensitive() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel("invoice-a", "Invoice", null),
|
||||
new ClassificationLabel("invoice-b", "INVOICE", null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Duplicate label name");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects duplicate ids")
|
||||
void rejectsDuplicateIds() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel("invoice", "Invoice", null),
|
||||
new ClassificationLabel("invoice", "Sales invoice", null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Duplicate label id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a blank name")
|
||||
void rejectsBlankName() {
|
||||
ClassificationLabels set = labels(new ClassificationLabel("blank", " ", null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Label name must not be blank");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a blank id")
|
||||
void rejectsBlankId() {
|
||||
ClassificationLabels set = labels(new ClassificationLabel(" ", "Invoice", null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Label id must not be blank");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an over-long name")
|
||||
void rejectsOverLongName() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel(
|
||||
"x", "x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1), null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("too long");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an over-long icon (a null icon is fine)")
|
||||
void rejectsOverLongIcon() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel(
|
||||
"invoice",
|
||||
"Invoice",
|
||||
"x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1)));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("icon is too long");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects more labels than the cap")
|
||||
void rejectsTooManyLabels() {
|
||||
List<ClassificationLabel> tooMany =
|
||||
IntStream.rangeClosed(0, LabelsValidator.MAX_LABELS)
|
||||
.mapToObj(i -> label("label" + i))
|
||||
.toList();
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(new ClassificationLabels(tooMany)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Too many labels");
|
||||
}
|
||||
}
|
||||
+34
-15
@@ -14,6 +14,7 @@ import static org.mockito.Mockito.when;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -26,8 +27,10 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.proprietary.classification.ClassificationLabelProvider;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||
|
||||
@@ -39,16 +42,22 @@ import tools.jackson.databind.json.JsonMapper;
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ClassifyLabelControllerTest {
|
||||
|
||||
private static final Long TEAM = 7L;
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private PdfContentExtractor pdfContentExtractor;
|
||||
@Mock private PdfMetadataService pdfMetadataService;
|
||||
@Mock private AiEngineClient aiEngineClient;
|
||||
@Mock private PolicyManagementAuthority policyManagementAuthority;
|
||||
|
||||
private final ObjectMapper objectMapper = JsonMapper.builder().build();
|
||||
private InProcessClassificationLabelStore labelStore;
|
||||
private ClassifyLabelController controller;
|
||||
|
||||
private void withLabels(List<ClassificationLabel> labels) {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
labelStore = new InProcessClassificationLabelStore();
|
||||
controller =
|
||||
new ClassifyLabelController(
|
||||
pdfDocumentFactory,
|
||||
@@ -57,8 +66,9 @@ class ClassifyLabelControllerTest {
|
||||
pdfMetadataService,
|
||||
aiEngineClient,
|
||||
objectMapper,
|
||||
ClassificationLabelProvider.withLabels(labels),
|
||||
null);
|
||||
null,
|
||||
labelStore,
|
||||
policyManagementAuthority);
|
||||
}
|
||||
|
||||
private void stubSinglePageDocument() throws Exception {
|
||||
@@ -88,7 +98,12 @@ class ClassifyLabelControllerTest {
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception {
|
||||
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
labelStore.save(
|
||||
TEAM,
|
||||
new ClassificationLabels(
|
||||
List.of(new ClassificationLabel("invoice", "Invoice", null))),
|
||||
"admin");
|
||||
|
||||
stubSinglePageDocument();
|
||||
|
||||
@@ -103,12 +118,16 @@ class ClassifyLabelControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_sendsLabelIdsAndNames() throws Exception {
|
||||
withLabels(
|
||||
List.of(
|
||||
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
|
||||
new ClassificationLabel("contract", "Contract", null),
|
||||
new ClassificationLabel("timesheet", "Timesheet", null)));
|
||||
void classifyAndLabel_sendsTeamLabelIdsAndNames() throws Exception {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
labelStore.save(
|
||||
TEAM,
|
||||
new ClassificationLabels(
|
||||
List.of(
|
||||
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
|
||||
new ClassificationLabel("contract", "Contract", null),
|
||||
new ClassificationLabel("timesheet", "Timesheet", null))),
|
||||
"admin");
|
||||
|
||||
stubSinglePageDocument();
|
||||
|
||||
@@ -133,13 +152,13 @@ class ClassifyLabelControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_skipsClassificationWhenNoLabels() throws Exception {
|
||||
withLabels(List.of());
|
||||
void classifyAndLabel_skipsClassificationWhenNothingStored() throws Exception {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
|
||||
stubSinglePageDocument();
|
||||
|
||||
// No vocabulary, and the engine holds no default of its own, so the file is passed through
|
||||
// unlabelled: neither the engine nor the metadata write is invoked.
|
||||
// No team labels stored, and the engine holds no default of its own, so the file is passed
|
||||
// through unlabelled: neither the engine nor the metadata write is invoked.
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString(), any());
|
||||
verify(pdfMetadataService, never())
|
||||
.setClassificationMetadata(any(PDDocument.class), anyString());
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Classification labels are now a fixed, built-in set bundled with the app and sent to the engine
|
||||
-- per request (see ClassificationLabelProvider); the per-team classification_labels table (created
|
||||
-- in V30) is no longer read or written. Drop it.
|
||||
--
|
||||
-- Forward migration: V30 is kept so any DB that already applied it still validates. This runs after
|
||||
-- V30 in every case, so it drops the table whether V30 just created it (fresh DB) or it was created
|
||||
-- and populated on an earlier deploy. IF EXISTS only guards the edge case where the table is already
|
||||
-- absent, keeping the migration safe to apply regardless of prior state.
|
||||
|
||||
DROP TABLE IF EXISTS classification_labels;
|
||||
@@ -116,8 +116,8 @@ class DocumentClassifierAgent:
|
||||
)
|
||||
|
||||
async def classify(self, request: ClassifyDocumentRequest) -> ClassifyDocumentResponse:
|
||||
# The caller (the backend) always supplies the allowed vocabulary — its
|
||||
# fixed built-in label set — so the engine holds no vocabulary of its own.
|
||||
# The caller (the backend) always supplies the allowed vocabulary — the
|
||||
# team's stored labels — so the engine holds no vocabulary of its own.
|
||||
allowed = request.labels
|
||||
window = select_window(request.pages)
|
||||
prompt = self._build_prompt(request.file_name, allowed, window)
|
||||
|
||||
@@ -3708,6 +3708,8 @@ openFromComputer = "Open from computer"
|
||||
openSettings = "Open settings"
|
||||
other = "Other"
|
||||
recent = "Recent"
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search files..."
|
||||
viewAll = "View all {{count}} files"
|
||||
|
||||
[fileSidebar.fileItem]
|
||||
@@ -3747,6 +3749,7 @@ backToMyFiles = "Back to My Files"
|
||||
breadcrumbs = "Folder path"
|
||||
cancel = "Cancel"
|
||||
classification = "Classification"
|
||||
clearSearch = "Clear search"
|
||||
clearSelection = "Clear selection"
|
||||
closeDetails = "Close details"
|
||||
create = "Create"
|
||||
@@ -3810,6 +3813,8 @@ resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; doub
|
||||
save = "Save"
|
||||
saveToServer = "Save to server"
|
||||
saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search this folder & subfolders"
|
||||
selectAll = "Select all"
|
||||
selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range."
|
||||
selectedCount = "{{count}} selected"
|
||||
@@ -3869,10 +3874,6 @@ title = "No cloud files yet"
|
||||
hint = "Files saved without uploading stay here. Drop a file to add one."
|
||||
title = "No local-only files"
|
||||
|
||||
[filesPage.empty.noResults]
|
||||
hint = "No files in this folder match your filter. Try a different term or clear the filter."
|
||||
title = "No matching files"
|
||||
|
||||
[filesPage.empty.recent]
|
||||
hint = "Files you open or edit will appear here."
|
||||
title = "Nothing modified yet"
|
||||
@@ -3944,11 +3945,6 @@ localHint = "Only stored in this browser"
|
||||
shared = "Shared"
|
||||
sharedHint = "Shared with you via link"
|
||||
|
||||
[filesPage.search]
|
||||
clear = "Clear filter"
|
||||
label = "Filter files by name"
|
||||
placeholder = "Filter files…"
|
||||
|
||||
[filesPage.sort]
|
||||
modifiedAsc = "Oldest first"
|
||||
modifiedDesc = "Recent first"
|
||||
@@ -7895,6 +7891,15 @@ viewAll = "View all"
|
||||
description = "Pipeline runs, deploys and agent events will appear here."
|
||||
title = "Nothing here yet"
|
||||
|
||||
[portal.search]
|
||||
ariaLabel = "Search"
|
||||
placeholder = "Search Stirling — endpoints, pipelines, docs…"
|
||||
|
||||
[portal.search.empty]
|
||||
noActionsDescription = "Quick actions will appear here once they're available."
|
||||
noActionsTitle = "No quick actions"
|
||||
noMatches = "No matches for \"{{query}}\""
|
||||
noMatchesDescription = "Try a different keyword or browse the catalogue."
|
||||
|
||||
[portal.settings.groups]
|
||||
admin = "Admin"
|
||||
@@ -9717,17 +9722,6 @@ title = "Upload to Server"
|
||||
updateButton = "Update on Server"
|
||||
uploadButton = "Upload to Server"
|
||||
|
||||
[superSearch]
|
||||
ariaLabel = "Super search"
|
||||
hint = "Type to search"
|
||||
placeholder = "Search Stirling"
|
||||
|
||||
[superSearch.group]
|
||||
files = "Files"
|
||||
processor = "Processor"
|
||||
settings = "Settings"
|
||||
tools = "Tools"
|
||||
|
||||
[survey]
|
||||
title = "Stirling-PDF Survey"
|
||||
|
||||
@@ -10717,7 +10711,6 @@ exitRedaction = "Exit Redaction Mode"
|
||||
exportAll = "Export PDF"
|
||||
exportSelected = "Export Selected Pages"
|
||||
formFill = "Fill Form"
|
||||
hideToolbar = "Hide toolbar"
|
||||
multiTool = "Multi-Tool"
|
||||
panMode = "Pan Mode"
|
||||
print = "Print PDF"
|
||||
@@ -10737,7 +10730,6 @@ selectAll = "Select All"
|
||||
selectByNumber = "Select by Page Numbers"
|
||||
selectLanguage = "Select language"
|
||||
share = "Share"
|
||||
showToolbar = "Show toolbar"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
toggleAttachments = "Toggle Attachments"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
|
||||
@@ -3719,6 +3719,8 @@ openFromComputer = "Open from computer"
|
||||
openSettings = "Open settings"
|
||||
other = "Other"
|
||||
recent = "Recent"
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search files..."
|
||||
viewAll = "View all {{count}} files"
|
||||
|
||||
[fileSidebar.fileItem]
|
||||
@@ -3732,9 +3734,20 @@ uploadToServer = "Upload to server"
|
||||
versionHistory = "Version history"
|
||||
|
||||
[fileSidebar.groupsModal]
|
||||
add = "Add"
|
||||
addLabel = "Add a label…"
|
||||
categoryIconAria = "Choose an icon for {{name}}"
|
||||
createCategory = "Create category"
|
||||
delete = "Delete category"
|
||||
done = "Done"
|
||||
reset = "Show all"
|
||||
subtitle = "Show or hide categories in the files sidebar."
|
||||
hide = "Hide category"
|
||||
newCategory = "New category name…"
|
||||
removeLabel = "Remove {{name}}"
|
||||
rename = "Rename"
|
||||
reset = "Reset to defaults"
|
||||
search = "Search labels…"
|
||||
show = "Show category"
|
||||
subtitle = "Group your files into parent categories. Add existing or new labels to a category, rename it, or create your own. Files in none of your categories appear under “Other”."
|
||||
title = "Sidebar categories"
|
||||
|
||||
[filesPage]
|
||||
@@ -3747,6 +3760,7 @@ backToMyFiles = "Back to My Files"
|
||||
breadcrumbs = "Folder path"
|
||||
cancel = "Cancel"
|
||||
classification = "Classification"
|
||||
clearSearch = "Clear search"
|
||||
clearSelection = "Clear selection"
|
||||
closeDetails = "Close details"
|
||||
create = "Create"
|
||||
@@ -3810,6 +3824,8 @@ resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; doub
|
||||
save = "Save"
|
||||
saveToServer = "Save to server"
|
||||
saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search this folder & subfolders"
|
||||
selectAll = "Select all"
|
||||
selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range."
|
||||
selectedCount = "{{count}} selected"
|
||||
@@ -3869,10 +3885,6 @@ title = "No cloud files yet"
|
||||
hint = "Files saved without uploading stay here. Drop a file to add one."
|
||||
title = "No local-only files"
|
||||
|
||||
[filesPage.empty.noResults]
|
||||
hint = "No files in this folder match your filter. Try a different term or clear the filter."
|
||||
title = "No matching files"
|
||||
|
||||
[filesPage.empty.recent]
|
||||
hint = "Files you open or edit will appear here."
|
||||
title = "Nothing modified yet"
|
||||
@@ -3944,11 +3956,6 @@ localHint = "Only stored in this browser"
|
||||
shared = "Shared"
|
||||
sharedHint = "Shared with you via link"
|
||||
|
||||
[filesPage.search]
|
||||
clear = "Clear filter"
|
||||
label = "Filter files by name"
|
||||
placeholder = "Filter files…"
|
||||
|
||||
[filesPage.sort]
|
||||
modifiedAsc = "Oldest first"
|
||||
modifiedDesc = "Recent first"
|
||||
@@ -6007,11 +6014,31 @@ summaryMore = "{{first}}, {{second}} and {{more}} more"
|
||||
summaryTwo = "{{first}} and {{second}}"
|
||||
|
||||
[policies.labels]
|
||||
categoryCount = "categories"
|
||||
hideCategory = "Hide category"
|
||||
labelCount = "labels"
|
||||
sharedNote = "These labels are built in and shared across your whole team."
|
||||
showCategory = "Show category"
|
||||
add = "Add"
|
||||
addPlaceholder = "Add a label…"
|
||||
customNote = "Customized for your team."
|
||||
defaultNote = "Using the built-in default, shared with your team."
|
||||
duplicate = "\"{{name}}\" already exists."
|
||||
edit = "Edit labels"
|
||||
empty = "No labels yet."
|
||||
export = "Export JSON"
|
||||
iconAria = "Choose an icon for {{name}}"
|
||||
import = "Import JSON"
|
||||
importError = "Couldn't import that file."
|
||||
managedNote = "Team labels are managed by your team leader."
|
||||
modalSubtitle = "Shared with your whole team. The classifier picks the labels that fit each document."
|
||||
modalTitle = "Classification labels"
|
||||
pickIcon = "Use {{label}} icon"
|
||||
removeAria = "Remove {{name}}"
|
||||
resetToDefault = "Reset to default"
|
||||
saveForTeam = "Save for team"
|
||||
saving = "Saving…"
|
||||
sectionLabel = "Classification labels"
|
||||
startFromScratch = "Start from scratch"
|
||||
teamCount = "team labels"
|
||||
tooLong = "Labels can be at most {{max}} characters."
|
||||
ungrouped = "Ungrouped"
|
||||
view = "View labels"
|
||||
|
||||
[policies.pii]
|
||||
account = "Account numbers (labelled)"
|
||||
@@ -7424,11 +7451,6 @@ title = "Policies"
|
||||
[portal.policies.card]
|
||||
comingSoon = "Upgrade to Enterprise"
|
||||
notSetUp = "Not set up"
|
||||
requiresAiEngine = "Requires AI engine"
|
||||
|
||||
[portal.policies.categories.classification]
|
||||
desc = "Identify each document's type on upload and tag its metadata for filing and search."
|
||||
label = "Classification"
|
||||
|
||||
[portal.policies.categories.compliance]
|
||||
desc = "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document."
|
||||
@@ -7453,13 +7475,6 @@ label = "Security"
|
||||
[portal.policies.config]
|
||||
scopeAll = "All documents"
|
||||
|
||||
[portal.policies.config.classification]
|
||||
summary = "Classifies every uploaded document against your team's labels and tags its metadata."
|
||||
|
||||
[portal.policies.config.classification.rules]
|
||||
0 = "Classify"
|
||||
1 = "Tag metadata"
|
||||
|
||||
[portal.policies.config.compliance]
|
||||
summary = "Validates documents against regulatory frameworks before they leave the system."
|
||||
|
||||
@@ -7554,7 +7569,6 @@ title = "No activity yet"
|
||||
[portal.policies.endpoints]
|
||||
addWatermark = "Watermark"
|
||||
autoRedact = "Redact PII"
|
||||
classifyAndLabel = "Classify"
|
||||
compressPdf = "Compress"
|
||||
flatten = "Flatten"
|
||||
ocrPdf = "OCR"
|
||||
@@ -7597,10 +7611,6 @@ continue = "Continue"
|
||||
enablePolicy = "Enable policy"
|
||||
saveChanges = "Save changes"
|
||||
|
||||
[portal.policies.wizard.capability.classify]
|
||||
desc = "Identifies the document's type from your team's labels and tags it, so it files and searches by category."
|
||||
label = "Classify the document"
|
||||
|
||||
[portal.policies.wizard.capability.compress]
|
||||
desc = "Compresses the document to a smaller file size."
|
||||
label = "Reduce file size"
|
||||
@@ -7625,10 +7635,6 @@ label = "Strip active content"
|
||||
desc = "Stamps a visible mark (e.g. “Confidential”) across every page."
|
||||
label = "Apply a watermark"
|
||||
|
||||
[portal.policies.wizard.classification]
|
||||
description = "Every uploaded document is classified against the built-in labels and tagged with the types that fit. The label set is shared across your whole team."
|
||||
labelsHeading = "Classification labels"
|
||||
|
||||
[portal.policies.wizard.errors]
|
||||
noTools = "Enable at least one tool in the workflow first."
|
||||
saveFailed = "Couldn't save the policy. Please try again."
|
||||
@@ -7985,6 +7991,16 @@ viewAll = "View all"
|
||||
description = "Pipeline runs, deploys and agent events will appear here."
|
||||
title = "Nothing here yet"
|
||||
|
||||
[portal.search]
|
||||
ariaLabel = "Search"
|
||||
placeholder = "Search Stirling — endpoints, pipelines, docs…"
|
||||
|
||||
[portal.search.empty]
|
||||
noActionsDescription = "Quick actions will appear here once they're available."
|
||||
noActionsTitle = "No quick actions"
|
||||
noMatches = "No matches for \"{{query}}\""
|
||||
noMatchesDescription = "Try a different keyword or browse the catalogue."
|
||||
|
||||
[portal.settings.groups]
|
||||
admin = "Admin"
|
||||
|
||||
@@ -9838,17 +9854,6 @@ title = "Upload to Server"
|
||||
updateButton = "Update on Server"
|
||||
uploadButton = "Upload to Server"
|
||||
|
||||
[superSearch]
|
||||
ariaLabel = "Super search"
|
||||
hint = "Type to search"
|
||||
placeholder = "Search Stirling"
|
||||
|
||||
[superSearch.group]
|
||||
files = "Files"
|
||||
processor = "Processor"
|
||||
settings = "Settings"
|
||||
tools = "Tools"
|
||||
|
||||
[survey]
|
||||
title = "Stirling-PDF Survey"
|
||||
|
||||
@@ -10407,6 +10412,9 @@ disableColorFilter = "Disable Color Filter"
|
||||
dualPageView = "Dual Page View"
|
||||
enableDarkFilter = "Enable Dark Filter"
|
||||
enableSepiaFilter = "Enable Sepia Filter"
|
||||
engineLoadErrorBody = "The PDF engine couldn't be loaded. If this keeps happening, make sure the app is up to date, then try again."
|
||||
engineLoadErrorRetry = "Retry"
|
||||
engineLoadErrorTitle = "Couldn't load the PDF viewer"
|
||||
firstPage = "First Page"
|
||||
lastPage = "Last Page"
|
||||
moreOptions = "More"
|
||||
@@ -10871,7 +10879,6 @@ exitRedaction = "Exit Redaction Mode"
|
||||
exportAll = "Export PDF"
|
||||
exportSelected = "Export Selected Pages"
|
||||
formFill = "Fill Form"
|
||||
hideToolbar = "Hide toolbar"
|
||||
multiTool = "Multi-Tool"
|
||||
panMode = "Pan Mode"
|
||||
print = "Print PDF"
|
||||
@@ -10891,7 +10898,6 @@ selectAll = "Select All"
|
||||
selectByNumber = "Select by Page Numbers"
|
||||
selectLanguage = "Select language"
|
||||
share = "Share"
|
||||
showToolbar = "Show toolbar"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
toggleAttachments = "Toggle Attachments"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
|
||||
@@ -16,7 +16,6 @@ import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutli
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
|
||||
import { FileId } from "@app/types/file";
|
||||
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
|
||||
@@ -90,8 +89,6 @@ interface FileGridProps {
|
||||
onChangeSortMode?: (mode: FilesPageSortMode) => void;
|
||||
/** Drives the empty-state copy. */
|
||||
currentTab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe";
|
||||
/** A filter is applied; an empty result then means "no matches", not "no files". */
|
||||
searchActive?: boolean;
|
||||
/** Cloud reachability; switches the cloud empty-state copy. */
|
||||
serverReachable?: boolean;
|
||||
/** Empty-state CTA handlers; if absent the matching button hides. */
|
||||
@@ -107,7 +104,6 @@ export function FileGrid(props: FileGridProps & { loading?: boolean }) {
|
||||
entries,
|
||||
loading,
|
||||
currentTab,
|
||||
searchActive,
|
||||
serverReachable,
|
||||
onEmptyUpload,
|
||||
onEmptyCreateFolder,
|
||||
@@ -119,28 +115,15 @@ export function FileGrid(props: FileGridProps & { loading?: boolean }) {
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
const emptyState = (
|
||||
return (
|
||||
<EmptyState
|
||||
tab={currentTab}
|
||||
searchActive={searchActive}
|
||||
serverReachable={serverReachable}
|
||||
onUpload={onEmptyUpload}
|
||||
onCreateFolder={onEmptyCreateFolder}
|
||||
newFolderDisabledReason={newFolderDisabledReason}
|
||||
/>
|
||||
);
|
||||
// When a filter empties the list view, keep the column headers in place and
|
||||
// show the no-results message beneath them, rather than replacing the whole
|
||||
// table. Grid view (cards, no headers) just shows the empty state.
|
||||
if (viewMode === "list" && searchActive) {
|
||||
return (
|
||||
<>
|
||||
<ListView {...props} />
|
||||
{emptyState}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return emptyState;
|
||||
}
|
||||
|
||||
if (viewMode === "list") {
|
||||
@@ -204,8 +187,6 @@ function SkeletonGrid({ viewMode }: { viewMode: FilesPageViewMode }) {
|
||||
interface EmptyStateProps {
|
||||
/** Drives copy + iconography. */
|
||||
tab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe";
|
||||
/** When true the empty list is the result of a filter, not a bare folder. */
|
||||
searchActive?: boolean;
|
||||
/** Switches the cloud empty-state copy. */
|
||||
serverReachable?: boolean;
|
||||
/** CTA handlers; absent => button hidden. */
|
||||
@@ -217,35 +198,12 @@ interface EmptyStateProps {
|
||||
|
||||
function EmptyState({
|
||||
tab = "all",
|
||||
searchActive = false,
|
||||
serverReachable = true,
|
||||
onUpload,
|
||||
onCreateFolder,
|
||||
newFolderDisabledReason,
|
||||
}: EmptyStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// A filter with no matches isn't an empty folder - say so, and skip the
|
||||
// upload / new-folder CTAs since clearing the filter is the way out.
|
||||
if (searchActive) {
|
||||
return (
|
||||
<div className="files-page-empty">
|
||||
<span className="files-page-empty-icon">
|
||||
<SearchIcon style={{ fontSize: "2.5rem" }} />
|
||||
</span>
|
||||
<div className="files-page-empty-title">
|
||||
{t("filesPage.empty.noResults.title", "No matching files")}
|
||||
</div>
|
||||
<div className="files-page-empty-hint">
|
||||
{t(
|
||||
"filesPage.empty.noResults.hint",
|
||||
"No files in this folder match your filter. Try a different term or clear the filter.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { titleKey, titleFallback, hintKey, hintFallback } = (() => {
|
||||
switch (tab) {
|
||||
case "local":
|
||||
|
||||
@@ -7,19 +7,11 @@ import React, {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Drawer,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Select,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Drawer, Group, MultiSelect, Select, Tooltip } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { SegmentedControl } from "@app/ui/SegmentedControl";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import QrCode2Icon from "@mui/icons-material/QrCode2";
|
||||
@@ -59,7 +51,6 @@ import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { FolderId, ROOT_FOLDER_ID } from "@app/types/folder";
|
||||
|
||||
import { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid";
|
||||
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
|
||||
import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel";
|
||||
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
|
||||
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
||||
@@ -694,19 +685,13 @@ export default function FileManagerView() {
|
||||
}, [navigate]);
|
||||
|
||||
// ─── keyboard shortcuts ─────────────────────────────────────────────────
|
||||
// Focus the super-search input (stable id), used by the "/" shortcut and the
|
||||
// FileSidebar rail Search button.
|
||||
const focusSearch = useCallback(() => {
|
||||
(
|
||||
document.getElementById("super-search-input") as HTMLInputElement | null
|
||||
)?.focus();
|
||||
}, []);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
// External focus trigger (used by the FileSidebar rail Search button).
|
||||
useEffect(() => {
|
||||
window.addEventListener("files-page:focus-search", focusSearch);
|
||||
return () =>
|
||||
window.removeEventListener("files-page:focus-search", focusSearch);
|
||||
}, [focusSearch]);
|
||||
const onFocus = () => searchInputRef.current?.focus();
|
||||
window.addEventListener("files-page:focus-search", onFocus);
|
||||
return () => window.removeEventListener("files-page:focus-search", onFocus);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
@@ -749,19 +734,13 @@ export default function FileManagerView() {
|
||||
// "/" focuses the search field.
|
||||
if (e.key === "/" && !inInput) {
|
||||
e.preventDefault();
|
||||
focusSearch();
|
||||
searchInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [
|
||||
visibleFiles,
|
||||
selectedFileIds,
|
||||
removeFiles,
|
||||
setSelectedFileIds,
|
||||
focusSearch,
|
||||
]);
|
||||
}, [visibleFiles, selectedFileIds, removeFiles, setSelectedFileIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -910,7 +889,11 @@ export default function FileManagerView() {
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SuperSearch />
|
||||
<SearchField
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
<div className="files-page-header-actions">
|
||||
<Tooltip
|
||||
label={
|
||||
@@ -1359,27 +1342,6 @@ export default function FileManagerView() {
|
||||
aria-label={t("filesPage.typeFilter.label", "Filter by type")}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
placeholder={t("filesPage.search.placeholder", "Filter files…")}
|
||||
leftSection={<SearchIcon sx={{ fontSize: "1rem" }} />}
|
||||
rightSection={
|
||||
search ? (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label={t("filesPage.search.clear", "Clear filter")}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "0.9rem" }} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
aria-label={t("filesPage.search.label", "Filter files by name")}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
value={sortMode}
|
||||
@@ -1471,7 +1433,6 @@ export default function FileManagerView() {
|
||||
entries={entries}
|
||||
loading={loading}
|
||||
currentTab={currentTab}
|
||||
searchActive={search.trim().length > 0}
|
||||
serverReachable={folders.serverReachable}
|
||||
selectedFileIds={selectedFileIds}
|
||||
activeWorkspaceFileIds={activeWorkspaceFileIdSet}
|
||||
@@ -1694,6 +1655,39 @@ export default function FileManagerView() {
|
||||
);
|
||||
}
|
||||
|
||||
const SearchField = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
{ value: string; onChange: (v: string) => void }
|
||||
>(function SearchField({ value, onChange }, ref) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="files-page-search">
|
||||
<SearchIcon fontSize="small" style={{ color: "var(--text-muted)" }} />
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={t(
|
||||
"filesPage.searchPlaceholder",
|
||||
"Search this folder & subfolders",
|
||||
)}
|
||||
aria-label={t("filesPage.search", "Search")}
|
||||
/>
|
||||
{value && (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => onChange("")}
|
||||
aria-label={t("filesPage.clearSearch", "Clear search")}
|
||||
>
|
||||
×
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function Breadcrumbs() {
|
||||
const { t } = useTranslation();
|
||||
const folders = useFolders();
|
||||
|
||||
@@ -1,40 +1,3 @@
|
||||
/* Positioning context for the viewer toolbar's reopen tab, which hangs below
|
||||
the bar and so must sit outside the overflow-clipped wrapper below. */
|
||||
.workbenchBarShell {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
/* Little pull-tab shown while the viewer tool row is retracted. Anchored to the
|
||||
bar's bottom-right edge (where the retract handle sat). Small by design - it
|
||||
just brings the row back. */
|
||||
.workbenchBarReopenTab {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 0.875rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-top: none;
|
||||
border-radius: 0 0 7px 7px;
|
||||
background: var(--bg-toolbar);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
z-index: 52;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.workbenchBarReopenTab:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
/* WorkbenchBar slide-in/out animation using CSS grid trick */
|
||||
.workbenchBarWrapper {
|
||||
display: grid;
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useEffect, useState, Suspense, lazy } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import { Box, Loader, Center } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
@@ -70,13 +67,10 @@ export default function Workbench() {
|
||||
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
||||
const { addFiles } = useFileHandler();
|
||||
const hasFiles = activeFiles.length > 0;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// The viewer's tool row can be retracted to give the document more height.
|
||||
// State lives here (not in WorkbenchBar) so the reopen tab can hang below the
|
||||
// bar, outside the bar's overflow-clipped wrapper. Scoped to the viewer.
|
||||
const [viewerToolbarCollapsed, setViewerToolbarCollapsed] = useState(false);
|
||||
const showReopenTab = currentView === "viewer" && viewerToolbarCollapsed;
|
||||
// Custom workbench views (e.g. Watched Folders) manage their own content and may
|
||||
// have no workbench files, but still need the bar's view switcher so users can
|
||||
// navigate back out.
|
||||
const isCustomViewActive = !isBaseWorkbench(currentView);
|
||||
|
||||
// Enable bar transitions after first paint so the initial hidden state shows
|
||||
// without animating (landing page on load shouldn't animate the bar up).
|
||||
@@ -230,38 +224,18 @@ export default function Workbench() {
|
||||
{currentView !== "myFiles" &&
|
||||
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
|
||||
?.hideTopControls && (
|
||||
<div className={styles.workbenchBarShell}>
|
||||
<div
|
||||
className={styles.workbenchBarWrapper}
|
||||
data-hidden="false"
|
||||
data-no-transition={String(!barTransitionEnabled)}
|
||||
>
|
||||
<div className={styles.workbenchBarInner}>
|
||||
<WorkbenchBar
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
hasFiles={hasFiles}
|
||||
viewerToolbarCollapsed={viewerToolbarCollapsed}
|
||||
onCollapseViewerToolbar={setViewerToolbarCollapsed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Reopen tab: a little handle hanging off the bar's bottom-right
|
||||
while the viewer tool row is retracted. */}
|
||||
{showReopenTab && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
className={styles.workbenchBarReopenTab}
|
||||
onClick={() => setViewerToolbarCollapsed(false)}
|
||||
aria-expanded={false}
|
||||
aria-label={t("workbenchBar.showToolbar", "Show toolbar")}
|
||||
title={t("workbenchBar.showToolbar", "Show toolbar")}
|
||||
leftSection={
|
||||
<KeyboardArrowDownIcon sx={{ fontSize: "1rem" }} />
|
||||
}
|
||||
<div
|
||||
className={styles.workbenchBarWrapper}
|
||||
data-hidden={String(!hasFiles && !isCustomViewActive)}
|
||||
data-no-transition={String(!barTransitionEnabled)}
|
||||
>
|
||||
<div className={styles.workbenchBarInner}>
|
||||
<WorkbenchBar
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
hasFiles={hasFiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
// Shared source of truth for a policy category's outline icon, keyed by category
|
||||
// id (not a parallel icon-name vocabulary). Used by the editor's policy
|
||||
// definitions and the portal's catalogue cards, summaries, and setup wizard.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { SxProps, Theme } from "@mui/material";
|
||||
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined";
|
||||
import CheckCircleOutlinedIcon from "@mui/icons-material/CheckCircleOutlined";
|
||||
import AltRouteOutlinedIcon from "@mui/icons-material/AltRouteOutlined";
|
||||
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
|
||||
|
||||
type MuiIcon = React.ComponentType<{ sx?: SxProps<Theme>; className?: string }>;
|
||||
|
||||
/** Policy category id → outline glyph. */
|
||||
const POLICY_CATEGORY_ICONS: Record<string, MuiIcon> = {
|
||||
ingestion: LayersOutlinedIcon,
|
||||
security: ShieldOutlinedIcon,
|
||||
classification: LabelOutlinedIcon,
|
||||
compliance: CheckCircleOutlinedIcon,
|
||||
routing: AltRouteOutlinedIcon,
|
||||
retention: ScheduleOutlinedIcon,
|
||||
};
|
||||
|
||||
const FALLBACK_ICON = LabelOutlinedIcon;
|
||||
|
||||
// Defaults to inheriting the surrounding font-size so a wrapping box controls size.
|
||||
export function policyCategoryIcon(
|
||||
categoryId: string,
|
||||
sx: SxProps<Theme> = { fontSize: "inherit" },
|
||||
className?: string,
|
||||
): ReactNode {
|
||||
const Icon = POLICY_CATEGORY_ICONS[categoryId] ?? FALLBACK_ICON;
|
||||
return <Icon sx={sx} className={className} />;
|
||||
}
|
||||
@@ -1,28 +1,4 @@
|
||||
/* AppConfigModal styles */
|
||||
|
||||
/* Deep-link highlight: pulses the control the super search jumped to
|
||||
(navigated via /settings/{section}?focus={anchor}). */
|
||||
@keyframes settings-focus-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 3px var(--mantine-color-blue-5);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-5) 16%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 6px transparent;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-focus-target {
|
||||
animation: settings-focus-pulse 1.8s ease-out;
|
||||
border-radius: 8px;
|
||||
scroll-margin: 1rem;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
|
||||
@@ -146,33 +146,6 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
[navigate, urlSync],
|
||||
);
|
||||
|
||||
// Deep-link: /settings/{section}?focus={anchor} scrolls to and briefly
|
||||
// highlights the matching control (used by the global super search to jump
|
||||
// straight to an individual setting row).
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
const focus = new URLSearchParams(location.search).get("focus");
|
||||
if (!focus) return;
|
||||
let raf = 0;
|
||||
// Wait for the (possibly just-switched) section to render before scrolling.
|
||||
const timer = window.setTimeout(() => {
|
||||
raf = window.requestAnimationFrame(() => {
|
||||
const el = document.getElementById(focus);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el.classList.add("settings-focus-target");
|
||||
window.setTimeout(
|
||||
() => el.classList.remove("settings-focus-target"),
|
||||
1800,
|
||||
);
|
||||
});
|
||||
}, 150);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
if (raf) window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [opened, active, location.search]);
|
||||
|
||||
// Backwards-compat: external `appConfig:navigate` events route through the
|
||||
// same switchSection path so they get the no-flash treatment too.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -33,9 +33,11 @@ import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
@@ -99,6 +101,8 @@ export interface FileSidebarProps {
|
||||
onUploadFiles?: (files: File[]) => void | Promise<void>;
|
||||
/** Override the Google Drive handler. */
|
||||
onPickGoogleDriveFiles?: (files: File[]) => void | Promise<void>;
|
||||
/** Override the Search row click (e.g. focus the /files search input). */
|
||||
onSearchClick?: () => void;
|
||||
/** Extra action row inserted under Open-from-computer (e.g. New folder). */
|
||||
extraAction?: {
|
||||
icon: React.ReactNode;
|
||||
@@ -152,6 +156,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
toggleIcon,
|
||||
onUploadFiles,
|
||||
onPickGoogleDriveFiles,
|
||||
onSearchClick,
|
||||
extraAction,
|
||||
},
|
||||
ref,
|
||||
@@ -162,6 +167,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
// Classification off (non-SaaS / AI-off) → never show the per-row label chip,
|
||||
// even if a stub carries labels from an imported PDF; keeps the row plain.
|
||||
const classificationEnabled = useClassificationEnabled();
|
||||
const [searchActive, setSearchActive] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const nativeFileInputRef = useRef<HTMLInputElement>(null);
|
||||
// State (not ref) so setting it triggers a re-render - avoids racing addFiles state updates.
|
||||
const [pendingViewFileId, setPendingViewFileId] = useState<string | null>(
|
||||
@@ -430,9 +438,14 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
}
|
||||
}, [pendingViewFileId, state.files.ids, setActiveFileId, navActions]);
|
||||
|
||||
// File filtering now lives in the global super search (top bar); the
|
||||
// sidebar simply lists every stored file.
|
||||
const filteredFileStubs = allFileStubs;
|
||||
// Memoized so an unrelated re-render (e.g. a policy-run store tick) keeps a
|
||||
// stable array identity — avoids re-running the grouping memo + backfill effect.
|
||||
const filteredFileStubs = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
return q
|
||||
? allFileStubs.filter((stub) => stub.name.toLowerCase().includes(q))
|
||||
: allFileStubs;
|
||||
}, [allFileStubs, searchQuery]);
|
||||
|
||||
// SaaS groups by classification label; core returns null → one flat, recency-sorted list.
|
||||
const fileGroups = useFileSidebarGroups(filteredFileStubs);
|
||||
@@ -459,6 +472,29 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle search activation
|
||||
const handleSearchClick = useCallback(() => {
|
||||
if (onSearchClick) {
|
||||
onSearchClick();
|
||||
return;
|
||||
}
|
||||
if (collapsed && onToggleCollapse) {
|
||||
onToggleCollapse();
|
||||
}
|
||||
setSearchActive(true);
|
||||
}, [collapsed, onToggleCollapse, onSearchClick]);
|
||||
|
||||
const handleSearchClose = useCallback(() => {
|
||||
setSearchActive(false);
|
||||
setSearchQuery("");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchActive && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [searchActive]);
|
||||
|
||||
// Handle Google Drive
|
||||
const handleGoogleDriveClick = useCallback(async () => {
|
||||
if (!isGoogleDriveEnabled) return;
|
||||
@@ -849,6 +885,56 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Search row */}
|
||||
<Tooltip
|
||||
label={t("fileSidebar.search", "Search")}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className={`file-sidebar-search-row${searchActive && !collapsed ? " active" : ""}`}
|
||||
onClick={!searchActive ? handleSearchClick : undefined}
|
||||
role={!searchActive ? "button" : undefined}
|
||||
tabIndex={!searchActive ? 0 : undefined}
|
||||
onKeyDown={
|
||||
!searchActive
|
||||
? (e) => e.key === "Enter" && handleSearchClick()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{searchActive && !collapsed ? (
|
||||
<CloseIcon
|
||||
className="file-sidebar-search-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSearchClose();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SearchIcon className="file-sidebar-search-icon" />
|
||||
)}
|
||||
{!collapsed &&
|
||||
(searchActive ? (
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="file-sidebar-search-input sidebar-content-fade"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t(
|
||||
"fileSidebar.searchPlaceholder",
|
||||
"Search files...",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="file-sidebar-search-label sidebar-content-fade">
|
||||
{t("fileSidebar.search", "Search")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="file-sidebar-scroll">
|
||||
{/* Hidden native file input - kept outside the !collapsed gate so
|
||||
@@ -1196,14 +1282,16 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="file-sidebar-empty">
|
||||
<p className="file-sidebar-empty-text">
|
||||
{t("fileSidebar.noFiles", "No files yet")}
|
||||
</p>
|
||||
<p className="file-sidebar-empty-hint">
|
||||
{t("fileSidebar.dropHint", "Open files to get started")}
|
||||
</p>
|
||||
</div>
|
||||
!searchActive && (
|
||||
<div className="file-sidebar-empty">
|
||||
<p className="file-sidebar-empty-text">
|
||||
{t("fileSidebar.noFiles", "No files yet")}
|
||||
</p>
|
||||
<p className="file-sidebar-empty-hint">
|
||||
{t("fileSidebar.dropHint", "Open files to get started")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,17 +2,11 @@
|
||||
/* Horizontal toolbar at the top of the workbench area. */
|
||||
|
||||
.workbench-bar {
|
||||
/* Flex-wrap layout that reflows by content width (measured in JS, which sets
|
||||
data-wrapped). Roomy: row 1 is [views | search | globals] and the tool
|
||||
icons sit on their own full-width row below. Crowded (data-wrapped): the
|
||||
search bumps to its own row, leaving [views | globals] on row 1 and the
|
||||
tools on row 3 — three rows total. `order` drives the reflow. */
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
align-content: flex-start;
|
||||
column-gap: 8px;
|
||||
min-height: 48px;
|
||||
min-height: 40px;
|
||||
padding: 0 8px;
|
||||
background-color: var(--bg-toolbar);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
@@ -22,16 +16,12 @@
|
||||
|
||||
/* ---- View switcher (left) ---- */
|
||||
.workbench-bar-views {
|
||||
order: 1;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
/* min-width:0 lets it shrink instead of forcing a wrap; overflow clips labels
|
||||
on very narrow viewports. */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
order: 1;
|
||||
height: 40px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@@ -95,88 +85,53 @@
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ---- Super search ---- */
|
||||
/* Roomy: grows to fill the centre between views and globals. */
|
||||
.workbench-bar-search {
|
||||
/* ---- Center: tool buttons ---- */
|
||||
|
||||
/* Single-row: center sits between views and globals */
|
||||
.workbench-bar-center {
|
||||
order: 2;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 48px;
|
||||
gap: 2px;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Crowded: drops to its own full-width row between the top row and the tools. */
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-search {
|
||||
/* Two-row: center drops below views+globals */
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-center {
|
||||
order: 3;
|
||||
flex: 0 0 100%;
|
||||
height: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* ---- Tool buttons (own full-width row, always below the top row) ---- */
|
||||
.workbench-bar-center {
|
||||
order: 4;
|
||||
flex: 0 0 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
/* Symmetric side padding leaves room for the retract handle pinned right
|
||||
without knocking the centred tool icons off-centre. */
|
||||
padding: 4px 36px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Retract / reopen handle for the viewer tool row. */
|
||||
.workbench-bar-toolbar-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.workbench-bar-toolbar-handle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
/* Pinned to the right edge of the tool row. */
|
||||
.workbench-bar-toolbar-handle-retract {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* ---- Right: global buttons (print / download / save / close), row 1 ---- */
|
||||
.workbench-bar-globals {
|
||||
order: 3;
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-center > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Right: global buttons (theme / language / download) ---- */
|
||||
.workbench-bar-globals {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
order: 3;
|
||||
height: 40px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Crowded: globals stays on row 1, moving up beside the views. */
|
||||
/* In two-row mode globals moves to row 1 right side */
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-globals {
|
||||
order: 2;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Shared action icon style - applies to both center and global buttons.
|
||||
|
||||
@@ -32,7 +32,6 @@ import { ViewerContext, useViewer } from "@app/contexts/ViewerContext";
|
||||
import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
|
||||
import ViewerShareButton from "@app/components/viewer/ViewerShareButton";
|
||||
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
|
||||
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||
@@ -53,7 +52,6 @@ import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutl
|
||||
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import PrintIcon from "@mui/icons-material/Print";
|
||||
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import "@app/components/shared/WorkbenchBar.css";
|
||||
|
||||
@@ -69,10 +67,6 @@ interface WorkbenchBarProps {
|
||||
currentView: WorkbenchType;
|
||||
setCurrentView: (view: WorkbenchType) => void;
|
||||
hasFiles: boolean;
|
||||
/** Whether the viewer's tool row is currently retracted. */
|
||||
viewerToolbarCollapsed?: boolean;
|
||||
/** Setter for the viewer tool-row retract state (owned by Workbench). */
|
||||
onCollapseViewerToolbar?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
function renderWithTooltip(
|
||||
@@ -97,8 +91,6 @@ export default function WorkbenchBar({
|
||||
currentView,
|
||||
setCurrentView,
|
||||
hasFiles,
|
||||
viewerToolbarCollapsed = false,
|
||||
onCollapseViewerToolbar,
|
||||
}: WorkbenchBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -122,7 +114,6 @@ export default function WorkbenchBar({
|
||||
} = useToolWorkflow();
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isCustomView = !isBaseWorkbench(currentView);
|
||||
const isViewer = currentView === "viewer";
|
||||
const disableForFullscreen =
|
||||
toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
|
||||
const terminology = useFileActionTerminology();
|
||||
@@ -443,31 +434,34 @@ export default function WorkbenchBar({
|
||||
})),
|
||||
];
|
||||
|
||||
// Reflow the top row by content width: when the views + globals leave too
|
||||
// little room for a usable search, bump the search to its own row
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const bar = barRef.current;
|
||||
if (!bar) return;
|
||||
const MIN_SEARCH_WIDTH = 320;
|
||||
|
||||
const measure = () => {
|
||||
const viewsEl = bar.querySelector<HTMLElement>(".workbench-bar-views");
|
||||
const globalsEl = bar.querySelector<HTMLElement>(
|
||||
".workbench-bar-globals",
|
||||
);
|
||||
const centerEl = bar.querySelector<HTMLElement>(".workbench-bar-center");
|
||||
|
||||
const viewsWidth = viewsEl?.offsetWidth ?? 0;
|
||||
const globalsWidth = globalsEl?.offsetWidth ?? 0;
|
||||
// clientWidth minus the two side clusters, the bar's 16px h-padding and
|
||||
// the two 8px column gaps flanking the search.
|
||||
const available = bar.clientWidth - viewsWidth - globalsWidth - 16 - 16;
|
||||
bar.dataset.wrapped = String(available < MIN_SEARCH_WIDTH);
|
||||
const centerChildren = centerEl
|
||||
? (Array.from(centerEl.children) as HTMLElement[])
|
||||
: [];
|
||||
const centerWidth =
|
||||
centerChildren.reduce((sum, el) => sum + el.offsetWidth, 0) +
|
||||
Math.max(0, centerChildren.length - 1) * 2; // gap: 2px
|
||||
|
||||
const needed = viewsWidth + centerWidth + globalsWidth + 24; // 24px bar padding
|
||||
bar.dataset.wrapped = String(needed > bar.clientWidth);
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(bar);
|
||||
const viewsEl = bar.querySelector<HTMLElement>(".workbench-bar-views");
|
||||
const globalsEl = bar.querySelector<HTMLElement>(".workbench-bar-globals");
|
||||
if (viewsEl) ro.observe(viewsEl);
|
||||
if (globalsEl) ro.observe(globalsEl);
|
||||
measure();
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
@@ -529,50 +523,27 @@ export default function WorkbenchBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Global super search - always present, even on the homepage */}
|
||||
<div className="workbench-bar-search">
|
||||
<SuperSearch />
|
||||
</div>
|
||||
|
||||
{/* Tool buttons - second row, only rendered when buttons exist. In the
|
||||
viewer the row is retractable: a handle on its right edge hides the
|
||||
whole row; Workbench then shows a tab below the bar to bring it back. */}
|
||||
{sectionsWithButtons.length > 0 &&
|
||||
!(isViewer && viewerToolbarCollapsed) && (
|
||||
<div className="workbench-bar-center">
|
||||
{sectionsWithButtons.map(
|
||||
({ section, buttons: sectionButtons }, idx) => (
|
||||
<React.Fragment key={section}>
|
||||
{idx > 0 && <div className="workbench-bar-divider" />}
|
||||
{sectionButtons.map((btn) => {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div
|
||||
key={btn.id}
|
||||
className="workbench-bar-action-wrapper"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
),
|
||||
)}
|
||||
{isViewer && onCollapseViewerToolbar && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
className="workbench-bar-toolbar-handle workbench-bar-toolbar-handle-retract"
|
||||
onClick={() => onCollapseViewerToolbar(true)}
|
||||
aria-expanded
|
||||
aria-label={t("workbenchBar.hideToolbar", "Hide toolbar")}
|
||||
title={t("workbenchBar.hideToolbar", "Hide toolbar")}
|
||||
leftSection={<KeyboardArrowUpIcon sx={{ fontSize: "1rem" }} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Tool buttons - second row, only rendered when buttons exist */}
|
||||
{sectionsWithButtons.length > 0 && (
|
||||
<div className="workbench-bar-center">
|
||||
{sectionsWithButtons.map(
|
||||
({ section, buttons: sectionButtons }, idx) => (
|
||||
<React.Fragment key={section}>
|
||||
{idx > 0 && <div className="workbench-bar-divider" />}
|
||||
{sectionButtons.map((btn) => {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div key={btn.id} className="workbench-bar-action-wrapper">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right: Global buttons - export group left, close anchored right */}
|
||||
<div className="workbench-bar-globals">
|
||||
|
||||
@@ -4,10 +4,6 @@ import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import {
|
||||
buildMatchSnippet,
|
||||
getSettingsSectionContent,
|
||||
} from "@app/data/settingsContentSearch";
|
||||
import type {
|
||||
ConfigNavSection,
|
||||
ConfigNavItem,
|
||||
@@ -28,6 +24,102 @@ interface SettingsSearchOption {
|
||||
matchedContext?: string;
|
||||
}
|
||||
|
||||
const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> =
|
||||
{
|
||||
general: ["settings.general"],
|
||||
hotkeys: ["settings.hotkeys"],
|
||||
account: ["account"],
|
||||
people: ["settings.workspace"],
|
||||
teams: ["settings.workspace", "settings.team"],
|
||||
"api-keys": ["settings.developer"],
|
||||
connectionMode: ["settings.connection"],
|
||||
planBilling: ["settings.planBilling"],
|
||||
adminGeneral: ["admin.settings.general"],
|
||||
adminFeatures: ["admin.settings.features"],
|
||||
adminEndpoints: ["admin.settings.endpoints"],
|
||||
adminDatabase: ["admin.settings.database"],
|
||||
adminAdvanced: ["admin.settings.advanced"],
|
||||
adminSecurity: ["admin.settings.security"],
|
||||
adminMcp: ["admin.settings.mcp"],
|
||||
adminConnections: [
|
||||
"admin.settings.connections",
|
||||
"admin.settings.mail",
|
||||
"admin.settings.security",
|
||||
"admin.settings.telegram",
|
||||
"admin.settings.premium",
|
||||
"admin.settings.general",
|
||||
"settings.securityAuth",
|
||||
"settings.connection",
|
||||
],
|
||||
adminPlan: [
|
||||
"settings.planBilling",
|
||||
"admin.settings.premium",
|
||||
"settings.licensingAnalytics",
|
||||
],
|
||||
adminAudit: ["settings.licensingAnalytics"],
|
||||
adminUsage: ["settings.licensingAnalytics"],
|
||||
adminLegal: ["admin.settings.legal"],
|
||||
adminPrivacy: ["admin.settings.privacy"],
|
||||
};
|
||||
|
||||
const getTranslationPrefixesForNavKey = (key: string): string[] => {
|
||||
const explicitPrefixes = SETTINGS_SEARCH_TRANSLATION_PREFIXES[key] ?? [];
|
||||
|
||||
const inferredPrefixes: string[] = [];
|
||||
|
||||
if (key.startsWith("admin")) {
|
||||
const adminSuffix = key.replace(/^admin/, "");
|
||||
const normalizedAdminSuffix =
|
||||
adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
|
||||
inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`);
|
||||
} else {
|
||||
inferredPrefixes.push(`settings.${key}`);
|
||||
}
|
||||
|
||||
return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes]));
|
||||
};
|
||||
|
||||
const flattenTranslationStrings = (value: unknown): string[] => {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap(flattenTranslationStrings);
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.values(value as Record<string, unknown>).flatMap(
|
||||
flattenTranslationStrings,
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const buildMatchSnippet = (text: string, query: string): string => {
|
||||
const normalizedText = text.toLocaleLowerCase();
|
||||
const normalizedQuery = query.toLocaleLowerCase();
|
||||
const matchIndex = normalizedText.indexOf(normalizedQuery);
|
||||
|
||||
if (matchIndex === -1) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const maxLength = 84;
|
||||
const contextPadding = 28;
|
||||
const start = Math.max(0, matchIndex - contextPadding);
|
||||
const end = Math.min(text.length, matchIndex + query.length + contextPadding);
|
||||
const snippet = text.slice(start, end);
|
||||
|
||||
if (snippet.length <= maxLength) {
|
||||
return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`;
|
||||
}
|
||||
|
||||
return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`;
|
||||
};
|
||||
|
||||
export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
|
||||
configNavSections,
|
||||
onNavigate,
|
||||
@@ -43,12 +135,19 @@ export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
|
||||
section.items
|
||||
.filter((item: ConfigNavItem) => !item.disabled)
|
||||
.map((item: ConfigNavItem) => {
|
||||
const translationPrefixes = getTranslationPrefixesForNavKey(item.key);
|
||||
const translationContent = translationPrefixes.flatMap((prefix) =>
|
||||
flattenTranslationStrings(
|
||||
t(prefix, { returnObjects: true, defaultValue: {} } as any),
|
||||
),
|
||||
);
|
||||
|
||||
const searchableContent = Array.from(
|
||||
new Set([
|
||||
item.label,
|
||||
section.title,
|
||||
`/settings/${item.key}`,
|
||||
...getSettingsSectionContent(item.key, t),
|
||||
...translationContent,
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -469,7 +469,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div
|
||||
id="setting-theme"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -507,7 +506,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="setting-language"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -533,7 +531,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div
|
||||
id="setting-tool-picker-mode"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -572,7 +569,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="setting-startup-view"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -615,7 +611,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="setting-reader-zoom"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -671,7 +666,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="setting-hide-unavailable-tools"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -703,7 +697,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="setting-hide-unavailable-conversions"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -744,7 +737,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
withArrow
|
||||
>
|
||||
<div
|
||||
id="setting-auto-unzip"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -782,7 +774,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
withArrow
|
||||
>
|
||||
<div
|
||||
id="setting-auto-unzip-file-limit"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -146,7 +146,7 @@ const HotkeysSection: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div id="setting-hotkeys-search">
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("settings.hotkeys.title", "Keyboard Shortcuts")}
|
||||
</Text>
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
.super-search {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.super-search input {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-muted);
|
||||
/* Tighter than the shared TextInput default (8px/14px) for a compact,
|
||||
professional bar — height lands in line with the 28px action icons. */
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .super-search input {
|
||||
background-color: var(--input-bg);
|
||||
}
|
||||
|
||||
.super-search input:focus {
|
||||
border-color: var(--accent-interactive);
|
||||
}
|
||||
|
||||
/* Keyboard-shortcut hint pinned to the right of the input. */
|
||||
.super-search-kbd {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
background: var(--hover-bg);
|
||||
border: 1px solid var(--border-subtle);
|
||||
/* Tiny breathing room between the ⌘ glyph and the K. */
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* Portalled to <body>; coords are set inline from the input's viewport rect. */
|
||||
.super-search-dropdown {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.35rem;
|
||||
background: var(--bg-toolbar);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.super-search-empty {
|
||||
padding: 0.75rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.super-search-group + .super-search-group {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.super-search-group-label {
|
||||
padding: 0.35rem 0.5rem 0.2rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.super-search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.super-search-item.active,
|
||||
.super-search-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.super-search-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.super-search-item-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.super-search-item-title {
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { TextInput } from "@app/components/shared/TextInput";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { isMacLike } from "@app/utils/hotkeys";
|
||||
import {
|
||||
useSuperSearch,
|
||||
SuperSearchResult,
|
||||
type UseSuperSearchResult,
|
||||
} from "@app/hooks/useSuperSearch";
|
||||
import "@app/components/shared/superSearch/SuperSearch.css";
|
||||
|
||||
interface DropdownRect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface SuperSearchProps {
|
||||
/**
|
||||
* Results provider, called as a hook — it MUST be referentially stable for
|
||||
* the component's lifetime. Defaults to the editor's files/tools/settings/
|
||||
* Processor provider; the portal passes its own destinations provider.
|
||||
*/
|
||||
useResults?: (query: string, active: boolean) => UseSuperSearchResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global "super search": a single entry point that searches across the host
|
||||
* app's destinations from a persistent bar. The results hang in a dropdown
|
||||
* directly below the input; Cmd/Ctrl+K focuses and opens it.
|
||||
*
|
||||
* The dropdown is portalled to <body>: the host bar's inner wrapper may set
|
||||
* `overflow: hidden`, which would otherwise clip it.
|
||||
*/
|
||||
export default function SuperSearch({
|
||||
useResults = useSuperSearch,
|
||||
}: SuperSearchProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const [rect, setRect] = useState<DropdownRect | null>(null);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { groups, flatResults, loadingFiles } = useResults(query, open);
|
||||
|
||||
const trimmed = query.trim();
|
||||
const hasQuery = trimmed.length > 0;
|
||||
|
||||
// Keep the highlighted index within bounds as results change.
|
||||
useEffect(() => {
|
||||
setHighlight((h) =>
|
||||
flatResults.length === 0 ? 0 : Math.min(h, flatResults.length - 1),
|
||||
);
|
||||
}, [flatResults.length]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
setHighlight(0);
|
||||
}, []);
|
||||
|
||||
// Position the portalled dropdown directly under the input while open.
|
||||
const updateRect = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
setRect({ top: r.bottom + 6, left: r.left, width: r.width });
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updateRect();
|
||||
window.addEventListener("resize", updateRect);
|
||||
window.addEventListener("scroll", updateRect, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateRect);
|
||||
window.removeEventListener("scroll", updateRect, true);
|
||||
};
|
||||
}, [open, updateRect]);
|
||||
|
||||
const selectResult = useCallback(
|
||||
(result: SuperSearchResult | undefined) => {
|
||||
if (!result) return;
|
||||
void result.onSelect();
|
||||
setQuery("");
|
||||
close();
|
||||
inputRef.current?.blur();
|
||||
},
|
||||
[close],
|
||||
);
|
||||
|
||||
// Global Cmd/Ctrl+K to focus + open the search.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const combo =
|
||||
(isMacLike() ? e.metaKey : e.ctrlKey) && !e.altKey && !e.shiftKey;
|
||||
if (combo && (e.key === "k" || e.key === "K")) {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
// Close on click outside the input or the (portalled) dropdown.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
containerRef.current?.contains(target) ||
|
||||
dropdownRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
close();
|
||||
};
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onMouseDown);
|
||||
}, [open, close]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (!open) setOpen(true);
|
||||
setHighlight((h) =>
|
||||
flatResults.length === 0 ? 0 : (h + 1) % flatResults.length,
|
||||
);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlight((h) =>
|
||||
flatResults.length === 0
|
||||
? 0
|
||||
: (h - 1 + flatResults.length) % flatResults.length,
|
||||
);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
selectResult(flatResults[highlight]);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (hasQuery) {
|
||||
setQuery("");
|
||||
} else {
|
||||
close();
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const shortcutHint = useMemo(() => (isMacLike() ? "⌘K" : "Ctrl+K"), []);
|
||||
|
||||
const showNoResults =
|
||||
open && hasQuery && !loadingFiles && flatResults.length === 0;
|
||||
|
||||
const dropdown =
|
||||
open && rect ? (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="super-search-dropdown"
|
||||
role="listbox"
|
||||
style={{ top: rect.top, left: rect.left, width: rect.width }}
|
||||
// Keep focus on the input when clicking inside the dropdown.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{!hasQuery && (
|
||||
<div className="super-search-empty">
|
||||
{t("superSearch.hint", "Type to search")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNoResults && (
|
||||
<div className="super-search-empty">
|
||||
{t("search.noResults", "No results found")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasQuery &&
|
||||
groups.map((group) => (
|
||||
<div key={group.id} className="super-search-group">
|
||||
<div className="super-search-group-label">{group.label}</div>
|
||||
{group.results.map((result) => {
|
||||
const index = flatResults.indexOf(result);
|
||||
const active = index === highlight;
|
||||
return (
|
||||
<Button
|
||||
key={result.key}
|
||||
type="button"
|
||||
variant="quiet"
|
||||
justify="start"
|
||||
fullWidth
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`super-search-item${active ? " active" : ""}`}
|
||||
onMouseEnter={() => setHighlight(index)}
|
||||
onClick={() => selectResult(result)}
|
||||
>
|
||||
<span className="super-search-item-icon">
|
||||
{result.icon ?? (
|
||||
<LocalIcon
|
||||
icon={result.iconName ?? "search-rounded"}
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="super-search-item-text">
|
||||
<span className="super-search-item-title">
|
||||
{result.title}
|
||||
</span>
|
||||
{result.subtitle && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{result.subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="super-search" ref={containerRef} onKeyDown={handleKeyDown}>
|
||||
<TextInput
|
||||
id="super-search-input"
|
||||
name="super-search-input"
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t("superSearch.placeholder", "Search Stirling")}
|
||||
icon={
|
||||
<LocalIcon icon="search-rounded" width="1.1rem" height="1.1rem" />
|
||||
}
|
||||
autoComplete="off"
|
||||
aria-label={t("superSearch.ariaLabel", "Super search")}
|
||||
onFocus={() => setOpen(true)}
|
||||
/>
|
||||
{!open && !hasQuery && (
|
||||
<kbd className="super-search-kbd" aria-hidden="true">
|
||||
{shortcutHint}
|
||||
</kbd>
|
||||
)}
|
||||
{dropdown && createPortal(dropdown, document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import ToolPanel from "@app/components/tools/ToolPanel";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
import { usePoliciesEnabled } from "@app/components/policies/usePoliciesEnabled";
|
||||
import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController";
|
||||
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
||||
@@ -42,6 +43,7 @@ export default function RightSidebar() {
|
||||
const {
|
||||
leftPanelView,
|
||||
isPanelVisible,
|
||||
searchQuery,
|
||||
filteredTools,
|
||||
toolRegistry,
|
||||
setSearchQuery,
|
||||
@@ -94,6 +96,7 @@ export default function RightSidebar() {
|
||||
const inToolView = leftPanelView !== "toolPicker";
|
||||
// Show X (close) button only when there's somewhere to go back to.
|
||||
const showCloseButton = inToolView || allToolsView;
|
||||
const showHeaderSearch = showCloseButton || leftPanelView === "toolPicker";
|
||||
|
||||
const handleHeaderBack = () => {
|
||||
if (inToolView) {
|
||||
@@ -107,17 +110,25 @@ export default function RightSidebar() {
|
||||
withViewTransition(() => handleToolSelect(id));
|
||||
};
|
||||
|
||||
// Typing in the header search while inside a tool exits the tool and lifts the
|
||||
// panel into the all-tools view so the user immediately sees search results.
|
||||
const handleHeaderSearchChange = (value: string) => {
|
||||
if (inToolView) {
|
||||
withViewTransition(() => {
|
||||
handleBackToTools();
|
||||
setAllToolsView(true);
|
||||
setSearchQuery(value);
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSearchQuery(value);
|
||||
};
|
||||
|
||||
const activeTool: ToolRegistryEntry | null =
|
||||
inToolView && selectedToolKey
|
||||
? (toolRegistry[selectedToolKey as ToolId] ?? null)
|
||||
: null;
|
||||
|
||||
// Tool search now lives in the global super search, so the panel header
|
||||
// carries only the active-tool pill or the collapse/close control. When it's
|
||||
// just the collapse chevron, float it into the corner (see CSS) so the tool
|
||||
// list starts at the top instead of below an empty band.
|
||||
const headerCollapseOnly = !activeTool && !showCloseButton;
|
||||
|
||||
const expandedWidth = "18.5rem";
|
||||
|
||||
const computedWidth = () => {
|
||||
@@ -223,7 +234,6 @@ export default function RightSidebar() {
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<>
|
||||
@@ -245,13 +255,18 @@ export default function RightSidebar() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`tool-panel__compact-header${
|
||||
headerCollapseOnly
|
||||
? " tool-panel__compact-header--collapse-only"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="tool-panel__compact-header">
|
||||
{showHeaderSearch ? (
|
||||
<div className="tool-panel__compact-header-search">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={handleHeaderSearchChange}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
autoFocus={allToolsView && !inToolView}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showCloseButton ? (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
|
||||
@@ -266,18 +266,6 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* When the header holds only the collapse chevron (no active tool, no search),
|
||||
float it into the corner instead of reserving an empty band, so the tool list
|
||||
starts at the top of the panel. */
|
||||
.tool-panel__compact-header--collapse-only {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
min-height: 0;
|
||||
padding: 1rem 0.5rem 0.5rem;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.tool-panel__compact-header .tool-panel__expand-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, act, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Controllable stand-in for the library engine hook so we can simulate the
|
||||
// three states that matter: still loading, loaded, and errored.
|
||||
let engineState: { engine: unknown; isLoading: boolean; error: Error | null } =
|
||||
{
|
||||
engine: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
vi.mock("@embedpdf/engines/react", () => ({
|
||||
usePdfiumEngine: () => engineState,
|
||||
}));
|
||||
|
||||
import { PdfEngineBoundary } from "@app/components/viewer/PdfEngineBoundary";
|
||||
|
||||
const renderBoundary = (onRetry = vi.fn(), timeoutMs = 1000) => {
|
||||
const utils = render(
|
||||
<MantineProvider>
|
||||
<PdfEngineBoundary
|
||||
wasmUrl="pdfium.wasm"
|
||||
onRetry={onRetry}
|
||||
timeoutMs={timeoutMs}
|
||||
>
|
||||
{() => <div>PDF CONTENT</div>}
|
||||
</PdfEngineBoundary>
|
||||
</MantineProvider>,
|
||||
);
|
||||
return { onRetry, ...utils };
|
||||
};
|
||||
|
||||
const wrap = (node: ReactNode) => <MantineProvider>{node}</MantineProvider>;
|
||||
|
||||
describe("PdfEngineBoundary", () => {
|
||||
beforeEach(() => {
|
||||
engineState = { engine: null, isLoading: true, error: null };
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows the loading fallback while the engine is initialising", () => {
|
||||
renderBoundary();
|
||||
expect(screen.getByText("Loading PDF Engine...")).toBeInTheDocument();
|
||||
expect(screen.queryByText("viewer.engineLoadErrorTitle")).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces an error with a retry after the load times out (no infinite spinner)", () => {
|
||||
const { onRetry } = renderBoundary(vi.fn(), 1000);
|
||||
|
||||
// Still spinning before the timeout elapses.
|
||||
expect(screen.getByText("Loading PDF Engine...")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// The spinner is replaced by an actionable error state.
|
||||
expect(screen.queryByText("Loading PDF Engine...")).toBeNull();
|
||||
expect(screen.getByText("viewer.engineLoadErrorTitle")).toBeInTheDocument();
|
||||
|
||||
const retry = screen.getByText("viewer.engineLoadErrorRetry");
|
||||
fireEvent.click(retry);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("surfaces an error immediately when the engine load rejects", () => {
|
||||
engineState = {
|
||||
engine: null,
|
||||
isLoading: false,
|
||||
error: new Error("boom"),
|
||||
};
|
||||
render(
|
||||
wrap(
|
||||
<PdfEngineBoundary wasmUrl="pdfium.wasm" onRetry={vi.fn()}>
|
||||
{() => <div>PDF CONTENT</div>}
|
||||
</PdfEngineBoundary>,
|
||||
),
|
||||
);
|
||||
expect(screen.getByText("viewer.engineLoadErrorTitle")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders children once the engine is ready", () => {
|
||||
engineState = { engine: {}, isLoading: false, error: null };
|
||||
render(
|
||||
wrap(
|
||||
<PdfEngineBoundary wasmUrl="pdfium.wasm" onRetry={vi.fn()}>
|
||||
{() => <div>PDF CONTENT</div>}
|
||||
</PdfEngineBoundary>,
|
||||
),
|
||||
);
|
||||
expect(screen.getByText("PDF CONTENT")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Loading PDF Engine...")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { usePdfiumEngine } from "@embedpdf/engines/react";
|
||||
import { Center, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import ToolLoadingFallback from "@app/components/tools/ToolLoadingFallback";
|
||||
|
||||
/** Engine instance produced by usePdfiumEngine once initialisation succeeds. */
|
||||
type PdfiumEngine = NonNullable<ReturnType<typeof usePdfiumEngine>["engine"]>;
|
||||
|
||||
/**
|
||||
* How long to wait for the PDFium WASM engine to initialise before treating the
|
||||
* load as failed. The engine normally initialises in well under a second; if it
|
||||
* has not resolved after this window it has almost certainly hung (e.g. the WASM
|
||||
* fetch stalled or the worker never reported back), so we surface an error with
|
||||
* a retry instead of spinning forever.
|
||||
*/
|
||||
const DEFAULT_ENGINE_LOAD_TIMEOUT_MS = 30_000;
|
||||
|
||||
interface PdfEngineBoundaryProps {
|
||||
/** Absolute URL of the pdfium.wasm binary to load. */
|
||||
wasmUrl: string;
|
||||
/** Invoked when the user asks to retry after a failed/hung load. */
|
||||
onRetry: () => void;
|
||||
/** Override the load timeout (mainly for tests). */
|
||||
timeoutMs?: number;
|
||||
/** Rendered once the engine is ready. */
|
||||
children: (engine: PdfiumEngine) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the PDFium WASM engine and gates its children on success.
|
||||
*
|
||||
* The underlying `usePdfiumEngine` hook only re-initialises when its `wasmUrl`
|
||||
* changes, so retrying is handled by the parent remounting this component via a
|
||||
* `key`. Because the boundary owns the hook, a fresh mount runs the load again
|
||||
* from scratch.
|
||||
*
|
||||
* Without this boundary a failed or hung WASM load left the viewer showing an
|
||||
* infinite "Loading PDF Engine..." spinner with no error and no way to recover.
|
||||
*/
|
||||
export function PdfEngineBoundary({
|
||||
wasmUrl,
|
||||
onRetry,
|
||||
timeoutMs = DEFAULT_ENGINE_LOAD_TIMEOUT_MS,
|
||||
children,
|
||||
}: PdfEngineBoundaryProps) {
|
||||
const { t } = useTranslation();
|
||||
const { engine, isLoading, error } = usePdfiumEngine({ wasmUrl });
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (engine || error) return;
|
||||
const timer = setTimeout(() => setTimedOut(true), timeoutMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [engine, error, isLoading, timeoutMs]);
|
||||
|
||||
if (engine) {
|
||||
return <>{children(engine)}</>;
|
||||
}
|
||||
|
||||
if (error || timedOut) {
|
||||
return (
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap="md" style={{ maxWidth: 420, padding: 16 }}>
|
||||
<div style={{ fontSize: "32px" }}>⚠️</div>
|
||||
<Text fw={600} size="md" style={{ textAlign: "center" }}>
|
||||
{t("viewer.engineLoadErrorTitle")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" style={{ textAlign: "center" }}>
|
||||
{t("viewer.engineLoadErrorBody")}
|
||||
</Text>
|
||||
<Button onClick={onRetry} variant="primary">
|
||||
{t("viewer.engineLoadErrorRetry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return <ToolLoadingFallback toolName="PDF Engine" />;
|
||||
}
|
||||
@@ -176,11 +176,7 @@ export function useViewerWorkbenchBarButtons(
|
||||
aria-label={searchLabel}
|
||||
onClick={viewer.searchInterfaceActions.toggle}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="find-in-page-rounded"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
/>
|
||||
<LocalIcon icon="search" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Search index for the admin portal ("Processor") destinations offered by the
|
||||
* global super search. Core and desktop builds ship no portal, so the index is
|
||||
* empty and the Processor group never renders; the proprietary build (which
|
||||
* mounts the portal as a route-set) shadows this with the real view list.
|
||||
*/
|
||||
export interface ProcessorSearchEntry {
|
||||
/** Portal view id — stable key for the result row. */
|
||||
id: string;
|
||||
/** i18n key for the view's display name (shared with the portal sidebar). */
|
||||
labelKey: string;
|
||||
labelFallback: string;
|
||||
/** In-app path to navigate to. Empty when externalUrl is set. */
|
||||
path: string;
|
||||
/** Opens in a new tab instead of navigating (e.g. hosted docs). */
|
||||
externalUrl?: string;
|
||||
/** Extra fuzzy-match terms beyond the label. */
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = [];
|
||||
@@ -1,157 +0,0 @@
|
||||
import i18n from "i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
/**
|
||||
* Content-level settings search: matches a query against every translation
|
||||
* string a settings section renders, so a term that appears anywhere on a
|
||||
* settings page ("SMTP", "OCR", a field label…) finds that section without a
|
||||
* curated keyword.
|
||||
*
|
||||
* This is the single source for the technique — the in-modal settings search
|
||||
* bar and the global super search both build on it. It is intentionally
|
||||
* component-free (translation subtrees only) so the always-mounted super
|
||||
* search bar can use it without pulling the lazy settings modal into the main
|
||||
* bundle.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Translation subtrees whose strings appear on each settings section, for the
|
||||
* sections where the nav key doesn't map 1:1 onto a toml prefix. Keys missing
|
||||
* here fall back to the inferred `settings.<key>` / `admin.settings.<key>`
|
||||
* prefix.
|
||||
*/
|
||||
const SECTION_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> = {
|
||||
general: ["settings.general"],
|
||||
hotkeys: ["settings.hotkeys"],
|
||||
account: ["account"],
|
||||
people: ["settings.workspace"],
|
||||
teams: ["settings.workspace", "settings.team"],
|
||||
"api-keys": ["settings.developer"],
|
||||
connectionMode: ["settings.connection"],
|
||||
planBilling: ["settings.planBilling"],
|
||||
adminGeneral: ["admin.settings.general"],
|
||||
adminFeatures: ["admin.settings.features"],
|
||||
adminEndpoints: ["admin.settings.endpoints"],
|
||||
adminDatabase: ["admin.settings.database"],
|
||||
adminAdvanced: ["admin.settings.advanced"],
|
||||
adminSecurity: ["admin.settings.security"],
|
||||
adminMcp: ["admin.settings.mcp"],
|
||||
adminConnections: [
|
||||
"admin.settings.connections",
|
||||
"admin.settings.mail",
|
||||
"admin.settings.security",
|
||||
"admin.settings.telegram",
|
||||
"admin.settings.premium",
|
||||
"admin.settings.general",
|
||||
"settings.securityAuth",
|
||||
"settings.connection",
|
||||
],
|
||||
adminPlan: [
|
||||
"settings.planBilling",
|
||||
"admin.settings.premium",
|
||||
"settings.licensingAnalytics",
|
||||
],
|
||||
adminAudit: ["settings.licensingAnalytics"],
|
||||
adminUsage: ["settings.licensingAnalytics"],
|
||||
adminLegal: ["admin.settings.legal"],
|
||||
adminPrivacy: ["admin.settings.privacy"],
|
||||
};
|
||||
|
||||
export const getTranslationPrefixesForNavKey = (key: string): string[] => {
|
||||
const explicitPrefixes = SECTION_TRANSLATION_PREFIXES[key] ?? [];
|
||||
|
||||
const inferredPrefixes: string[] = [];
|
||||
|
||||
if (key.startsWith("admin")) {
|
||||
const adminSuffix = key.replace(/^admin/, "");
|
||||
const normalizedAdminSuffix =
|
||||
adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
|
||||
inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`);
|
||||
} else {
|
||||
inferredPrefixes.push(`settings.${key}`);
|
||||
}
|
||||
|
||||
return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes]));
|
||||
};
|
||||
|
||||
export const flattenTranslationStrings = (value: unknown): string[] => {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap(flattenTranslationStrings);
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.values(value as Record<string, unknown>).flatMap(
|
||||
flattenTranslationStrings,
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
/** Trims a matched string to a short snippet centred on the query hit. */
|
||||
export const buildMatchSnippet = (text: string, query: string): string => {
|
||||
const normalizedText = text.toLocaleLowerCase();
|
||||
const normalizedQuery = query.toLocaleLowerCase();
|
||||
const matchIndex = normalizedText.indexOf(normalizedQuery);
|
||||
|
||||
if (matchIndex === -1) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const maxLength = 84;
|
||||
const contextPadding = 28;
|
||||
const start = Math.max(0, matchIndex - contextPadding);
|
||||
const end = Math.min(text.length, matchIndex + query.length + contextPadding);
|
||||
const snippet = text.slice(start, end);
|
||||
|
||||
if (snippet.length <= maxLength) {
|
||||
return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`;
|
||||
}
|
||||
|
||||
return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`;
|
||||
};
|
||||
|
||||
// Flattening every subtree on each keystroke would be wasteful; sections'
|
||||
// content is static per language, so cache it and drop the cache on switch.
|
||||
const contentCache = new Map<string, string[]>();
|
||||
let contentCacheLanguage: string | undefined;
|
||||
|
||||
export function getSettingsSectionContent(key: string, t: TFunction): string[] {
|
||||
if (contentCacheLanguage !== i18n.language) {
|
||||
contentCache.clear();
|
||||
contentCacheLanguage = i18n.language;
|
||||
}
|
||||
const cached = contentCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const content = getTranslationPrefixesForNavKey(key).flatMap((prefix) =>
|
||||
flattenTranslationStrings(
|
||||
t(prefix, { returnObjects: true, defaultValue: {} }),
|
||||
),
|
||||
);
|
||||
contentCache.set(key, content);
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* First content string of the section containing the query
|
||||
* (case-insensitive), or null. Substring only — fuzzy matching across whole
|
||||
* paragraphs of copy produces junk hits.
|
||||
*/
|
||||
export function findSettingsContentMatch(
|
||||
key: string,
|
||||
query: string,
|
||||
t: TFunction,
|
||||
): string | null {
|
||||
const normalizedQuery = query.toLocaleLowerCase();
|
||||
return (
|
||||
getSettingsSectionContent(key, t).find((text) =>
|
||||
text.toLocaleLowerCase().includes(normalizedQuery),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { NavKey } from "@app/components/shared/config/types";
|
||||
|
||||
/**
|
||||
* A single, searchable setting *row* inside the settings modal.
|
||||
*
|
||||
* The existing in-modal SettingsSearchBar searches section content but can only
|
||||
* navigate to a whole section. This index lets the global super search deep-link
|
||||
* to an individual control: navigating to `/settings/{section}?focus={anchor}`,
|
||||
* where `anchor` is the DOM `id` placed on that control's row (see
|
||||
* AppConfigModal's focus-scroll effect and the `id=` attributes added to the
|
||||
* matching section components).
|
||||
*/
|
||||
export interface SettingsSearchEntry {
|
||||
/** Settings section this row lives in (nav key, e.g. "general"). */
|
||||
section: NavKey;
|
||||
/** DOM id on the control's row; used as the `?focus=` anchor. */
|
||||
anchor: string;
|
||||
/** i18n key for the display label. */
|
||||
labelKey: string;
|
||||
/** English fallback / default for the label. */
|
||||
labelFallback: string;
|
||||
/** Extra English terms to match against (synonyms, related words). */
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated row-level entries for the high-value, user-facing settings sections.
|
||||
* Section-level results (every other tab) come from the nav sections directly,
|
||||
* so this list only needs the rows worth jumping straight to.
|
||||
*/
|
||||
export const SETTINGS_SEARCH_INDEX: SettingsSearchEntry[] = [
|
||||
// --- General > Appearance ---
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-theme",
|
||||
labelKey: "settings.general.theme",
|
||||
labelFallback: "Theme",
|
||||
keywords: ["dark", "light", "mode", "appearance", "colour", "color"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-language",
|
||||
labelKey: "settings.general.language",
|
||||
labelFallback: "Language",
|
||||
keywords: ["locale", "translation", "i18n"],
|
||||
},
|
||||
// --- General > Behaviour ---
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-tool-picker-mode",
|
||||
labelKey: "settings.general.defaultToolPickerMode",
|
||||
labelFallback: "Default tool picker mode",
|
||||
keywords: ["sidebar", "fullscreen", "tools", "panel"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-startup-view",
|
||||
labelKey: "settings.general.defaultStartupView",
|
||||
labelFallback: "Default view on launch",
|
||||
keywords: ["startup", "launch", "home", "reader", "automate"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-reader-zoom",
|
||||
labelKey: "settings.general.defaultViewerZoom",
|
||||
labelFallback: "Default reader zoom",
|
||||
keywords: ["zoom", "viewer", "fit width", "fit page", "magnification"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-hide-unavailable-tools",
|
||||
labelKey: "settings.general.hideUnavailableTools",
|
||||
labelFallback: "Hide unavailable tools",
|
||||
keywords: ["disabled", "greyed", "tools"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-hide-unavailable-conversions",
|
||||
labelKey: "settings.general.hideUnavailableConversions",
|
||||
labelFallback: "Hide unavailable conversions",
|
||||
keywords: ["disabled", "convert", "conversions"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-auto-unzip",
|
||||
labelKey: "settings.general.autoUnzip",
|
||||
labelFallback: "Auto-unzip API responses",
|
||||
keywords: ["zip", "extract", "unzip", "archive"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-auto-unzip-file-limit",
|
||||
labelKey: "settings.general.autoUnzipFileLimit",
|
||||
labelFallback: "Auto-unzip file limit",
|
||||
keywords: ["zip", "limit", "files", "extract"],
|
||||
},
|
||||
// --- Keyboard Shortcuts ---
|
||||
{
|
||||
section: "hotkeys",
|
||||
anchor: "setting-hotkeys-search",
|
||||
labelKey: "settings.hotkeys.title",
|
||||
labelFallback: "Keyboard Shortcuts",
|
||||
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
|
||||
},
|
||||
];
|
||||
@@ -1,67 +0,0 @@
|
||||
import { NavKey } from "@app/components/shared/config/types";
|
||||
|
||||
/**
|
||||
* A whole settings section (nav tab), described as pure data so the global
|
||||
* super search can offer section-level results.
|
||||
*
|
||||
* This is the single source of truth for *which settings sections are
|
||||
* searchable* in a given build. It is intentionally **component-free**: the
|
||||
* always-mounted top bar imports it (via `@app/data/settingsSectionRegistry`)
|
||||
* to feed the super search, and pulling the heavy settings component tree in
|
||||
* here would defeat the lazy-loaded settings modal (AppConfigModalLazy).
|
||||
*
|
||||
* Layering mirrors the nav builders (`configNavSections`): core lists the
|
||||
* always-present sections; higher layers shadow this module to add their own
|
||||
* (proprietary admin sections, saas cloud sections, …). A section only belongs
|
||||
* in a layer's registry if that layer's settings modal can actually render it —
|
||||
* otherwise search would deep-link to a tab that doesn't exist.
|
||||
*
|
||||
* Per-user visibility (admin / login) is expressed by the flags below and
|
||||
* applied by the super search at query time, mirroring the modal's own gating.
|
||||
*/
|
||||
export interface SettingsSectionEntry {
|
||||
/** Nav key; used for the `/settings/{key}` deep link. */
|
||||
key: NavKey;
|
||||
/** i18n key for the display label. */
|
||||
labelKey: string;
|
||||
/** English fallback / default for the label. */
|
||||
labelFallback: string;
|
||||
/** Extra English terms to match against (synonyms, related words). */
|
||||
keywords?: string[];
|
||||
/** Surface only when login mode is on (e.g. account, API keys). */
|
||||
requiresLogin?: boolean;
|
||||
/**
|
||||
* Admin-area section: the self-hosted modal surfaces it when the user is an
|
||||
* admin OR login mode is off (single-user self-host). Mirrors the proprietary
|
||||
* nav builder's `isAdmin || !loginEnabled` gate.
|
||||
*/
|
||||
adminArea?: boolean;
|
||||
}
|
||||
|
||||
/** Core (OSS) sections — always present in every build. */
|
||||
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
|
||||
{
|
||||
key: "general",
|
||||
labelKey: "settings.general.title",
|
||||
labelFallback: "General",
|
||||
keywords: ["theme", "language", "appearance", "preferences", "startup"],
|
||||
},
|
||||
{
|
||||
key: "hotkeys",
|
||||
labelKey: "settings.hotkeys.title",
|
||||
labelFallback: "Keyboard Shortcuts",
|
||||
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
|
||||
},
|
||||
{
|
||||
key: "help",
|
||||
labelKey: "settings.help.label",
|
||||
labelFallback: "Tours",
|
||||
keywords: ["help", "tour", "guide", "support", "docs"],
|
||||
},
|
||||
{
|
||||
key: "legal",
|
||||
labelKey: "settings.legal.label",
|
||||
labelFallback: "Legal",
|
||||
keywords: ["legal", "terms", "privacy", "licenses"],
|
||||
},
|
||||
];
|
||||
@@ -1,423 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type React from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { ViewerContext } from "@app/contexts/ViewerContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useFileActions } from "@app/contexts/file/fileHooks";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { rankByFuzzy, idToWords } from "@app/utils/fuzzySearch";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { SETTINGS_SEARCH_INDEX } from "@app/data/settingsSearchIndex";
|
||||
import { SETTINGS_SECTION_REGISTRY } from "@app/data/settingsSectionRegistry";
|
||||
import {
|
||||
buildMatchSnippet,
|
||||
findSettingsContentMatch,
|
||||
} from "@app/data/settingsContentSearch";
|
||||
import {
|
||||
PROCESSOR_SEARCH_INDEX,
|
||||
type ProcessorSearchEntry,
|
||||
} from "@app/data/processorSearchIndex";
|
||||
|
||||
export type SuperSearchGroupId = "files" | "tools" | "settings" | "processor";
|
||||
|
||||
export interface SuperSearchResult {
|
||||
/** Stable unique key across all groups. */
|
||||
key: string;
|
||||
/** Group id — the editor uses SuperSearchGroupId; other hosts use their own. */
|
||||
group: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** LocalIcon name (files/settings); tools provide a React node via `icon`. */
|
||||
iconName?: string;
|
||||
icon?: React.ReactNode;
|
||||
score: number;
|
||||
onSelect: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface SuperSearchGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
results: SuperSearchResult[];
|
||||
}
|
||||
|
||||
/** Per-group result caps so the dropdown stays scannable. */
|
||||
const GROUP_LIMIT = 6;
|
||||
/** Group display order in the dropdown. */
|
||||
const GROUP_ORDER: SuperSearchGroupId[] = [
|
||||
"files",
|
||||
"tools",
|
||||
"settings",
|
||||
"processor",
|
||||
];
|
||||
|
||||
export interface UseSuperSearchResult {
|
||||
/** Non-empty groups, in display order. */
|
||||
groups: SuperSearchGroup[];
|
||||
/** All results flattened in display order (for keyboard navigation). */
|
||||
flatResults: SuperSearchResult[];
|
||||
/** True while the My Files store is loading for the first time. */
|
||||
loadingFiles: boolean;
|
||||
}
|
||||
|
||||
/** Visibility gates shared by the settings and Processor sources. */
|
||||
export interface SuperSearchGates {
|
||||
isAdmin: boolean;
|
||||
loginEnabled: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared sources. Every host bar (editor workbench, portal shell) builds its
|
||||
// results from these, so a query ranks identically everywhere — only the
|
||||
// select actions differ (in-app contexts vs cross-app navigation).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Loads the My Files stubs whenever the search surface is open. */
|
||||
export function useMyFilesStubs(active: boolean): {
|
||||
stubs: StirlingFileStub[];
|
||||
loadingFiles: boolean;
|
||||
} {
|
||||
const [stubs, setStubs] = useState<StirlingFileStub[]>([]);
|
||||
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||
const loadedOnceRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
// Refresh whenever the surface opens so newly added files appear.
|
||||
let cancelled = false;
|
||||
if (!loadedOnceRef.current) setLoadingFiles(true);
|
||||
fileStorage
|
||||
.getLeafStirlingFileStubs()
|
||||
.then((all) => {
|
||||
if (cancelled) return;
|
||||
setStubs(all);
|
||||
loadedOnceRef.current = true;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[SuperSearch] Failed to load file stubs:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingFiles(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
return { stubs, loadingFiles };
|
||||
}
|
||||
|
||||
export function rankFileResults(
|
||||
stubs: StirlingFileStub[],
|
||||
trimmed: string,
|
||||
openFile: (stub: StirlingFileStub) => void | Promise<void>,
|
||||
): SuperSearchResult[] {
|
||||
if (!trimmed) return [];
|
||||
return rankByFuzzy(stubs, trimmed, [(s) => s.name])
|
||||
.slice(0, GROUP_LIMIT)
|
||||
.map(({ item, score }) => ({
|
||||
key: `file:${item.id}`,
|
||||
group: "files",
|
||||
title: item.name,
|
||||
iconName: "insert-drive-file-rounded",
|
||||
score,
|
||||
onSelect: () => openFile(item),
|
||||
}));
|
||||
}
|
||||
|
||||
export function rankToolResults(
|
||||
registry: Partial<ToolRegistry>,
|
||||
trimmed: string,
|
||||
openTool: (id: ToolId) => void,
|
||||
): SuperSearchResult[] {
|
||||
if (!trimmed) return [];
|
||||
const entries = Object.entries(registry) as [
|
||||
ToolId,
|
||||
ToolRegistry[ToolId] | undefined,
|
||||
][];
|
||||
return rankByFuzzy(entries, trimmed, [
|
||||
([id]) => idToWords(id),
|
||||
([, v]) => v?.name ?? "",
|
||||
([, v]) => v?.description ?? "",
|
||||
([, v]) => v?.synonyms?.join(" ") ?? "",
|
||||
])
|
||||
.slice(0, GROUP_LIMIT)
|
||||
.map(({ item: [id, tool], score }) => ({
|
||||
key: `tool:${id}`,
|
||||
group: "tools",
|
||||
title: tool?.name ?? id,
|
||||
subtitle: tool?.description,
|
||||
icon: tool?.icon,
|
||||
score,
|
||||
onSelect: () => openTool(id),
|
||||
}));
|
||||
}
|
||||
|
||||
export function rankSettingsResults(
|
||||
trimmed: string,
|
||||
t: TFunction,
|
||||
gates: SuperSearchGates,
|
||||
openSettings: (section: string, anchor?: string) => void,
|
||||
): SuperSearchResult[] {
|
||||
if (!trimmed) return [];
|
||||
const { isAdmin, loginEnabled } = gates;
|
||||
|
||||
// Row-level entries (deep-link with ?focus=) take priority.
|
||||
const rows = rankByFuzzy(SETTINGS_SEARCH_INDEX, trimmed, [
|
||||
(e) => t(e.labelKey, e.labelFallback),
|
||||
(e) => e.labelFallback,
|
||||
(e) => e.keywords?.join(" ") ?? "",
|
||||
]).map(({ item, score }) => ({
|
||||
key: `setting:${item.section}:${item.anchor}`,
|
||||
group: "settings",
|
||||
title: t(item.labelKey, item.labelFallback),
|
||||
subtitle: t(`settings.${item.section}.title`, item.section),
|
||||
iconName: "settings-rounded",
|
||||
score: score + 1, // nudge rows above bare section matches
|
||||
onSelect: () => openSettings(item.section, item.anchor),
|
||||
}));
|
||||
|
||||
// Section-level entries (whole tab), gated like the modal nav. The registry
|
||||
// resolves per build (core / proprietary / saas / desktop), so this only
|
||||
// ever sees sections the current build's settings modal can actually show.
|
||||
const visibleSections = SETTINGS_SECTION_REGISTRY.filter((s) => {
|
||||
if (s.requiresLogin && !loginEnabled) return false;
|
||||
// Admin-area sections mirror the builder's `isAdmin || !loginEnabled` gate.
|
||||
if (s.adminArea && !(isAdmin || !loginEnabled)) return false;
|
||||
return true;
|
||||
});
|
||||
const sectionMatches = rankByFuzzy(visibleSections, trimmed, [
|
||||
(s) => t(s.labelKey, s.labelFallback),
|
||||
(s) => s.labelFallback,
|
||||
(s) => s.keywords?.join(" ") ?? "",
|
||||
]);
|
||||
const sections = sectionMatches.map(({ item, score }) => ({
|
||||
key: `setting-section:${item.key}`,
|
||||
group: "settings",
|
||||
title: t(item.labelKey, item.labelFallback),
|
||||
iconName: "settings-rounded",
|
||||
score,
|
||||
onSelect: () => openSettings(item.key),
|
||||
}));
|
||||
|
||||
// Content matches: sections whose rendered copy contains the query (the
|
||||
// in-modal settings search technique), so terms with no curated keyword
|
||||
// ("SMTP", a field label) still find their section. Ranked below every
|
||||
// label/keyword match; 3+ chars so a single letter doesn't match half the
|
||||
// modal.
|
||||
const labelMatchedKeys = new Set(sectionMatches.map(({ item }) => item.key));
|
||||
const contentMatches =
|
||||
trimmed.length < 3
|
||||
? []
|
||||
: visibleSections
|
||||
.filter((s) => !labelMatchedKeys.has(s.key))
|
||||
.flatMap((s) => {
|
||||
const match = findSettingsContentMatch(s.key, trimmed, t);
|
||||
if (!match) return [];
|
||||
return [
|
||||
{
|
||||
key: `setting-content:${s.key}`,
|
||||
group: "settings",
|
||||
title: t(s.labelKey, s.labelFallback),
|
||||
subtitle: buildMatchSnippet(match, trimmed),
|
||||
iconName: "settings-rounded",
|
||||
score: 20, // below the fuzzy thresholds (30/40)
|
||||
onSelect: () => openSettings(s.key),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return [...rows, ...sections, ...contentMatches]
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, GROUP_LIMIT);
|
||||
}
|
||||
|
||||
export function rankProcessorResults(
|
||||
trimmed: string,
|
||||
t: TFunction,
|
||||
gates: SuperSearchGates,
|
||||
selectEntry: (entry: ProcessorSearchEntry) => void,
|
||||
): SuperSearchResult[] {
|
||||
if (!trimmed || PROCESSOR_SEARCH_INDEX.length === 0) return [];
|
||||
// The portal is an admin surface; mirror the settings modal's admin gate
|
||||
// (a login-disabled single-user deployment has a full-access operator).
|
||||
if (!(gates.isAdmin || !gates.loginEnabled)) return [];
|
||||
return rankByFuzzy(PROCESSOR_SEARCH_INDEX, trimmed, [
|
||||
(e) => t(e.labelKey, e.labelFallback),
|
||||
(e) => e.labelFallback,
|
||||
(e) => e.keywords?.join(" ") ?? "",
|
||||
])
|
||||
.slice(0, GROUP_LIMIT)
|
||||
.map(({ item, score }) => ({
|
||||
key: `processor:${item.id}`,
|
||||
group: "processor",
|
||||
title: t(item.labelKey, item.labelFallback),
|
||||
iconName: "grid-view-rounded",
|
||||
score,
|
||||
onSelect: () => selectEntry(item),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders the sources into the shared group layout, dropping empties. Hosts
|
||||
* pass their own order so local results lead (the editor puts its own
|
||||
* files/tools first and Processor pages last; the portal the reverse).
|
||||
*/
|
||||
export function assembleSuperSearchGroups(
|
||||
byId: Partial<Record<SuperSearchGroupId, SuperSearchResult[]>>,
|
||||
t: TFunction,
|
||||
order: SuperSearchGroupId[] = GROUP_ORDER,
|
||||
): SuperSearchGroup[] {
|
||||
const labels: Record<SuperSearchGroupId, string> = {
|
||||
files: t("superSearch.group.files", "Files"),
|
||||
tools: t("superSearch.group.tools", "Tools"),
|
||||
settings: t("superSearch.group.settings", "Settings"),
|
||||
processor: t("superSearch.group.processor", "Processor"),
|
||||
};
|
||||
return order
|
||||
.map((id) => ({
|
||||
id,
|
||||
label: labels[id],
|
||||
results: byId[id] ?? [],
|
||||
}))
|
||||
.filter((g) => g.results.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's results provider: the shared sources wired to in-app select
|
||||
* actions (open file → viewer, select tool in the workbench, deep-link into
|
||||
* the settings modal, route into the Processor).
|
||||
*
|
||||
* @param query current search text
|
||||
* @param active whether the search surface is open; gates the My Files load
|
||||
*/
|
||||
export function useSuperSearch(
|
||||
query: string,
|
||||
active: boolean,
|
||||
): UseSuperSearchResult {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
toolRegistry,
|
||||
handleToolSelect,
|
||||
handleToolSelectForced,
|
||||
toolAvailability,
|
||||
} = useToolWorkflow();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { config } = useAppConfig();
|
||||
// ViewerContext is only present once the viewer subtree mounts; treat as optional.
|
||||
const viewer = useContext(ViewerContext);
|
||||
|
||||
const trimmed = query.trim();
|
||||
const { stubs, loadingFiles } = useMyFilesStubs(active);
|
||||
|
||||
// --- Actions -----------------------------------------------------------
|
||||
const openFile = useCallback(
|
||||
async (stub: StirlingFileStub) => {
|
||||
try {
|
||||
// The file already lives in storage — load it as a stub so its id and
|
||||
// metadata are preserved (addFiles would persist a duplicate record).
|
||||
await fileActions.addStirlingFileStubs([stub], { selectFiles: true });
|
||||
navActions.setWorkbench("viewer");
|
||||
viewer?.setActiveFileId?.(stub.id);
|
||||
} catch (err) {
|
||||
console.error("[SuperSearch] Failed to open file:", stub.name, err);
|
||||
}
|
||||
},
|
||||
[fileActions, navActions, viewer],
|
||||
);
|
||||
|
||||
const openTool = useCallback(
|
||||
(id: ToolId) => {
|
||||
// Tools whose backend endpoint isn't served in this environment are
|
||||
// flagged unavailable; handleToolSelect silently no-ops them. For a
|
||||
// search ("take me to Repair") we still want the click to open the
|
||||
// tool's UI, so fall back to the forced path for those. Available tools
|
||||
// keep the normal path so the unsaved-changes guard still applies.
|
||||
const available = toolAvailability[id]?.available !== false;
|
||||
if (available) {
|
||||
handleToolSelect(id);
|
||||
} else {
|
||||
handleToolSelectForced(id);
|
||||
}
|
||||
},
|
||||
[handleToolSelect, handleToolSelectForced, toolAvailability],
|
||||
);
|
||||
|
||||
const openSettings = useCallback(
|
||||
(section: string, anchor?: string) => {
|
||||
const path = anchor
|
||||
? `/settings/${section}?focus=${encodeURIComponent(anchor)}`
|
||||
: `/settings/${section}`;
|
||||
navigate(path);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const selectProcessorEntry = useCallback(
|
||||
(item: ProcessorSearchEntry) => {
|
||||
if (item.externalUrl) {
|
||||
window.open(item.externalUrl, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
navigate(item.path);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
// --- Assemble ----------------------------------------------------------
|
||||
const gates = useMemo<SuperSearchGates>(
|
||||
() => ({
|
||||
isAdmin: config?.isAdmin ?? false,
|
||||
loginEnabled: config?.enableLogin ?? false,
|
||||
}),
|
||||
[config],
|
||||
);
|
||||
|
||||
const groups = useMemo<SuperSearchGroup[]>(
|
||||
() =>
|
||||
assembleSuperSearchGroups(
|
||||
{
|
||||
files: rankFileResults(stubs, trimmed, openFile),
|
||||
tools: rankToolResults(toolRegistry, trimmed, openTool),
|
||||
settings: rankSettingsResults(trimmed, t, gates, openSettings),
|
||||
processor: rankProcessorResults(
|
||||
trimmed,
|
||||
t,
|
||||
gates,
|
||||
selectProcessorEntry,
|
||||
),
|
||||
},
|
||||
t,
|
||||
),
|
||||
[
|
||||
stubs,
|
||||
trimmed,
|
||||
openFile,
|
||||
toolRegistry,
|
||||
openTool,
|
||||
gates,
|
||||
openSettings,
|
||||
selectProcessorEntry,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]);
|
||||
|
||||
return { groups, flatResults, loadingFiles };
|
||||
}
|
||||
@@ -597,6 +597,11 @@ const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
<FileSidebar
|
||||
ref={ref}
|
||||
{...props}
|
||||
onSearchClick={() => {
|
||||
// Just focus the central search field; don't toggle collapse
|
||||
// (which on /files navigates back home).
|
||||
window.dispatchEvent(new Event("files-page:focus-search"));
|
||||
}}
|
||||
onUploadFiles={handleUpload}
|
||||
onPickGoogleDriveFiles={handleUpload}
|
||||
extraAction={{
|
||||
|
||||
@@ -28,24 +28,53 @@ export const pdfiumWasmModulePromise = new Promise<WebAssembly.Module | null>(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Compile the WASM without streaming by fetching the whole binary first.
|
||||
*
|
||||
* `compileStreaming` requires the response to be served with the
|
||||
* `application/wasm` MIME type and no incompatible `Content-Encoding`. Under the
|
||||
* `tauri://` asset protocol (and behind some proxies) those headers aren't
|
||||
* guaranteed, which makes streaming compilation throw. Fetching the bytes and
|
||||
* compiling them directly sidesteps the MIME/encoding requirement entirely.
|
||||
*/
|
||||
async function compileFromArrayBuffer(): Promise<WebAssembly.Module | null> {
|
||||
try {
|
||||
const response = await fetch(pdfiumWasmUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unexpected response ${response.status} for pdfium.wasm`);
|
||||
}
|
||||
const bytes = await response.arrayBuffer();
|
||||
return await WebAssembly.compile(bytes);
|
||||
} catch (err) {
|
||||
console.warn("Eager WASM ArrayBuffer compilation failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function startEagerWasmCompilation(): void {
|
||||
if (compilationStarted) return;
|
||||
compilationStarted = true;
|
||||
|
||||
if (
|
||||
typeof WebAssembly === "object" &&
|
||||
typeof WebAssembly.compileStreaming === "function"
|
||||
) {
|
||||
if (typeof WebAssembly !== "object") {
|
||||
resolvePromise(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer streaming compilation, but fall back to fetching the bytes and
|
||||
// compiling them directly when streaming isn't available or fails (e.g. wrong
|
||||
// MIME type / content-encoding under the tauri:// protocol). Resolving null on
|
||||
// total failure lets pdfiumService fall back to its own instantiation path.
|
||||
if (typeof WebAssembly.compileStreaming === "function") {
|
||||
WebAssembly.compileStreaming(fetch(pdfiumWasmUrl))
|
||||
.then(resolvePromise)
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
"Eager WASM compilation failed or not supported in this environment:",
|
||||
"Eager WASM streaming compilation failed, falling back to ArrayBuffer:",
|
||||
err,
|
||||
);
|
||||
resolvePromise(null);
|
||||
compileFromArrayBuffer().then(resolvePromise);
|
||||
});
|
||||
} else {
|
||||
resolvePromise(null);
|
||||
compileFromArrayBuffer().then(resolvePromise);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,6 +565,25 @@ test.describe("Files page", () => {
|
||||
test.describe("Side-rail integration with /files", () => {
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Rail Search focuses the central search field, no navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await gotoFilesPage(page);
|
||||
// Click the search row in the rail.
|
||||
await page.locator(".file-sidebar-search-row").click();
|
||||
// The central search input should be focused.
|
||||
const focused = await page.evaluate(
|
||||
() => document.activeElement?.getAttribute("aria-label") ?? "",
|
||||
);
|
||||
expect(focused).toMatch(/Search/i);
|
||||
// And we must still be on /files (i.e. didn't navigate home).
|
||||
await expect(page).toHaveURL(/\/files/);
|
||||
});
|
||||
|
||||
test("Rail New folder button visible on /files", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import type { Route } from "@playwright/test";
|
||||
import path from "path";
|
||||
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
|
||||
|
||||
const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
|
||||
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
|
||||
|
||||
/** Stub the two signing list endpoints with empty arrays. */
|
||||
async function stubSigningApis(page: import("@playwright/test").Page) {
|
||||
await page.route("**/api/v1/security/cert-sign/sign-requests", (r: Route) =>
|
||||
r.fulfill({ json: [] }),
|
||||
);
|
||||
await page.route("**/api/v1/security/cert-sign/sessions", (r: Route) =>
|
||||
r.fulfill({ json: [] }),
|
||||
);
|
||||
await page.route("**/api/v1/storage/teams/**", (r: Route) =>
|
||||
r.fulfill({ json: [] }),
|
||||
);
|
||||
await page.route("**/api/v1/storage/files", (r: Route) =>
|
||||
r.fulfill({ json: [] }),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("SharedSign tool — gating", () => {
|
||||
test("shows 'not enabled' notice when storageGroupSigningEnabled is false", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Default stub has no storageGroupSigningEnabled — feature is off.
|
||||
await page.goto("/shared-sign", { waitUntil: "domcontentloaded" });
|
||||
const alert = page.getByRole("alert");
|
||||
await expect(alert).toBeVisible({ timeout: 10_000 });
|
||||
await expect(alert).toContainText(/not enabled|collaborative signing/i);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("SharedSign tool — feature enabled", () => {
|
||||
test.use({
|
||||
stubOptions: {
|
||||
enableLogin: true,
|
||||
},
|
||||
seedJwt: true,
|
||||
});
|
||||
|
||||
test("renders Active/Completed tabs and Request signatures button", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Override app-config to include storageGroupSigningEnabled.
|
||||
await page.route("**/api/v1/config/app-config", (r: Route) =>
|
||||
r.fulfill({
|
||||
json: {
|
||||
enableLogin: true,
|
||||
isAdmin: false,
|
||||
languages: ["en-US"],
|
||||
defaultLocale: "en-US",
|
||||
storageGroupSigningEnabled: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await stubSigningApis(page);
|
||||
|
||||
await page.goto("/shared-sign", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Mantine SegmentedControl hides the underlying radio inputs via CSS;
|
||||
// assert on the visible label text instead.
|
||||
await expect(
|
||||
page
|
||||
.locator("label")
|
||||
.filter({ hasText: /^active$/i })
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page
|
||||
.locator("label")
|
||||
.filter({ hasText: /^completed$/i })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
// "Request signatures" action button should be visible.
|
||||
const requestBtn = page.getByRole("button", {
|
||||
name: /request signatures/i,
|
||||
});
|
||||
await expect(requestBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test("empty-state message shows when no sessions exist", async ({ page }) => {
|
||||
await page.route("**/api/v1/config/app-config", (r: Route) =>
|
||||
r.fulfill({
|
||||
json: {
|
||||
enableLogin: true,
|
||||
isAdmin: false,
|
||||
languages: ["en-US"],
|
||||
defaultLocale: "en-US",
|
||||
storageGroupSigningEnabled: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await stubSigningApis(page);
|
||||
|
||||
await page.goto("/shared-sign", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Empty-state copy for the active tab.
|
||||
await expect(
|
||||
page.getByText(/no pending sign requests|no active sessions/i),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("clicking 'Request signatures' shows the create-session wizard", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/v1/config/app-config", (r: Route) =>
|
||||
r.fulfill({
|
||||
json: {
|
||||
enableLogin: true,
|
||||
isAdmin: false,
|
||||
languages: ["en-US"],
|
||||
defaultLocale: "en-US",
|
||||
storageGroupSigningEnabled: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await stubSigningApis(page);
|
||||
// Team members endpoint for participant picker.
|
||||
await page.route("**/api/v1/proprietary/team/**", (r: Route) =>
|
||||
r.fulfill({ json: [] }),
|
||||
);
|
||||
|
||||
await page.goto("/shared-sign", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const requestBtn = page.getByRole("button", {
|
||||
name: /request signatures/i,
|
||||
});
|
||||
await expect(requestBtn).toBeVisible({ timeout: 10_000 });
|
||||
await requestBtn.click();
|
||||
|
||||
// Wizard should appear — "Back to sessions" back-button is the marker.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /back to sessions/i }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("Completed tab shows empty state after switching", async ({ page }) => {
|
||||
await page.route("**/api/v1/config/app-config", (r: Route) =>
|
||||
r.fulfill({
|
||||
json: {
|
||||
enableLogin: true,
|
||||
isAdmin: false,
|
||||
languages: ["en-US"],
|
||||
defaultLocale: "en-US",
|
||||
storageGroupSigningEnabled: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await stubSigningApis(page);
|
||||
|
||||
await page.goto("/shared-sign", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Mantine SegmentedControl: click the visible label, not the hidden radio input.
|
||||
const completedTab = page
|
||||
.locator("label")
|
||||
.filter({ hasText: /^completed$/i })
|
||||
.first();
|
||||
await expect(completedTab).toBeVisible({ timeout: 10_000 });
|
||||
await completedTab.click();
|
||||
|
||||
await expect(page.getByText(/no completed sessions/i)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Viewer Share button — gating", () => {
|
||||
test("Share button absent when storageSharingEnabled is false", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Default app-config has no storageSharingEnabled.
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
|
||||
// The share button should not be in the DOM.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /share/i }).first(),
|
||||
).not.toBeAttached({ timeout: 8_000 });
|
||||
});
|
||||
|
||||
test("Share button present when storageSharingEnabled is true", async ({
|
||||
page,
|
||||
}) => {
|
||||
// storageSharingEnabled is a pure config flag — the WorkbenchBar renders
|
||||
// the Share button based solely on it, with no login-required check.
|
||||
await page.route("**/api/v1/config/app-config", (r: Route) =>
|
||||
r.fulfill({
|
||||
json: {
|
||||
enableLogin: false,
|
||||
isAdmin: false,
|
||||
languages: ["en-US"],
|
||||
defaultLocale: "en-US",
|
||||
storageSharingEnabled: true,
|
||||
storageEnabled: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/v1/storage/files", (r: Route) =>
|
||||
r.fulfill({ json: [] }),
|
||||
);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
|
||||
// Share button appears in the workbench toolbar once a file is open.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /share/i }).first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
|
||||
test.describe("3. Tool Search", () => {
|
||||
test.describe("3.1 Search - Happy Path", () => {
|
||||
test("should filter tools in real time based on search input", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Step 1: Click on the search box
|
||||
const searchBox = page.getByPlaceholder(/search|cari/i).first();
|
||||
await searchBox.click();
|
||||
|
||||
// Step 2: Type "merge"
|
||||
await searchBox.fill("merge");
|
||||
|
||||
// Step 3: Verify search results filter to show relevant tools
|
||||
await expect(
|
||||
page.locator("text=/merge|menggabungkan/i").first(),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Step 5: Clear the search field
|
||||
await searchBox.clear();
|
||||
|
||||
// Step 6: Verify all tools reappear (check for multiple categories)
|
||||
await expect(
|
||||
page.locator("text=/recommended|direkomendasikan/i").first(),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("3.2 Search - No Results", () => {
|
||||
test("should handle queries with no matching tools gracefully", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Step 1: Click on the search box
|
||||
const searchBox = page.getByPlaceholder(/search|cari/i).first();
|
||||
await searchBox.click();
|
||||
|
||||
// Step 2: Type xyznonexistent123
|
||||
await searchBox.fill("xyznonexistent123");
|
||||
|
||||
// Step 3: Verify the search field accepted the input
|
||||
await expect(searchBox).toHaveValue("xyznonexistent123");
|
||||
|
||||
// The app uses fuzzy search with a fallback that shows all tools when nothing
|
||||
// matches, so we verify the search state is active (no "recommended" section)
|
||||
// and the page remains functional without errors.
|
||||
await expect(
|
||||
page.locator("text=/recommended|direkomendasikan/i"),
|
||||
).toHaveCount(0, { timeout: 5000 });
|
||||
|
||||
// Step 4: Clear the search field
|
||||
await searchBox.clear();
|
||||
|
||||
// Step 5: Verify all tools reappear (recommended section comes back)
|
||||
await expect(
|
||||
page.locator("text=/recommended|direkomendasikan/i").first(),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("3.3 Search - Special Characters", () => {
|
||||
test("should sanitize search input against XSS", async ({ page }) => {
|
||||
// Step 1: Type XSS payload into the search box
|
||||
const searchBox = page.getByPlaceholder(/search|cari/i).first();
|
||||
await searchBox.fill("<script>alert(1)</script>");
|
||||
|
||||
// Step 2: Verify no script execution occurs (no alert dialog)
|
||||
// If an alert appeared, Playwright would throw an unhandled dialog error
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Step 3: Verify the search treats the input as plain text
|
||||
await expect(searchBox).toHaveValue("<script>alert(1)</script>");
|
||||
|
||||
// Step 4: Clear the search field
|
||||
await searchBox.clear();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/* Shared classification-label pill (team labels editor + sidebar category manager). */
|
||||
|
||||
.sui-labelchip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 0.2rem 0.3rem 0.2rem 0.25rem;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.sui-labelchip-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.sui-labelchip-name {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sui-labelchip-count {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-3);
|
||||
padding-left: 0.1rem;
|
||||
}
|
||||
|
||||
.sui-labelchip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.sui-labelchip-remove:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-red);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LabelChip } from "@app/ui/LabelChip";
|
||||
|
||||
const meta: Meta<typeof LabelChip> = {
|
||||
title: "Primitives/LabelChip",
|
||||
component: LabelChip,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "centered" },
|
||||
args: { label: "Invoice", icon: "receipt-long" },
|
||||
argTypes: {
|
||||
label: { control: "text" },
|
||||
icon: { control: "text" },
|
||||
count: { control: "number" },
|
||||
onRemove: { action: "removed" },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LabelChip>;
|
||||
|
||||
/** The classification-label pill shared by the labels editor and the sidebar category manager. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
/** With a file count, as shown in the sidebar category manager. */
|
||||
export const WithCount: Story = {
|
||||
args: { label: "Contract", icon: "handshake", count: 12 },
|
||||
};
|
||||
|
||||
/** Removable — the trailing × appears when `onRemove` is set. */
|
||||
export const Removable: Story = {
|
||||
args: { label: "NDA", icon: "lock", onRemove: () => {} },
|
||||
};
|
||||
|
||||
/** Falls back to the default "sell" icon when none is given. */
|
||||
export const DefaultIcon: Story = {
|
||||
args: { label: "Uncategorised label", icon: undefined },
|
||||
};
|
||||
|
||||
/** Long names truncate rather than overflow the pill. */
|
||||
export const LongName: Story = {
|
||||
args: { label: "Memorandum of understanding and mutual agreement", count: 3 },
|
||||
};
|
||||
|
||||
export const Row: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", maxWidth: 420 }}>
|
||||
<LabelChip label="Invoice" icon="receipt-long" count={12} />
|
||||
<LabelChip label="Contract" icon="handshake" onRemove={() => {}} />
|
||||
<LabelChip label="Lab report" icon="science" count={2} />
|
||||
<LabelChip label="Payslip" icon="payments" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ReactNode } from "react";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import "@app/ui/LabelChip.css";
|
||||
|
||||
export interface LabelChipProps {
|
||||
/** The label text. */
|
||||
label: string;
|
||||
/** Material Symbols key for a leading icon; ignored when `leading` is given. */
|
||||
icon?: string;
|
||||
/** Custom leading node (e.g. an icon picker), overriding `icon`. */
|
||||
leading?: ReactNode;
|
||||
/** Optional trailing count (e.g. how many files carry this label). */
|
||||
count?: number;
|
||||
/** Show a trailing `×`; called on click. */
|
||||
onRemove?: () => void;
|
||||
/** Accessible name for the remove button. */
|
||||
removeAriaLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A classification-label pill: leading icon (or a custom control like an icon
|
||||
* picker) + name, with an optional count and remove button. The shared look for
|
||||
* every place labels are shown as chips — the team labels editor and the
|
||||
* sidebar category manager both render this so they stay visually identical.
|
||||
*/
|
||||
export function LabelChip({
|
||||
label,
|
||||
icon,
|
||||
leading,
|
||||
count,
|
||||
onRemove,
|
||||
removeAriaLabel,
|
||||
}: LabelChipProps) {
|
||||
return (
|
||||
<span className="sui-labelchip" role="listitem">
|
||||
{leading ?? (
|
||||
<span className="sui-labelchip-icon">
|
||||
<LocalIcon icon={icon || "sell"} width="1rem" />
|
||||
</span>
|
||||
)}
|
||||
<span className="sui-labelchip-name" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
{count != null && count > 0 && (
|
||||
<span className="sui-labelchip-count">{count}</span>
|
||||
)}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className="sui-labelchip-remove"
|
||||
onClick={onRemove}
|
||||
aria-label={removeAriaLabel ?? `Remove ${label}`}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "0.85rem" }} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Desktop inherits proprietary's app but must NOT ship the portal (see the
|
||||
* admin-route seam) — shadow the index back to core's empty list so the super
|
||||
* search never offers Processor destinations that don't exist on desktop.
|
||||
*/
|
||||
export type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
|
||||
export { PROCESSOR_SEARCH_INDEX } from "@core/data/processorSearchIndex";
|
||||
@@ -1,42 +0,0 @@
|
||||
import { type SettingsSectionEntry } from "@core/data/settingsSectionRegistry";
|
||||
|
||||
export type { SettingsSectionEntry };
|
||||
|
||||
/**
|
||||
* Desktop settings sections. The desktop modal reflows heavily by connection
|
||||
* mode (`configNavSections`): local mode shows only Preferences + Connection
|
||||
* Mode + Legal, while SaaS mode swaps in the cloud Plan/Team sections and hides
|
||||
* the self-hosted admin area. To stay safe across both modes without threading
|
||||
* the (async) connection state into the always-mounted search, this lists only
|
||||
* the sections guaranteed to render in every desktop mode.
|
||||
*
|
||||
* Cloud Plan/Team search on desktop-SaaS is a deliberate Tier-0 gap, not a
|
||||
* regression — better to omit them than to deep-link to a dead tab in local
|
||||
* mode.
|
||||
*/
|
||||
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
|
||||
{
|
||||
key: "general",
|
||||
labelKey: "settings.general.title",
|
||||
labelFallback: "General",
|
||||
keywords: ["theme", "language", "appearance", "preferences", "startup"],
|
||||
},
|
||||
{
|
||||
key: "hotkeys",
|
||||
labelKey: "settings.hotkeys.title",
|
||||
labelFallback: "Keyboard Shortcuts",
|
||||
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
|
||||
},
|
||||
{
|
||||
key: "connectionMode",
|
||||
labelKey: "settings.connection.title",
|
||||
labelFallback: "Connection Mode",
|
||||
keywords: ["connection", "local", "cloud", "server", "sign in"],
|
||||
},
|
||||
{
|
||||
key: "legal",
|
||||
labelKey: "settings.legal.label",
|
||||
labelFallback: "Legal",
|
||||
keywords: ["legal", "terms", "privacy", "licenses"],
|
||||
},
|
||||
];
|
||||
@@ -56,11 +56,11 @@ export interface PolicyField {
|
||||
export interface PolicyCategory {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red";
|
||||
desc: string;
|
||||
providesClassification?: boolean;
|
||||
comingSoon?: boolean;
|
||||
requiresAiEngine?: boolean;
|
||||
}
|
||||
|
||||
export interface PolicyConfigDef {
|
||||
@@ -133,21 +133,14 @@ export interface CatalogueEntry {
|
||||
/* Endpoint display labels */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* i18n keys keyed by endpoint; labels stored steps in the detail view. Mostly
|
||||
* {@link ToolEndpoint}s, plus the AI classify endpoint, which isn't part of the generated union.
|
||||
*/
|
||||
export const ENDPOINT_LABELS: Partial<
|
||||
Record<ToolEndpoint | "/api/v1/ai/tools/classify-and-label", string>
|
||||
> = {
|
||||
/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */
|
||||
export const ENDPOINT_LABELS: Partial<Record<ToolEndpoint, string>> = {
|
||||
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
|
||||
"/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
|
||||
"/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark",
|
||||
"/api/v1/misc/ocr-pdf": "portal.policies.endpoints.ocrPdf",
|
||||
"/api/v1/misc/flatten": "portal.policies.endpoints.flatten",
|
||||
"/api/v1/misc/compress-pdf": "portal.policies.endpoints.compressPdf",
|
||||
"/api/v1/ai/tools/classify-and-label":
|
||||
"portal.policies.endpoints.classifyAndLabel",
|
||||
};
|
||||
|
||||
export function humanizeEndpoint(
|
||||
@@ -177,6 +170,7 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "ingestion",
|
||||
label: "portal.policies.categories.ingestion.label",
|
||||
icon: "layers",
|
||||
tone: "blue",
|
||||
desc: "portal.policies.categories.ingestion.desc",
|
||||
providesClassification: true,
|
||||
@@ -185,20 +179,14 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "security",
|
||||
label: "portal.policies.categories.security.label",
|
||||
icon: "shield",
|
||||
tone: "purple",
|
||||
desc: "portal.policies.categories.security.desc",
|
||||
},
|
||||
{
|
||||
id: "classification",
|
||||
label: "portal.policies.categories.classification.label",
|
||||
tone: "blue",
|
||||
desc: "portal.policies.categories.classification.desc",
|
||||
providesClassification: true,
|
||||
requiresAiEngine: true,
|
||||
},
|
||||
{
|
||||
id: "compliance",
|
||||
label: "portal.policies.categories.compliance.label",
|
||||
icon: "check",
|
||||
tone: "amber",
|
||||
desc: "portal.policies.categories.compliance.desc",
|
||||
comingSoon: true,
|
||||
@@ -206,6 +194,7 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "routing",
|
||||
label: "portal.policies.categories.routing.label",
|
||||
icon: "route",
|
||||
tone: "green",
|
||||
desc: "portal.policies.categories.routing.desc",
|
||||
comingSoon: true,
|
||||
@@ -213,6 +202,7 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "retention",
|
||||
label: "portal.policies.categories.retention.label",
|
||||
icon: "schedule",
|
||||
tone: "neutral",
|
||||
desc: "portal.policies.categories.retention.desc",
|
||||
comingSoon: true,
|
||||
@@ -274,16 +264,6 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
],
|
||||
fields: [],
|
||||
},
|
||||
classification: {
|
||||
summary: "portal.policies.config.classification.summary",
|
||||
rules: [
|
||||
"portal.policies.config.classification.rules.0",
|
||||
"portal.policies.config.classification.rules.1",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [policyStep("classify")],
|
||||
fields: [],
|
||||
},
|
||||
compliance: {
|
||||
summary: "portal.policies.config.compliance.summary",
|
||||
rules: [
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
|
||||
export interface QuickAction {
|
||||
group: "Jump to" | "Create" | "Theme";
|
||||
label: string;
|
||||
/** Keyboard hint shown to the right. */
|
||||
hint: string;
|
||||
}
|
||||
|
||||
/** GET /v1/search/quick-actions */
|
||||
export async function fetchQuickActions(): Promise<QuickAction[]> {
|
||||
return apiClient.local.json<QuickAction[]>("/v1/search/quick-actions");
|
||||
}
|
||||
@@ -1,19 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Sidebar } from "@portal/components/Sidebar";
|
||||
import { PortalSearchBar } from "@portal/components/PortalSearchBar";
|
||||
import "@portal/components/AppShell.css";
|
||||
|
||||
/**
|
||||
* Two-column layout: fixed-width sidebar on the left, a scrolling main column on
|
||||
* the right (topped by the global search bar). The Sidebar reads its state from
|
||||
* context, so this shell stays prop-free.
|
||||
* the right. The Sidebar reads its state from context, so this shell stays
|
||||
* prop-free.
|
||||
*/
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="portal-shell">
|
||||
<Sidebar />
|
||||
<div className="portal-shell__main">
|
||||
<PortalSearchBar />
|
||||
<main className="portal-shell__view">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type CatalogueEntry,
|
||||
type PoliciesResponse,
|
||||
} from "@portal/api/policies";
|
||||
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import "@portal/components/PolicySummary.css";
|
||||
|
||||
/**
|
||||
@@ -64,7 +64,7 @@ export function PolicySummary() {
|
||||
render: ({ entry }) => (
|
||||
<div className="portal-policysum__cat">
|
||||
<span className="portal-policysum__icon" aria-hidden>
|
||||
{policyCategoryIcon(entry.category.id)}
|
||||
{policyIcon(entry.category.icon)}
|
||||
</span>
|
||||
<div className="portal-policysum__cat-text">
|
||||
<strong>{t(entry.category.label)}</strong>
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
|
||||
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { AppShell } from "@portal/components/AppShell";
|
||||
import { SearchModal } from "@portal/components/SearchModal";
|
||||
import { PortalSettingsHost } from "@portal/components/PortalSettingsHost";
|
||||
import { ViewRouter } from "@portal/ViewRouter";
|
||||
|
||||
/**
|
||||
* Global keyboard shortcuts. Lives below the UIProvider so it can dispatch into
|
||||
* the overlay state. Currently just ⌘K / Ctrl+K to toggle the search palette.
|
||||
*/
|
||||
function GlobalShortcuts() {
|
||||
const { toggleSearch, closeSearch } = useUI();
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
|
||||
if (isCmdK) {
|
||||
e.preventDefault();
|
||||
toggleSearch();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
closeSearch();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [toggleSearch, closeSearch]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The routed view, wrapped in an error boundary so a single view crashing can't
|
||||
* white-screen the portal (the shell + nav stay alive). Keyed by route so
|
||||
@@ -20,21 +49,23 @@ function RoutedContent() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The flavor-agnostic portal chrome: the shell (sidebar + search bar + routed
|
||||
* view) plus the global overlays that every flavor shares. Requires only the
|
||||
* Tier and UI contexts above it — both flavors provide those. Flavor-specific
|
||||
* overlays (e.g. the self-hosted account-link modal) are mounted by
|
||||
* PortalProviders, not here.
|
||||
* The flavor-agnostic portal chrome: the shell (sidebar + header + routed view)
|
||||
* plus the global overlays that every flavor shares. Requires only the Tier and
|
||||
* UI contexts above it — both flavors provide those. Flavor-specific overlays
|
||||
* (e.g. the self-hosted account-link modal) are mounted by PortalProviders, not
|
||||
* here.
|
||||
*/
|
||||
export function PortalChrome() {
|
||||
return (
|
||||
<>
|
||||
<GlobalShortcuts />
|
||||
{/* The pipeline builder reads the tool registry to list and configure operations. */}
|
||||
<ToolRegistryProvider>
|
||||
<AppShell>
|
||||
<RoutedContent />
|
||||
</AppShell>
|
||||
</ToolRegistryProvider>
|
||||
<SearchModal />
|
||||
<PortalSettingsHost />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/* Slim strip at the top of the main column hosting the shared search bar. */
|
||||
.portal-searchbar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) var(--space-3) 0;
|
||||
}
|
||||
|
||||
.portal-searchbar .super-search {
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
|
||||
import { usePortalSearchResults } from "@portal/hooks/usePortalSearchResults";
|
||||
import "@portal/components/PortalSearchBar.css";
|
||||
|
||||
/**
|
||||
* The portal face of the global super search — the same bar the editor's
|
||||
* workbench shows, fed by the portal-wired results provider. Cmd/Ctrl+K
|
||||
* focuses it (the bar registers its own shortcut). The config provider
|
||||
* supplies the admin/login gates the settings and Processor sources share
|
||||
* with the editor bar.
|
||||
*/
|
||||
export function PortalSearchBar() {
|
||||
return (
|
||||
<AppConfigProvider bootstrapMode="non-blocking">
|
||||
<div className="portal-searchbar">
|
||||
<SuperSearch useResults={usePortalSearchResults} />
|
||||
</div>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
.portal-search__input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.25rem 0.25rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-search__input {
|
||||
flex: 1 1 auto;
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-search__input::placeholder {
|
||||
color: var(--color-text-placeholder);
|
||||
}
|
||||
|
||||
.portal-search__esc {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-search__results {
|
||||
padding-top: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-height: 24rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.portal-search__group-label {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-section-label);
|
||||
padding: 0 0.5rem 0.375rem;
|
||||
}
|
||||
|
||||
.portal-search__item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-2);
|
||||
font-size: 0.8125rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.portal-search__item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-search__item-hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useEffect } from "react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { SearchModal } from "@portal/components/SearchModal";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
|
||||
function ForceOpen() {
|
||||
const { openSearch } = useUI();
|
||||
useEffect(() => {
|
||||
openSearch();
|
||||
}, [openSearch]);
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta: Meta<typeof SearchModal> = {
|
||||
title: "Portal/Header/SearchModal",
|
||||
component: SearchModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||
<ForceOpen />
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SearchModal>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const EmptyCatalogue: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/search/quick-actions", () => HttpResponse.json([])),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, EmptyState, Modal, Skeleton } from "@app/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import { fetchQuickActions, type QuickAction } from "@portal/api/search";
|
||||
import { SearchIcon } from "@portal/components/icons";
|
||||
import "@portal/components/SearchModal.css";
|
||||
|
||||
export function SearchModal() {
|
||||
const { t } = useTranslation();
|
||||
const { searchOpen, closeSearch } = useUI();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const state = useAsync<QuickAction[]>(() => fetchQuickActions(), []);
|
||||
const { data: actions } = state;
|
||||
const { isLoading } = useSectionFlags(state);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchOpen) {
|
||||
setQuery("");
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
return undefined;
|
||||
}, [searchOpen]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!actions) return [] as QuickAction[];
|
||||
if (!query.trim()) return actions;
|
||||
const needle = query.trim().toLowerCase();
|
||||
return actions.filter((item) => item.label.toLowerCase().includes(needle));
|
||||
}, [actions, query]);
|
||||
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
filtered.reduce<Record<string, QuickAction[]>>((acc, item) => {
|
||||
if (!acc[item.group]) acc[item.group] = [];
|
||||
acc[item.group].push(item);
|
||||
return acc;
|
||||
}, {}),
|
||||
[filtered],
|
||||
);
|
||||
|
||||
const groupKeys = Object.keys(groups);
|
||||
const isEmpty = !isLoading && groupKeys.length === 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={searchOpen}
|
||||
onClose={closeSearch}
|
||||
width="lg"
|
||||
ariaLabel={t("portal.search.ariaLabel")}
|
||||
>
|
||||
<div className="portal-search">
|
||||
<div className="portal-search__input-row">
|
||||
<SearchIcon size={16} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("portal.search.placeholder")}
|
||||
aria-label={t("portal.search.ariaLabel")}
|
||||
className="portal-search__input"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<span className="portal-search__esc" aria-hidden>
|
||||
ESC
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="portal-search__results">
|
||||
{isLoading && (
|
||||
<div className="portal-search__loading">
|
||||
<Skeleton height="1rem" width="40%" />
|
||||
<Skeleton height="0.875rem" />
|
||||
<Skeleton height="0.875rem" width="80%" />
|
||||
<Skeleton height="0.875rem" width="65%" />
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={
|
||||
query.trim()
|
||||
? t("portal.search.empty.noMatches", { query: query.trim() })
|
||||
: t("portal.search.empty.noActionsTitle")
|
||||
}
|
||||
description={
|
||||
query.trim()
|
||||
? t("portal.search.empty.noMatchesDescription")
|
||||
: t("portal.search.empty.noActionsDescription")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isLoading &&
|
||||
!isEmpty &&
|
||||
Object.entries(groups).map(([group, items]) => (
|
||||
<div key={group} className="portal-search__group">
|
||||
<div className="portal-search__group-label">{group}</div>
|
||||
{items.map((item) => (
|
||||
<Button
|
||||
key={item.label}
|
||||
variant="tertiary"
|
||||
justify="start"
|
||||
fullWidth
|
||||
className="portal-search__item"
|
||||
onClick={closeSearch}
|
||||
>
|
||||
<span className="portal-search__item-label">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="portal-search__item-hint">
|
||||
{item.hint}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/* Read-only classification-vocabulary viewer in the policy wizard. */
|
||||
.classification-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.classification-summary-stats {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.classification-summary-note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* Expandable category → labels list. */
|
||||
.classification-categories {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.classification-category {
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
.classification-category:last-child {
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
.classification-category-header {
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
.classification-category-lead {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.classification-category-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.classification-category-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.classification-category-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1_5);
|
||||
padding: 0 var(--space-2) var(--space-2) 2rem;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// Read-only view of the classification vocabulary shown in the policy wizard. The labels and their
|
||||
// categories are a fixed, built-in set shared across the whole team — there's nothing to edit, but
|
||||
// the full vocabulary is browsable: expand a category to see the labels it groups.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
|
||||
import { Button, Card, Chip } from "@app/ui";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import {
|
||||
DEFAULT_CLASSIFICATION_LABELS,
|
||||
LABEL_FAMILIES,
|
||||
} from "@app/data/classificationLabels";
|
||||
import "@portal/components/policies/ClassificationLabelsSection.css";
|
||||
|
||||
export function ClassificationLabelsSection() {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="classification-summary">
|
||||
<div className="classification-summary-stats">
|
||||
<span>
|
||||
<strong>{DEFAULT_CLASSIFICATION_LABELS.length}</strong>{" "}
|
||||
{t("policies.labels.labelCount", "labels")}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{LABEL_FAMILIES.length}</strong>{" "}
|
||||
{t("policies.labels.categoryCount", "categories")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="classification-categories">
|
||||
{LABEL_FAMILIES.map((family) => {
|
||||
const open = expanded.has(family.id);
|
||||
return (
|
||||
<li key={family.id} className="classification-category">
|
||||
<Button
|
||||
variant="quiet"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="classification-category-header"
|
||||
aria-expanded={open}
|
||||
onClick={() => toggle(family.id)}
|
||||
leftSection={
|
||||
<span className="classification-category-lead">
|
||||
{open ? (
|
||||
<KeyboardArrowDownIcon sx={{ fontSize: "1.1rem" }} />
|
||||
) : (
|
||||
<KeyboardArrowRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
)}
|
||||
<LocalIcon icon={family.icon} width="1.1rem" />
|
||||
<span className="classification-category-name">
|
||||
{family.name}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
rightSection={
|
||||
<span className="classification-category-count">
|
||||
{family.labels.length}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{open && (
|
||||
<div className="classification-category-labels">
|
||||
{family.labels.map((label) => (
|
||||
<Chip key={label.id} accent="neutral" size="sm">
|
||||
{t(`classification.labels.${label.id}`, label.name)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<span className="classification-summary-note">
|
||||
{t(
|
||||
"policies.labels.sharedNote",
|
||||
"These labels are built in and shared across your whole team.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,19 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, Chip, StatusBadge } from "@app/ui";
|
||||
import type { CatalogueEntry } from "@portal/api/policies";
|
||||
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import "@portal/views/Policies.css";
|
||||
|
||||
interface PolicyCategoryCardProps {
|
||||
entry: CatalogueEntry;
|
||||
onOpen: (entry: CatalogueEntry) => void;
|
||||
/** Setup is unavailable (e.g. the AI engine is off): shown, but not openable. */
|
||||
locked?: boolean;
|
||||
/** Chip text explaining why setup is locked (e.g. "Requires AI engine"). */
|
||||
lockedLabel?: string;
|
||||
}
|
||||
|
||||
export function PolicyCategoryCard({
|
||||
entry,
|
||||
onOpen,
|
||||
locked = false,
|
||||
lockedLabel,
|
||||
}: PolicyCategoryCardProps) {
|
||||
export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { category, config, policy } = entry;
|
||||
const comingSoon = category.comingSoon === true;
|
||||
const openable = !comingSoon && !locked;
|
||||
const openable = !comingSoon;
|
||||
const status = policy?.state.status;
|
||||
const enforces = config.rules.map((r) => t(r)).join(" · ");
|
||||
|
||||
@@ -30,7 +21,7 @@ export function PolicyCategoryCard({
|
||||
<Card
|
||||
className={
|
||||
"portal-policies__card" +
|
||||
(comingSoon || locked ? " portal-policies__card--locked" : "")
|
||||
(comingSoon ? " portal-policies__card--locked" : "")
|
||||
}
|
||||
interactive={openable}
|
||||
onClick={openable ? () => onOpen(entry) : undefined}
|
||||
@@ -48,7 +39,7 @@ export function PolicyCategoryCard({
|
||||
}
|
||||
>
|
||||
<span className="portal-policies__cat-icon" aria-hidden>
|
||||
{policyCategoryIcon(category.id)}
|
||||
{policyIcon(category.icon)}
|
||||
</span>
|
||||
|
||||
<div className="portal-policies__card-identity">
|
||||
@@ -62,10 +53,6 @@ export function PolicyCategoryCard({
|
||||
<Chip accent="neutral" size="sm">
|
||||
{t("portal.policies.card.comingSoon")}
|
||||
</Chip>
|
||||
) : locked ? (
|
||||
<Chip accent="neutral" size="sm">
|
||||
{lockedLabel ?? t("portal.policies.card.requiresAiEngine")}
|
||||
</Chip>
|
||||
) : policy ? (
|
||||
<div className="portal-policies__card-meta">
|
||||
<span className="portal-policies__card-statpair">
|
||||
|
||||
@@ -8,9 +8,6 @@ import {
|
||||
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
|
||||
|
||||
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
|
||||
const classification = POLICY_CATEGORIES.find(
|
||||
(c) => c.id === "classification",
|
||||
)!;
|
||||
|
||||
const meta: Meta<typeof PolicySetupWizard> = {
|
||||
title: "Portal/Policies/PolicySetupWizard",
|
||||
@@ -50,14 +47,3 @@ export const Edit: Story = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Classification: the workflow step shows the team label editor, not tool toggles. */
|
||||
export const Classification: Story = {
|
||||
args: {
|
||||
entry: {
|
||||
category: classification,
|
||||
config: POLICY_CONFIG.classification,
|
||||
policy: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
|
||||
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
|
||||
import StorageOutlinedIcon from "@mui/icons-material/StorageOutlined";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -34,27 +29,12 @@ import {
|
||||
import { fetchSources } from "@portal/api/sources";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
|
||||
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
|
||||
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
|
||||
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
|
||||
import { ClassificationLabelsSection } from "@portal/components/policies/ClassificationLabelsSection";
|
||||
import "@portal/views/Policies.css";
|
||||
|
||||
/** Outline icon for a source tile, keyed by the backend source `type`. */
|
||||
function sourceIcon(type: string): ReactNode {
|
||||
const sx = { fontSize: "1.1rem" } as const;
|
||||
switch (type) {
|
||||
case "editor":
|
||||
return <EditOutlinedIcon sx={sx} />;
|
||||
case "folder":
|
||||
return <FolderOutlinedIcon sx={sx} />;
|
||||
case "s3":
|
||||
return <CloudOutlinedIcon sx={sx} />;
|
||||
default:
|
||||
return <StorageOutlinedIcon sx={sx} />;
|
||||
}
|
||||
}
|
||||
|
||||
interface PolicySetupWizardProps {
|
||||
/** The category being configured, or null when closed. */
|
||||
entry: CatalogueEntry | null;
|
||||
@@ -139,13 +119,6 @@ const CAPABILITY_META: Record<
|
||||
descKey: "portal.policies.wizard.capability.compress.desc",
|
||||
descEn: "Compresses the document to a smaller file size.",
|
||||
},
|
||||
classify: {
|
||||
labelKey: "portal.policies.wizard.capability.classify.label",
|
||||
labelEn: "Classify the document",
|
||||
descKey: "portal.policies.wizard.capability.classify.desc",
|
||||
descEn:
|
||||
"Identifies the document's type from your team's labels and tags it, so it files and searches by category.",
|
||||
},
|
||||
};
|
||||
|
||||
function seedTools(entry: CatalogueEntry): ToolState[] {
|
||||
@@ -206,18 +179,9 @@ function PolicySetupWizardBody({
|
||||
|
||||
const { category, config, policy } = entry;
|
||||
const isEdit = policy != null;
|
||||
const isClassification = category.id === "classification";
|
||||
|
||||
const [step, setStep] = useState<Step>("workflow");
|
||||
const [tools, setTools] = useState<ToolState[]>(() => {
|
||||
const seeded = seedTools(entry);
|
||||
// Classification's single tool has no toggle in the workflow step, so keep it
|
||||
// enabled unconditionally — otherwise editing a policy whose saved steps
|
||||
// somehow lack it would strand submit with no way to re-enable it.
|
||||
return isClassification
|
||||
? seeded.map((t) => ({ ...t, enabled: true }))
|
||||
: seeded;
|
||||
});
|
||||
const [tools, setTools] = useState<ToolState[]>(() => seedTools(entry));
|
||||
const [fieldValues, setFieldValues] = useState(() =>
|
||||
resolveFieldValues(entry),
|
||||
);
|
||||
@@ -226,25 +190,11 @@ function PolicySetupWizardBody({
|
||||
);
|
||||
|
||||
const sourcesAsync = useAsync(() => fetchSources(), []);
|
||||
const availableSources = useMemo(() => {
|
||||
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
|
||||
(s) => s.status !== "disabled",
|
||||
);
|
||||
// The editor is always an available source. The backend now returns it as a
|
||||
// virtual source too, so take that when present (avoids a duplicate tile) and
|
||||
// otherwise fall back to a synthetic one; keep it first, selected by default.
|
||||
const editorSource = backendSources.find((s) => s.id === "editor") ?? {
|
||||
id: "editor",
|
||||
name: t("portal.sources.types.editor.label"),
|
||||
type: "editor",
|
||||
status: "active" as const,
|
||||
referenceCount: 0,
|
||||
referencingPolicies: [],
|
||||
config: [],
|
||||
docsTotal: null,
|
||||
};
|
||||
return [editorSource, ...backendSources.filter((s) => s.id !== "editor")];
|
||||
}, [sourcesAsync.data, t]);
|
||||
const availableSources = useMemo(
|
||||
() =>
|
||||
(sourcesAsync.data?.sources ?? []).filter((s) => s.status !== "disabled"),
|
||||
[sourcesAsync.data],
|
||||
);
|
||||
// Document-type scoping has no UI; preserve any saved scope on edit and
|
||||
// default new policies to all document types.
|
||||
const [scopeTypes] = useState<string[]>(policy?.state.scopeTypes ?? []);
|
||||
@@ -335,7 +285,7 @@ function PolicySetupWizardBody({
|
||||
title={
|
||||
<span className="portal-policies__wizard-title">
|
||||
<span className="portal-policies__cat-icon" aria-hidden>
|
||||
{policyCategoryIcon(category.id)}
|
||||
{policyIcon(category.icon)}
|
||||
</span>
|
||||
{isEdit
|
||||
? t("portal.policies.wizard.title.edit", {
|
||||
@@ -399,25 +349,7 @@ function PolicySetupWizardBody({
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "workflow" && isClassification && (
|
||||
<div className="portal-policies__wizard-section">
|
||||
<p className="portal-policies__wizard-desc">
|
||||
{t(
|
||||
"portal.policies.wizard.classification.description",
|
||||
"Every uploaded document is classified against the built-in labels and tagged with the types that fit. The label set is shared across your whole team.",
|
||||
)}
|
||||
</p>
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
{t(
|
||||
"portal.policies.wizard.classification.labelsHeading",
|
||||
"Classification labels",
|
||||
)}
|
||||
</h3>
|
||||
<ClassificationLabelsSection />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "workflow" && !isClassification && (
|
||||
{step === "workflow" && (
|
||||
<div className="portal-policies__wizard-section">
|
||||
<p className="portal-policies__wizard-desc">
|
||||
{t(
|
||||
@@ -516,38 +448,35 @@ function PolicySetupWizardBody({
|
||||
// The backend always returns the editor as a virtual source, so the
|
||||
// loaded list is never empty - no "no sources" state exists.
|
||||
<div className="portal-policies__sources">
|
||||
{availableSources.map((src) => {
|
||||
const on = sources.includes(src.id);
|
||||
return (
|
||||
<Button
|
||||
key={src.id}
|
||||
variant={on ? "secondary" : "quiet"}
|
||||
justify="between"
|
||||
fullWidth
|
||||
className={
|
||||
"portal-policies__source" +
|
||||
(on ? " portal-policies__source--on" : "")
|
||||
}
|
||||
// The check keeps its slot when unselected (hidden) so the
|
||||
// icon + name stay put whether or not the tile is selected.
|
||||
rightSection={
|
||||
<CheckIcon
|
||||
sx={{
|
||||
fontSize: "1.1rem",
|
||||
visibility: on ? "visible" : "hidden",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={() => toggleSource(src.id)}
|
||||
aria-pressed={on}
|
||||
>
|
||||
{availableSources.map((src) => (
|
||||
// A selectable multi-line tile (icon + name + type + check).
|
||||
// Uses the shared Button (raw <button> is lint-banned); the tile
|
||||
// CSS overrides the Button's fixed height for the two-line layout.
|
||||
<Button
|
||||
key={src.id}
|
||||
variant="quiet"
|
||||
justify="start"
|
||||
className={
|
||||
"portal-policies__source" +
|
||||
(sources.includes(src.id)
|
||||
? " portal-policies__source--on"
|
||||
: "")
|
||||
}
|
||||
onClick={() => toggleSource(src.id)}
|
||||
>
|
||||
<span className="portal-policies__source-icon" aria-hidden>
|
||||
{sourceTypeMeta(src.type).icon}
|
||||
</span>
|
||||
<span className="portal-policies__source-text">
|
||||
<span className="portal-policies__source-label">
|
||||
{sourceIcon(src.type)}
|
||||
{src.name}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<span className="portal-policies__source-desc">
|
||||
{src.type}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Glyphs for the policy catalogue's string icon keys. The catalogue model
|
||||
* carries semantic keys (e.g. "shield", "layers") rather than React nodes so
|
||||
* the data stays portable; the portal owns the rendering and maps each key to a
|
||||
* glyph here.
|
||||
*/
|
||||
export const POLICY_ICON_GLYPHS: Record<string, string> = {
|
||||
layers: "▤",
|
||||
shield: "🛡",
|
||||
check: "✓",
|
||||
route: "⇉",
|
||||
clock: "⏲",
|
||||
// Source icons.
|
||||
file: "▢",
|
||||
device: "▣",
|
||||
globe: "◍",
|
||||
cloud: "☁",
|
||||
mail: "✉",
|
||||
folder: "▤",
|
||||
};
|
||||
|
||||
/** Resolve an icon key to its glyph, falling back to a neutral dot. */
|
||||
export function policyIcon(key: string): string {
|
||||
return POLICY_ICON_GLYPHS[key] ?? "•";
|
||||
}
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
} from "react";
|
||||
|
||||
interface UIContextValue {
|
||||
searchOpen: boolean;
|
||||
openSearch: () => void;
|
||||
closeSearch: () => void;
|
||||
toggleSearch: () => void;
|
||||
|
||||
assistantOpen: boolean;
|
||||
openAssistant: () => void;
|
||||
closeAssistant: () => void;
|
||||
@@ -42,6 +47,7 @@ interface UIContextValue {
|
||||
const UIContext = createContext<UIContextValue | null>(null);
|
||||
|
||||
export function UIProvider({ children }: { children: ReactNode }) {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [assistantOpen, setAssistantOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<
|
||||
@@ -57,6 +63,11 @@ export function UIProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const value = useMemo<UIContextValue>(
|
||||
() => ({
|
||||
searchOpen,
|
||||
openSearch: () => setSearchOpen(true),
|
||||
closeSearch: () => setSearchOpen(false),
|
||||
toggleSearch: () => setSearchOpen((o) => !o),
|
||||
|
||||
assistantOpen,
|
||||
openAssistant: () => setAssistantOpen(true),
|
||||
closeAssistant: () => setAssistantOpen(false),
|
||||
@@ -97,6 +108,7 @@ export function UIProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
}),
|
||||
[
|
||||
searchOpen,
|
||||
assistantOpen,
|
||||
settingsOpen,
|
||||
settingsInitialSection,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// AI-engine flag from the backend's public app-config. Gates Classification
|
||||
// setup: the card always shows but can't be enabled until the engine is on.
|
||||
// `loading` lets callers hold the decision rather than flash a locked card.
|
||||
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
|
||||
interface AppConfigShape {
|
||||
aiEngineEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface AiEngineState {
|
||||
enabled: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useAiEngineEnabled(): AiEngineState {
|
||||
const state = useAsync<AppConfigShape>(
|
||||
() => apiClient.local.json<AppConfigShape>("/api/v1/config/app-config"),
|
||||
[],
|
||||
);
|
||||
return {
|
||||
enabled: Boolean(state.data?.aiEngineEnabled),
|
||||
loading: state.loading && state.data === null,
|
||||
};
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import { getToolUrlPath } from "@app/data/toolsTaxonomy";
|
||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { rankByFuzzy } from "@app/utils/fuzzySearch";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
import type { ProcessorSearchEntry } from "@app/data/processorSearchIndex";
|
||||
import {
|
||||
assembleSuperSearchGroups,
|
||||
rankProcessorResults,
|
||||
rankSettingsResults,
|
||||
rankToolResults,
|
||||
type SuperSearchGates,
|
||||
type SuperSearchGroup,
|
||||
type SuperSearchGroupId,
|
||||
type UseSuperSearchResult,
|
||||
} from "@app/hooks/useSuperSearch";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
|
||||
import { EDITOR_IS_SAME_APP, EDITOR_URL } from "@portal/auth/editorUrl";
|
||||
import { fetchUsers, type Member } from "@portal/api/users";
|
||||
import { fetchPolicies, type CatalogueEntry } from "@portal/api/policies";
|
||||
import { fetchPipelines, type PipelineView } from "@portal/api/pipelines";
|
||||
import { fetchSources, type SourceView } from "@portal/api/sources";
|
||||
import {
|
||||
UsersIcon,
|
||||
PoliciesIcon,
|
||||
PipelinesIcon,
|
||||
SourcesIcon,
|
||||
} from "@portal/components/icons";
|
||||
|
||||
/** Processor pages lead in the portal; the editor bar orders its own first. */
|
||||
const PORTAL_GROUP_ORDER: SuperSearchGroupId[] = [
|
||||
"processor",
|
||||
"tools",
|
||||
"settings",
|
||||
];
|
||||
|
||||
/** Entity groups cap lower than the shared GROUP_LIMIT — with up to seven
|
||||
* groups in the portal dropdown, six rows each stops being scannable. */
|
||||
const ENTITY_GROUP_LIMIT = 4;
|
||||
|
||||
/**
|
||||
* Tool results live in the editor app, so selecting one is a full page load
|
||||
* there (the editor initialises its tool state from the URL on boot —
|
||||
* client-side routing can't reach that init once mounted).
|
||||
*/
|
||||
function editorHref(path: string): string {
|
||||
if (EDITOR_IS_SAME_APP) return withBasePath(path);
|
||||
return EDITOR_URL.replace(/\/$/, "") + path;
|
||||
}
|
||||
|
||||
interface PortalEntities {
|
||||
users: Member[];
|
||||
policies: CatalogueEntry[];
|
||||
pipelines: PipelineView[];
|
||||
sources: SourceView[];
|
||||
}
|
||||
|
||||
const NO_ENTITIES: PortalEntities = {
|
||||
users: [],
|
||||
policies: [],
|
||||
pipelines: [],
|
||||
sources: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads the portal's searchable entities whenever the search surface opens,
|
||||
* then ranking happens client-side per keystroke. allSettled so one failing
|
||||
* endpoint (e.g. policies on a backend without them) doesn't drop the rest.
|
||||
*/
|
||||
function usePortalEntities(active: boolean): PortalEntities {
|
||||
const { tier } = useTier();
|
||||
const [entities, setEntities] = useState<PortalEntities>(NO_ENTITIES);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
let cancelled = false;
|
||||
Promise.allSettled([
|
||||
fetchUsers(tier),
|
||||
fetchPolicies(),
|
||||
fetchPipelines(),
|
||||
fetchSources(),
|
||||
]).then(([users, policies, pipelines, sources]) => {
|
||||
if (cancelled) return;
|
||||
setEntities({
|
||||
users: users.status === "fulfilled" ? users.value.members : [],
|
||||
policies:
|
||||
policies.status === "fulfilled" ? policies.value.catalogue : [],
|
||||
pipelines:
|
||||
pipelines.status === "fulfilled" ? pipelines.value.pipelines : [],
|
||||
sources: sources.status === "fulfilled" ? sources.value.sources : [],
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, tier]);
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* The portal's results provider for the shared super search bar: the same
|
||||
* sources the editor bar ranks minus files (a file only opens inside the
|
||||
* editor), with Processor pages leading and the portal's own entities —
|
||||
* users, policies, pipelines, sources — ranked between the pages and the
|
||||
* shared groups. Tools hand over to the editor, settings opens the portal's
|
||||
* settings modal, everything else navigates in-app.
|
||||
*/
|
||||
export function usePortalSearchResults(
|
||||
query: string,
|
||||
active: boolean,
|
||||
): UseSuperSearchResult {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { openSettings } = useUI();
|
||||
const { allTools } = useToolRegistry();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
const trimmed = query.trim();
|
||||
const entities = usePortalEntities(active);
|
||||
|
||||
const openTool = useCallback((id: ToolId) => {
|
||||
window.location.assign(editorHref(getToolUrlPath(id)));
|
||||
}, []);
|
||||
|
||||
const openSettingsSection = useCallback(
|
||||
(section: string) => openSettings(section),
|
||||
[openSettings],
|
||||
);
|
||||
|
||||
const selectProcessorEntry = useCallback(
|
||||
(item: ProcessorSearchEntry) => {
|
||||
if (item.externalUrl) {
|
||||
window.open(item.externalUrl, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
navigate(item.path);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const gates = useMemo<SuperSearchGates>(
|
||||
() => ({
|
||||
isAdmin: config?.isAdmin ?? false,
|
||||
loginEnabled: config?.enableLogin ?? false,
|
||||
}),
|
||||
[config],
|
||||
);
|
||||
|
||||
const entityGroups = useMemo<SuperSearchGroup[]>(() => {
|
||||
if (!trimmed) return [];
|
||||
const groups: SuperSearchGroup[] = [];
|
||||
|
||||
const users = rankByFuzzy(entities.users, trimmed, [
|
||||
(m) => m.name,
|
||||
(m) => m.email,
|
||||
])
|
||||
.slice(0, ENTITY_GROUP_LIMIT)
|
||||
.map(({ item, score }) => ({
|
||||
key: `portal-user:${item.id}`,
|
||||
group: "portal-users",
|
||||
title: item.name,
|
||||
subtitle: item.email,
|
||||
icon: <UsersIcon />,
|
||||
score,
|
||||
onSelect: () => navigate(toPortalPath(VIEW_PATHS.users)),
|
||||
}));
|
||||
if (users.length > 0) {
|
||||
groups.push({
|
||||
id: "portal-users",
|
||||
label: t("portal.nav.users"),
|
||||
results: users,
|
||||
});
|
||||
}
|
||||
|
||||
const policies = rankByFuzzy(
|
||||
entities.policies.filter((e) => !e.category.comingSoon),
|
||||
trimmed,
|
||||
[(e) => e.category.label, (e) => e.category.desc],
|
||||
)
|
||||
.slice(0, ENTITY_GROUP_LIMIT)
|
||||
.map(({ item, score }) => ({
|
||||
key: `portal-policy:${item.category.id}`,
|
||||
group: "portal-policies",
|
||||
title: item.category.label,
|
||||
subtitle: item.category.desc,
|
||||
icon: <PoliciesIcon />,
|
||||
score,
|
||||
onSelect: () => navigate(toPortalPath(VIEW_PATHS.policies)),
|
||||
}));
|
||||
if (policies.length > 0) {
|
||||
groups.push({
|
||||
id: "portal-policies",
|
||||
label: t("portal.nav.policies"),
|
||||
results: policies,
|
||||
});
|
||||
}
|
||||
|
||||
const pipelines = rankByFuzzy(entities.pipelines, trimmed, [
|
||||
(p) => p.name,
|
||||
(p) => p.trigger,
|
||||
])
|
||||
.slice(0, ENTITY_GROUP_LIMIT)
|
||||
.map(({ item, score }) => ({
|
||||
key: `portal-pipeline:${item.id}`,
|
||||
group: "portal-pipelines",
|
||||
title: item.name,
|
||||
subtitle: item.trigger,
|
||||
icon: <PipelinesIcon />,
|
||||
score,
|
||||
onSelect: () =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${item.id}`),
|
||||
}));
|
||||
if (pipelines.length > 0) {
|
||||
groups.push({
|
||||
id: "portal-pipelines",
|
||||
label: t("portal.nav.pipelines"),
|
||||
results: pipelines,
|
||||
});
|
||||
}
|
||||
|
||||
const sources = rankByFuzzy(entities.sources, trimmed, [
|
||||
(s) => s.name,
|
||||
(s) => s.type,
|
||||
])
|
||||
.slice(0, ENTITY_GROUP_LIMIT)
|
||||
.map(({ item, score }) => ({
|
||||
key: `portal-source:${item.id}`,
|
||||
group: "portal-sources",
|
||||
title: item.name,
|
||||
subtitle: item.type,
|
||||
icon: <SourcesIcon />,
|
||||
score,
|
||||
onSelect: () =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.sources)}/${item.id}`),
|
||||
}));
|
||||
if (sources.length > 0) {
|
||||
groups.push({
|
||||
id: "portal-sources",
|
||||
label: t("portal.nav.sources"),
|
||||
results: sources,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [entities, trimmed, t, navigate]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const shared = assembleSuperSearchGroups(
|
||||
{
|
||||
tools: rankToolResults(allTools, trimmed, openTool),
|
||||
settings: rankSettingsResults(trimmed, t, gates, openSettingsSection),
|
||||
processor: rankProcessorResults(
|
||||
trimmed,
|
||||
t,
|
||||
gates,
|
||||
selectProcessorEntry,
|
||||
),
|
||||
},
|
||||
t,
|
||||
PORTAL_GROUP_ORDER,
|
||||
);
|
||||
// Entities slot in right after the Processor pages (index -1 + 1 = 0
|
||||
// puts them first when no page matched).
|
||||
const out = [...shared];
|
||||
out.splice(
|
||||
out.findIndex((g) => g.id === "processor") + 1,
|
||||
0,
|
||||
...entityGroups,
|
||||
);
|
||||
return out;
|
||||
}, [
|
||||
trimmed,
|
||||
allTools,
|
||||
openTool,
|
||||
gates,
|
||||
openSettingsSection,
|
||||
selectProcessorEntry,
|
||||
entityGroups,
|
||||
t,
|
||||
]);
|
||||
|
||||
const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]);
|
||||
|
||||
return { groups, flatResults, loadingFiles: false };
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
// The Classification policy only needs the app-config `aiEngineEnabled` flag to be
|
||||
// on; its label vocabulary is a fixed built-in set (no team endpoint anymore).
|
||||
|
||||
export const classificationHandlers = [
|
||||
http.get("/api/v1/config/app-config", () =>
|
||||
HttpResponse.json({ aiEngineEnabled: true }),
|
||||
),
|
||||
];
|
||||
@@ -1,6 +1,7 @@
|
||||
import { assistantHandlers } from "@portal/mocks/handlers/assistant";
|
||||
import { authHandlers } from "@portal/mocks/handlers/auth";
|
||||
import { notificationsHandlers } from "@portal/mocks/handlers/notifications";
|
||||
import { searchHandlers } from "@portal/mocks/handlers/search";
|
||||
import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines";
|
||||
import { sourcesHandlers } from "@portal/mocks/handlers/sources";
|
||||
import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure";
|
||||
@@ -11,7 +12,6 @@ import { usersHandlers } from "@portal/mocks/handlers/users";
|
||||
import { teamSaasHandlers } from "@portal/mocks/handlers/teamSaas";
|
||||
import { agentsHandlers } from "@portal/mocks/handlers/agents";
|
||||
import { policiesHandlers } from "@portal/mocks/handlers/policies";
|
||||
import { classificationHandlers } from "@portal/mocks/handlers/classification";
|
||||
import { documentsHandlers } from "@portal/mocks/handlers/documents";
|
||||
import { sdkComponentsHandlers } from "@portal/mocks/handlers/sdkComponents";
|
||||
import { editorDeployHandlers } from "@portal/mocks/handlers/editorDeploy";
|
||||
@@ -21,6 +21,7 @@ export const handlers = [
|
||||
...authHandlers,
|
||||
...notificationsHandlers,
|
||||
...assistantHandlers,
|
||||
...searchHandlers,
|
||||
...pipelinesHandlers,
|
||||
...sourcesHandlers,
|
||||
...infrastructureHandlers,
|
||||
@@ -31,7 +32,6 @@ export const handlers = [
|
||||
...teamSaasHandlers,
|
||||
...agentsHandlers,
|
||||
...policiesHandlers,
|
||||
...classificationHandlers,
|
||||
...documentsHandlers,
|
||||
...sdkComponentsHandlers,
|
||||
...editorDeployHandlers,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { QUICK_ACTIONS } from "@portal/mocks/search";
|
||||
|
||||
export const searchHandlers = [
|
||||
http.get("/v1/search/quick-actions", () => {
|
||||
return HttpResponse.json(QUICK_ACTIONS);
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Mock quick-action catalogue for the ⌘K search palette. The QuickAction type
|
||||
* lives in api/search.ts (the backend contract); this module only builds fake
|
||||
* data for Storybook and tests.
|
||||
*/
|
||||
|
||||
import type { QuickAction } from "@portal/api/search";
|
||||
|
||||
export const QUICK_ACTIONS: QuickAction[] = [
|
||||
{ group: "Jump to", label: "Home", hint: "G H" },
|
||||
{ group: "Jump to", label: "Pipelines", hint: "G P" },
|
||||
{ group: "Jump to", label: "Sources", hint: "G S" },
|
||||
{ group: "Jump to", label: "Documents", hint: "G D" },
|
||||
{ group: "Create", label: "New pipeline", hint: "N P" },
|
||||
{ group: "Create", label: "New API key", hint: "N K" },
|
||||
{ group: "Theme", label: "Toggle dark / light", hint: "T" },
|
||||
];
|
||||
@@ -266,23 +266,116 @@
|
||||
}
|
||||
|
||||
/* Sources picker */
|
||||
/* Selectable source tiles: a vertical stack of full-width shared Buttons
|
||||
(icon · name · check). Layout is the Button's own leftSection/label/
|
||||
rightSection — only the selected border/tint is added here. */
|
||||
.portal-policies__sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.portal-policies__sources {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-policies__source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem;
|
||||
text-align: left;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
/* Shared Button pins a fixed control height and natural width; this tile is a
|
||||
full-width, two-line card, so fill the grid cell and grow to fit content. */
|
||||
width: 100%;
|
||||
height: auto;
|
||||
font-weight: inherit;
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
|
||||
/* The shared Button wraps children in an inner/label node. Let the inner grow
|
||||
(so the ::after check sits at the far right) and the label carry the
|
||||
icon + two-line-text row. */
|
||||
.portal-policies__source .mantine-Button-inner {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-policies__source .mantine-Button-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.portal-policies__source::after {
|
||||
content: "";
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1.5px solid var(--color-border-strong);
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-policies__source:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-policies__source--on {
|
||||
border-color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
}
|
||||
|
||||
.portal-policies__source--on::after {
|
||||
content: "✓";
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
border-color: var(--color-blue);
|
||||
background: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-policies__source-icon {
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-policies__source--on .portal-policies__source-icon {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-policies__source-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-policies__source-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-policies__source-desc {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.portal-policies__link {
|
||||
|
||||
@@ -20,7 +20,6 @@ import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary";
|
||||
import { PolicyCategoryCard } from "@portal/components/policies/PolicyCategoryCard";
|
||||
import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel";
|
||||
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
|
||||
import { useAiEngineEnabled } from "@portal/hooks/useAiEngineEnabled";
|
||||
import "@portal/views/Policies.css";
|
||||
|
||||
export function Policies() {
|
||||
@@ -35,18 +34,6 @@ export function Policies() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pageError, setPageError] = useState<string | null>(null);
|
||||
|
||||
const { enabled: aiEngineEnabled, loading: aiEngineLoading } =
|
||||
useAiEngineEnabled();
|
||||
|
||||
function isLocked(entry: CatalogueEntry): boolean {
|
||||
return (
|
||||
entry.category.requiresAiEngine === true &&
|
||||
!aiEngineEnabled &&
|
||||
!aiEngineLoading &&
|
||||
!entry.policy
|
||||
);
|
||||
}
|
||||
|
||||
const catalogue = data?.catalogue ?? [];
|
||||
const refetch = useCallback(() => setVersion((v) => v + 1), []);
|
||||
// The catalogue cards are always shown (they're the "configure a policy" CTAs),
|
||||
@@ -70,11 +57,6 @@ export function Policies() {
|
||||
}));
|
||||
|
||||
function openEntry(entry: CatalogueEntry) {
|
||||
// Block setup of an AI-required policy until the engine is confirmed on (so a
|
||||
// click during the app-config load can't open a wizard for a disabled
|
||||
// feature); a configured policy stays openable so it can be paused/deleted.
|
||||
if (entry.category.requiresAiEngine && !aiEngineEnabled && !entry.policy)
|
||||
return;
|
||||
if (entry.policy) setDetail(entry);
|
||||
else setWizard(entry);
|
||||
}
|
||||
@@ -175,8 +157,6 @@ export function Policies() {
|
||||
key={entry.category.id}
|
||||
entry={entry}
|
||||
onOpen={openEntry}
|
||||
locked={isLocked(entry)}
|
||||
lockedLabel={t("portal.policies.card.requiresAiEngine")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
/* Category show/hide list (Files-sidebar visibility picker). */
|
||||
.category-visibility {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.category-visibility-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 2.25rem;
|
||||
padding: 0 var(--space-1);
|
||||
}
|
||||
.category-visibility-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.category-visibility-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.category-visibility-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
/* Hidden categories read as dimmed until re-shown. */
|
||||
.category-visibility-row--hidden .category-visibility-icon,
|
||||
.category-visibility-row--hidden .category-visibility-name,
|
||||
.category-visibility-row--hidden .category-visibility-count {
|
||||
opacity: 0.5;
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ClassificationCategoryManager } from "@app/components/policies/ClassificationCategoryManager";
|
||||
import type { SidebarCategory } from "@app/services/fileSidebarCategories";
|
||||
|
||||
const CATEGORIES: SidebarCategory[] = [
|
||||
{ id: "finance", name: "Financial", icon: "payments", labelKeys: [] },
|
||||
{ id: "legal", name: "Legal", icon: "gavel", labelKeys: [] },
|
||||
{ id: "hr", name: "HR", icon: "badge", labelKeys: [] },
|
||||
{ id: "reports", name: "Reports", icon: "monitoring", labelKeys: [] },
|
||||
];
|
||||
|
||||
const COUNTS = new Map<string, number>([
|
||||
["finance", 12],
|
||||
["legal", 4],
|
||||
["reports", 7],
|
||||
]);
|
||||
|
||||
// The store is device-local; the story holds the hidden state so the toggle is live.
|
||||
function Harness() {
|
||||
const [categories, setCategories] = useState(CATEGORIES);
|
||||
return (
|
||||
<div style={{ maxWidth: 420 }}>
|
||||
<ClassificationCategoryManager
|
||||
categories={categories}
|
||||
counts={COUNTS}
|
||||
onToggleHidden={(id, hidden) =>
|
||||
setCategories((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, hidden } : c)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof Harness> = {
|
||||
title: "Policies/ClassificationCategoryManager",
|
||||
component: Harness,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Harness>;
|
||||
|
||||
/** Show/hide the fixed, shared categories in the Files sidebar. */
|
||||
export const Default: Story = {};
|
||||
@@ -1,68 +0,0 @@
|
||||
// Uber-simple category visibility list for the Files sidebar. Categories are the fixed, shared set
|
||||
// (the built-in label families) — they can't be created, renamed, re-grouped, or have their labels
|
||||
// edited. The only choice is showing or hiding each one in this device's sidebar; a hidden category
|
||||
// forms no group, so its files fall to "Other".
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import type { SidebarCategory } from "@app/services/fileSidebarCategories";
|
||||
import "@app/components/policies/ClassificationCategoryManager.css";
|
||||
|
||||
interface ClassificationCategoryManagerProps {
|
||||
categories: SidebarCategory[];
|
||||
onToggleHidden: (id: string, hidden: boolean) => void;
|
||||
/** Optional per-category file counts. */
|
||||
counts?: Map<string, number>;
|
||||
}
|
||||
|
||||
export function ClassificationCategoryManager({
|
||||
categories,
|
||||
onToggleHidden,
|
||||
counts,
|
||||
}: ClassificationCategoryManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ul className="category-visibility">
|
||||
{categories.map((category) => {
|
||||
const count = counts?.get(category.id);
|
||||
return (
|
||||
<li
|
||||
key={category.id}
|
||||
className={
|
||||
category.hidden
|
||||
? "category-visibility-row category-visibility-row--hidden"
|
||||
: "category-visibility-row"
|
||||
}
|
||||
>
|
||||
<span className="category-visibility-icon">
|
||||
<LocalIcon icon={category.icon} width="1.1rem" />
|
||||
</span>
|
||||
<span className="category-visibility-name">{category.name}</span>
|
||||
{count !== undefined && (
|
||||
<span className="category-visibility-count">{count}</span>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
aria-pressed={!category.hidden}
|
||||
aria-label={
|
||||
category.hidden
|
||||
? t("policies.labels.showCategory", "Show category")
|
||||
: t("policies.labels.hideCategory", "Hide category")
|
||||
}
|
||||
onClick={() => onToggleHidden(category.id, !category.hidden)}
|
||||
>
|
||||
{category.hidden ? (
|
||||
<VisibilityOffIcon sx={{ fontSize: "1.1rem" }} />
|
||||
) : (
|
||||
<VisibilityIcon sx={{ fontSize: "1.1rem" }} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* The Classification policy's team-labels control, shown in its Edit-Settings
|
||||
* view: a compact summary (count + chips) with an Expand button that opens the
|
||||
* fat {@link LabelsEditorModal}. Team-shared; only users who can configure
|
||||
* policies may edit it. Owns the editable draft and the load/save/import/export
|
||||
* wiring via {@link useClassificationLabels}.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
|
||||
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||
import { Card } from "@app/ui/Card";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { Chip } from "@app/ui/Chip";
|
||||
import { Banner } from "@app/ui/Banner";
|
||||
import { useClassificationLabels } from "@app/hooks/useClassificationLabels";
|
||||
import { LabelsEditorModal } from "@app/components/policies/LabelsEditorModal";
|
||||
import {
|
||||
downloadLabels,
|
||||
parseLabelsFile,
|
||||
validateLabels,
|
||||
} from "@app/services/labelsFile";
|
||||
import {
|
||||
DEFAULT_CLASSIFICATION_LABELS,
|
||||
type ClassificationLabel,
|
||||
} from "@app/data/classificationLabels";
|
||||
import "@app/components/policies/LabelsEditor.css";
|
||||
|
||||
/** Chips shown in the collapsed team summary before it gets noisy. */
|
||||
const SUMMARY_CHIP_COUNT = 12;
|
||||
|
||||
interface ClassificationLabelsSectionProps {
|
||||
canConfigure: boolean;
|
||||
}
|
||||
|
||||
export function ClassificationLabelsSection({
|
||||
canConfigure,
|
||||
}: ClassificationLabelsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { teamLabels, isCustom, loading, saving, error, saveTeam } =
|
||||
useClassificationLabels(true);
|
||||
|
||||
const [draft, setDraft] = useState<ClassificationLabel[]>(teamLabels);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
// Sync the draft to server truth whenever it changes (load / save / reset).
|
||||
// Local edits don't change `teamLabels`, so this never clobbers them mid-edit.
|
||||
useEffect(() => setDraft(teamLabels), [teamLabels]);
|
||||
|
||||
const dirty = useMemo(
|
||||
() => JSON.stringify(draft) !== JSON.stringify(teamLabels),
|
||||
[draft, teamLabels],
|
||||
);
|
||||
|
||||
const close = () => {
|
||||
setDraft(teamLabels);
|
||||
setLocalError(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onImportFile = (file: File) => {
|
||||
setLocalError(null);
|
||||
void parseLabelsFile(file)
|
||||
.then(setDraft)
|
||||
.catch((e: unknown) =>
|
||||
setLocalError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t("policies.labels.importError", "Couldn't import that file."),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
const errors = validateLabels({ labels: draft });
|
||||
if (errors.length > 0) {
|
||||
setLocalError(errors[0]);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
void saveTeam(draft).then(() => setOpen(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="labels-summary">
|
||||
<p className="pol-section-label">
|
||||
{t("policies.labels.sectionLabel", "Classification labels")}
|
||||
</p>
|
||||
<Card>
|
||||
{loading ? (
|
||||
<span className="labels-empty">{t("loading", "Loading…")}</span>
|
||||
) : (
|
||||
<div className="labels-summary">
|
||||
<div className="labels-summary-stats">
|
||||
<span>
|
||||
<strong>{teamLabels.length}</strong>{" "}
|
||||
{t("policies.labels.teamCount", "team labels")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="labels-chips">
|
||||
{teamLabels.slice(0, SUMMARY_CHIP_COUNT).map((label) => (
|
||||
<Chip key={label.id} accent="neutral" size="sm">
|
||||
{label.name}
|
||||
</Chip>
|
||||
))}
|
||||
{teamLabels.length > SUMMARY_CHIP_COUNT && (
|
||||
<Chip accent="neutral" size="sm">
|
||||
+{teamLabels.length - SUMMARY_CHIP_COUNT}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
<span className="labels-summary-note">
|
||||
{isCustom
|
||||
? t("policies.labels.customNote", "Customized for your team.")
|
||||
: t(
|
||||
"policies.labels.defaultNote",
|
||||
"Using the built-in default, shared with your team.",
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<OpenInFullIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={() => setOpen(true)}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
{canConfigure
|
||||
? t("policies.labels.edit", "Edit labels")
|
||||
: t("policies.labels.view", "View labels")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{!canConfigure && (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
icon={<LockOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
description={t(
|
||||
"policies.labels.managedNote",
|
||||
"Team labels are managed by your team leader.",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && !open && <Banner tone="danger" description={error} />}
|
||||
|
||||
<LabelsEditorModal
|
||||
open={open}
|
||||
onClose={close}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onImportFile={onImportFile}
|
||||
onExport={() => downloadLabels(draft)}
|
||||
onReset={() => {
|
||||
// Stage the built-in default into the draft — reversible via Cancel,
|
||||
// only persisted on Save (no immediate destructive server delete).
|
||||
setLocalError(null);
|
||||
setDraft(DEFAULT_CLASSIFICATION_LABELS);
|
||||
}}
|
||||
onClear={() => {
|
||||
// Stage an empty list to build from scratch — also reversible until Save.
|
||||
setLocalError(null);
|
||||
setDraft([]);
|
||||
}}
|
||||
onSave={onSave}
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
readOnly={!canConfigure}
|
||||
error={localError ?? error}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Small popover picker for a classification label's icon: a trigger button
|
||||
* showing the current icon, opening a grid of the curated
|
||||
* {@link LABEL_ICON_OPTIONS}. Icons render via {@link LocalIcon} (Material
|
||||
* Symbols). No search yet — the palette is small enough to scan.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Popover, SimpleGrid, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
|
||||
import { LABEL_ICON_OPTIONS, DEFAULT_LABEL_ICON } from "@app/data/labelIcons";
|
||||
|
||||
interface LabelIconPickerProps {
|
||||
/** Current icon key, or undefined to show the default placeholder. */
|
||||
value?: string;
|
||||
onChange: (icon: string) => void;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
export function LabelIconPicker({
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: LabelIconPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={open}
|
||||
onChange={setOpen}
|
||||
onDismiss={() => setOpen(false)}
|
||||
position="bottom-start"
|
||||
withArrow
|
||||
trapFocus
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_AUTOMATE_DROPDOWN}
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className="labels-icon-pick"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<LocalIcon icon={value || DEFAULT_LABEL_ICON} width="1.15rem" />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs" style={{ maxHeight: 280, overflowY: "auto" }}>
|
||||
<SimpleGrid cols={10} spacing={4} verticalSpacing={4}>
|
||||
{LABEL_ICON_OPTIONS.map((option) => {
|
||||
const iconLabel = t(
|
||||
`policies.labels.iconName.${option.icon}`,
|
||||
option.label,
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
key={option.icon}
|
||||
label={iconLabel}
|
||||
withArrow
|
||||
openDelay={300}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className={`labels-icon-option${option.icon === value ? " is-selected" : ""}`}
|
||||
onClick={() => {
|
||||
onChange(option.icon);
|
||||
setOpen(false);
|
||||
}}
|
||||
aria-label={t(
|
||||
"policies.labels.pickIcon",
|
||||
"Use {{label}} icon",
|
||||
{
|
||||
label: iconLabel,
|
||||
},
|
||||
)}
|
||||
aria-pressed={option.icon === value}
|
||||
>
|
||||
<LocalIcon icon={option.icon} width="1.25rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/* Classification-labels editor: add box, chip grid, icon picker, and the
|
||||
summary/modal chrome around them. */
|
||||
|
||||
.labels-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- Add box ---- */
|
||||
.labels-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.labels-add-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.8125rem;
|
||||
font-family: inherit;
|
||||
color: var(--color-text-1);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
transition: border-color var(--motion-fast);
|
||||
}
|
||||
.labels-add-input:focus {
|
||||
border-color: var(--color-blue);
|
||||
}
|
||||
.labels-add-error {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-red);
|
||||
}
|
||||
.labels-empty {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* ---- Chip grid (chips themselves are the shared @app/ui/LabelChip) ---- */
|
||||
.labels-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1_5);
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- Icon picker (trigger + popover options) ---- */
|
||||
.labels-icon-pick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
.labels-icon-pick:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.labels-icon-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
.labels-icon-option:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.labels-icon-option.is-selected {
|
||||
background: var(--color-blue-light);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
/* ---- Section summary (Edit-Settings card) ---- */
|
||||
.labels-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.labels-summary-stats {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.labels-summary-note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* ---- Fat modal ---- */
|
||||
.labels-modal {
|
||||
width: min(1100px, 94vw);
|
||||
max-width: min(1100px, 94vw);
|
||||
}
|
||||
.labels-modal-body {
|
||||
max-height: min(70vh, 640px);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-height: 0;
|
||||
}
|
||||
.labels-toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.labels-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
}
|
||||
.labels-footer-left,
|
||||
.labels-footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* ---- Grouped mode: chips under collapsible parent categories ---- */
|
||||
.labels-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.labels-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.labels-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1, inherit);
|
||||
}
|
||||
.labels-group-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.labels-group-count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-3, rgba(128, 128, 128, 0.9));
|
||||
}
|
||||
.labels-group-ungrouped {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-2, inherit);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Editor for a classification-label list: an add box on top, then the labels as chips (icon picker + remove), duplicate names rejected case-insensitively (optionally also against `reservedNames`). In `grouped` mode the chips are organised under collapsible parent categories (the device-local sidebar categories), matching the sidebar's category picker; labels in no category fall under "Ungrouped". Grouping is presentational — editing still mutates the flat list.
|
||||
|
||||
import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { LabelChip } from "@app/ui/LabelChip";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import { LabelIconPicker } from "@app/components/policies/LabelIconPicker";
|
||||
import { DEFAULT_LABEL_ICON } from "@app/data/labelIcons";
|
||||
import {
|
||||
getSidebarCategories,
|
||||
subscribeSidebarCategories,
|
||||
} from "@app/services/fileSidebarCategories";
|
||||
import {
|
||||
labelId,
|
||||
type ClassificationLabel,
|
||||
} from "@app/data/classificationLabels";
|
||||
|
||||
const MAX_TEXT_LENGTH = 128;
|
||||
|
||||
interface LabelsEditorProps {
|
||||
value: ClassificationLabel[];
|
||||
onChange: (next: ClassificationLabel[]) => void;
|
||||
readOnly?: boolean;
|
||||
/** Names that can't be added here because another set already owns them. */
|
||||
reservedNames?: string[];
|
||||
addPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
/** Render chips under collapsible parent categories (like the sidebar picker). */
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
export function LabelsEditor({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
reservedNames,
|
||||
addPlaceholder,
|
||||
emptyText,
|
||||
grouped = false,
|
||||
}: LabelsEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [pending, setPending] = useState("");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
|
||||
const add = () => {
|
||||
const name = pending.trim();
|
||||
if (!name) return;
|
||||
if (name.length > MAX_TEXT_LENGTH) {
|
||||
setAddError(
|
||||
t(
|
||||
"policies.labels.tooLong",
|
||||
"Labels can be at most {{max}} characters.",
|
||||
{ max: MAX_TEXT_LENGTH },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const key = name.toLowerCase();
|
||||
const taken =
|
||||
value.some((label) => label.name.toLowerCase() === key) ||
|
||||
(reservedNames ?? []).some((reserved) => reserved.toLowerCase() === key);
|
||||
if (taken) {
|
||||
setAddError(
|
||||
t("policies.labels.duplicate", '"{{name}}" already exists.', { name }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setAddError(null);
|
||||
setPending("");
|
||||
onChange([...value, { id: labelId(name), name }]);
|
||||
};
|
||||
|
||||
const removeById = (id: string) =>
|
||||
onChange(value.filter((label) => label.id !== id));
|
||||
|
||||
const setIconById = (id: string, icon: string) =>
|
||||
onChange(
|
||||
value.map((label) => (label.id === id ? { ...label, icon } : label)),
|
||||
);
|
||||
|
||||
const renderChip = (label: ClassificationLabel) => {
|
||||
const key = label.id;
|
||||
return (
|
||||
<LabelChip
|
||||
key={key}
|
||||
label={label.name}
|
||||
leading={
|
||||
readOnly ? (
|
||||
<span className="sui-labelchip-icon">
|
||||
<LocalIcon icon={label.icon || DEFAULT_LABEL_ICON} width="1rem" />
|
||||
</span>
|
||||
) : (
|
||||
<LabelIconPicker
|
||||
value={label.icon}
|
||||
onChange={(icon) => setIconById(key, icon)}
|
||||
ariaLabel={t(
|
||||
"policies.labels.iconAria",
|
||||
"Choose an icon for {{name}}",
|
||||
{ name: label.name },
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onRemove={readOnly ? undefined : () => removeById(key)}
|
||||
removeAriaLabel={t("policies.labels.removeAria", "Remove {{name}}", {
|
||||
name: label.name,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="labels-editor">
|
||||
{!readOnly && (
|
||||
<div className="labels-add">
|
||||
<input
|
||||
className="labels-add-input"
|
||||
value={pending}
|
||||
maxLength={MAX_TEXT_LENGTH + 1}
|
||||
placeholder={
|
||||
addPlaceholder ??
|
||||
t("policies.labels.addPlaceholder", "Add a label…")
|
||||
}
|
||||
onChange={(e) => {
|
||||
setPending(e.target.value);
|
||||
if (addError) setAddError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
add();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<AddIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={add}
|
||||
disabled={!pending.trim()}
|
||||
>
|
||||
{t("policies.labels.add", "Add")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{addError && <p className="labels-add-error">{addError}</p>}
|
||||
|
||||
{value.length === 0 ? (
|
||||
<p className="labels-empty">
|
||||
{emptyText ?? t("policies.labels.empty", "No labels yet.")}
|
||||
</p>
|
||||
) : grouped ? (
|
||||
<GroupedLabels value={value} renderChip={renderChip} />
|
||||
) : (
|
||||
<div className="labels-chips" role="list">
|
||||
{value.map(renderChip)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupedLabelsProps {
|
||||
value: ClassificationLabel[];
|
||||
renderChip: (label: ClassificationLabel) => ReactNode;
|
||||
}
|
||||
|
||||
/** Chips laid out under collapsible parent categories (device-local structure). */
|
||||
function GroupedLabels({ value, renderChip }: GroupedLabelsProps) {
|
||||
const { t } = useTranslation();
|
||||
const categories = useSyncExternalStore(
|
||||
subscribeSidebarCategories,
|
||||
getSidebarCategories,
|
||||
);
|
||||
|
||||
// Category sections that actually contain labels from `value`, plus the leftovers.
|
||||
const { sections, ungrouped } = useMemo(() => {
|
||||
const byId = new Map(value.map((l) => [l.id, l]));
|
||||
const claimed = new Set<string>();
|
||||
const sections = categories
|
||||
.map((category) => {
|
||||
const members = category.labelKeys
|
||||
.map((id) => byId.get(id))
|
||||
.filter((l): l is ClassificationLabel => !!l);
|
||||
members.forEach((m) => claimed.add(m.id));
|
||||
return { category, members };
|
||||
})
|
||||
.filter((s) => s.members.length > 0);
|
||||
const ungrouped = value.filter((l) => !claimed.has(l.id));
|
||||
return { sections, ungrouped };
|
||||
}, [value, categories]);
|
||||
|
||||
// Only the first section starts expanded — a scannable overview.
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(
|
||||
() => new Set(sections.length > 0 ? [sections[0].category.id] : []),
|
||||
);
|
||||
const toggle = (id: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="labels-groups">
|
||||
{sections.map(({ category, members }) => {
|
||||
const isOpen = expanded.has(category.id);
|
||||
return (
|
||||
<section key={category.id} className="labels-group">
|
||||
<Button
|
||||
variant="quiet"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="labels-group-header"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => toggle(category.id)}
|
||||
leftSection={
|
||||
<>
|
||||
{isOpen ? (
|
||||
<KeyboardArrowDownIcon sx={{ fontSize: "1.1rem" }} />
|
||||
) : (
|
||||
<KeyboardArrowRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
)}
|
||||
<LocalIcon icon={category.icon} width="1.05rem" />
|
||||
</>
|
||||
}
|
||||
rightSection={
|
||||
<span className="labels-group-count">{members.length}</span>
|
||||
}
|
||||
>
|
||||
<span className="labels-group-name">{category.name}</span>
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div className="labels-chips" role="list">
|
||||
{members.map(renderChip)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{ungrouped.length > 0 && (
|
||||
<section className="labels-group">
|
||||
<p className="labels-group-ungrouped">
|
||||
{t("policies.labels.ungrouped", "Ungrouped")}
|
||||
</p>
|
||||
<div className="labels-chips" role="list">
|
||||
{ungrouped.map(renderChip)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Full-screen ("fat") editor for the team's classification labels — the roomy
|
||||
* view the settings summary's Edit button opens. Hosts the {@link LabelsEditor}
|
||||
* chip grid plus an Import/Export toolbar and a footer holding the destructive
|
||||
* actions (reset / start-from-scratch) and the Save/Cancel buttons. Editing is
|
||||
* staged: nothing is persisted until Save, which stays disabled until the draft
|
||||
* actually changes. The draft is owned by the caller so the settings summary
|
||||
* reflects saved changes.
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
|
||||
import FileUploadOutlinedIcon from "@mui/icons-material/FileUploadOutlined";
|
||||
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
||||
import DeleteSweepOutlinedIcon from "@mui/icons-material/DeleteSweepOutlined";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import { Modal } from "@app/ui/Modal";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { Banner } from "@app/ui/Banner";
|
||||
import { LabelsEditor } from "@app/components/policies/LabelsEditor";
|
||||
import type { ClassificationLabel } from "@app/data/classificationLabels";
|
||||
|
||||
interface LabelsEditorModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
draft: ClassificationLabel[];
|
||||
onDraftChange: (next: ClassificationLabel[]) => void;
|
||||
onImportFile: (file: File) => void;
|
||||
onExport: () => void;
|
||||
/** Stage the built-in default into the draft. */
|
||||
onReset: () => void;
|
||||
/** Stage an empty list into the draft (build from scratch). */
|
||||
onClear: () => void;
|
||||
onSave: () => void;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
readOnly: boolean;
|
||||
/** Save (server) or import (file) failure to surface, if any. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function LabelsEditorModal({
|
||||
open,
|
||||
onClose,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onImportFile,
|
||||
onExport,
|
||||
onReset,
|
||||
onClear,
|
||||
onSave,
|
||||
dirty,
|
||||
saving,
|
||||
readOnly,
|
||||
error,
|
||||
}: LabelsEditorModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
// Reset the input so picking the same file twice still fires onChange.
|
||||
e.target.value = "";
|
||||
if (file) onImportFile(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="xl"
|
||||
className="labels-modal"
|
||||
title={t("policies.labels.modalTitle", "Classification labels")}
|
||||
subtitle={t(
|
||||
"policies.labels.modalSubtitle",
|
||||
"Shared with your whole team. The classifier picks the labels that fit each document.",
|
||||
)}
|
||||
footer={
|
||||
<div className="labels-footer">
|
||||
<div className="labels-footer-left">
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="danger"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<DeleteSweepOutlinedIcon sx={{ fontSize: "1rem" }} />
|
||||
}
|
||||
onClick={onClear}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("policies.labels.startFromScratch", "Start from scratch")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="danger"
|
||||
size="sm"
|
||||
leftSection={<RestartAltIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={onReset}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("policies.labels.resetToDefault", "Reset to default")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="labels-footer-right">
|
||||
<Button variant="tertiary" size="sm" onClick={onClose}>
|
||||
{readOnly ? t("close", "Close") : t("cancel", "Cancel")}
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving
|
||||
? t("policies.labels.saving", "Saving…")
|
||||
: t("policies.labels.saveForTeam", "Save for team")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="labels-modal-body">
|
||||
{error && (
|
||||
<Banner
|
||||
tone="danger"
|
||||
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
description={error}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<div className="labels-toolbar">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<FileUploadOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
{t("policies.labels.import", "Import JSON")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<FileDownloadOutlinedIcon sx={{ fontSize: "1rem" }} />
|
||||
}
|
||||
onClick={onExport}
|
||||
>
|
||||
{t("policies.labels.export", "Export JSON")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
hidden
|
||||
onChange={handleFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<LabelsEditor
|
||||
value={draft}
|
||||
onChange={onDraftChange}
|
||||
readOnly={readOnly}
|
||||
grouped
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* The fixed, configurable tool chain per policy category — the locked set the
|
||||
* config page shows (one section per tool). Tools can be configured + toggled
|
||||
* on/off, but not added or removed. Each id is a frontend tool-registry key (and
|
||||
* maps to that tool's backend endpoint via the registry's operationConfig).
|
||||
*/
|
||||
export const POLICY_TOOL_CHAINS: Record<string, string[]> = {
|
||||
// Security: redact PII + watermark + sanitize (strips JS). Which are enabled
|
||||
// by default comes from the preset's defaultOperations, not this list.
|
||||
security: ["redact", "watermark", "sanitize"],
|
||||
// Classification: a single backend step that classifies the document and
|
||||
// writes the result into its metadata.
|
||||
classification: ["classify"],
|
||||
};
|
||||
|
||||
/** The configurable tool chain for a category, or null if it has none yet. */
|
||||
export function getPolicyToolChain(categoryId: string): string[] | null {
|
||||
return POLICY_TOOL_CHAINS[categoryId] ?? null;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { DEFAULT_CLASSIFICATION_LABELS } from "@app/data/classificationLabels";
|
||||
|
||||
// The classifier vocabulary is bundled in TWO places that must not drift:
|
||||
// - this frontend JSON (source of truth for sidebar categories + display names)
|
||||
// - a backend copy the classify tool sends to the AI engine per request
|
||||
// (app/proprietary/src/main/resources/classification/classification-labels.json).
|
||||
// The backend sends label IDS the engine stores on the document and the frontend
|
||||
// then displays, so a divergence means the classifier picks a label the UI can't
|
||||
// render (or the UI advertises labels the classifier never uses). This guards it.
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "../../../../..");
|
||||
const BACKEND_LABELS_FILE = path.join(
|
||||
REPO_ROOT,
|
||||
"app/proprietary/src/main/resources/classification/classification-labels.json",
|
||||
);
|
||||
|
||||
interface Label {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
function readBackendLabels(): Label[] {
|
||||
const raw = JSON.parse(fs.readFileSync(BACKEND_LABELS_FILE, "utf8")) as {
|
||||
labels: Label[];
|
||||
};
|
||||
return raw.labels;
|
||||
}
|
||||
|
||||
describe("classification label vocabulary (frontend ↔ backend)", () => {
|
||||
const frontend = DEFAULT_CLASSIFICATION_LABELS;
|
||||
const backend = readBackendLabels();
|
||||
|
||||
it("has the identical set of label ids on both sides", () => {
|
||||
const frontendIds = [...new Set(frontend.map((l) => l.id))].sort();
|
||||
const backendIds = [...new Set(backend.map((l) => l.id))].sort();
|
||||
expect(backendIds).toEqual(frontendIds);
|
||||
});
|
||||
|
||||
it("has matching name and icon per id on both sides", () => {
|
||||
const backendById = new Map(backend.map((l) => [l.id, l]));
|
||||
for (const label of frontend) {
|
||||
const other = backendById.get(label.id);
|
||||
expect(other, `backend missing label "${label.id}"`).toBeDefined();
|
||||
expect({ name: other!.name, icon: other!.icon ?? null }).toEqual({
|
||||
name: label.name,
|
||||
icon: label.icon ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
// Classification labels. The SOURCE OF TRUTH is the co-located static JSON
|
||||
// (`classificationLabels.json`), imported here and shaped into typed objects —
|
||||
// edit THAT file, not this one. It's a fixed, built-in vocabulary shared by
|
||||
// everyone (no per-team customization); it drives the sidebar's grouping, icons,
|
||||
// and display names.
|
||||
// Default classification labels. The SOURCE OF TRUTH is the co-located static
|
||||
// JSON (`classificationLabels.json`), imported here and shaped into typed
|
||||
// objects — edit THAT file, not this one. This is the ONLY copy of the label
|
||||
// data: it seeds a team's editable set and drives the sidebar's grouping,
|
||||
// icons, and display names. Neither the backend nor the engine keeps a copy.
|
||||
//
|
||||
// The backend keeps a SECOND copy of this list
|
||||
// (`app/proprietary/src/main/resources/classification/classification-labels.json`)
|
||||
// which it sends to the engine per classify request (the engine holds no
|
||||
// vocabulary of its own). The two copies must not drift — `classificationLabels.drift.test.ts`
|
||||
// guards that. When you edit the labels here, update the backend copy too.
|
||||
// NOTE: this is only the built-in default vocabulary. A team's own
|
||||
// (admin-editable) labels live in the backend store and are what the backend
|
||||
// sends to the engine per classify request — the engine holds no vocabulary of
|
||||
// its own. Edits to a team's set reach Python on the next run, independent of
|
||||
// this file.
|
||||
//
|
||||
// `labels` is the flat set: each has a stable `id` (slug — the value on the wire,
|
||||
// in storage and keyed on) and a human `name` (display, translatable via
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
/**
|
||||
* Curated palette of icons the classification labels use, rendered in the file
|
||||
* sidebar's label/category groups. Keys are Material Symbols names rendered via
|
||||
* {@link LocalIcon}.
|
||||
* Curated palette of icons a classification label can use, shown in the label
|
||||
* icon picker and rendered in the file sidebar's label groups. Keys are
|
||||
* Material Symbols names rendered via {@link LocalIcon}.
|
||||
*
|
||||
* IMPORTANT — this list is also the icon BUNDLING MANIFEST for classification.
|
||||
* The label/family icons live in `classificationLabels.json`, but the bundler
|
||||
* (`scripts/generate-icons.js`) only regex-scans `.ts/.tsx` for `icon: "…"`
|
||||
* literals, not JSON — so every icon those labels use must appear here as a
|
||||
* literal or it falls back to the CDN and renders blank offline. Do NOT delete
|
||||
* `LABEL_ICON_OPTIONS` as "unused": nothing imports it, but it's load-bearing
|
||||
* for offline icons. After editing, run `task frontend:prepare:icons`.
|
||||
* IMPORTANT: each entry is written as an `icon: "…"` literal so the icon-bundler
|
||||
* (`scripts/generate-icons.js`, which regex-scans for icon literals) picks every
|
||||
* one up and bundles it — otherwise a picked icon would fall back to the CDN and
|
||||
* render blank offline. After adding/removing entries run `task frontend:prepare:icons`.
|
||||
*
|
||||
* Search is intentionally omitted for now (no synonyms to maintain); the palette
|
||||
* is small enough to eyeball.
|
||||
*/
|
||||
|
||||
export interface LabelIconOption {
|
||||
/** Material Symbols key (no `material-symbols:` prefix). */
|
||||
icon: string;
|
||||
/** Short English name for the icon (documentation only). */
|
||||
/** Short English name — the en-US default for the icon's tooltip/aria; the
|
||||
* picker translates it via `policies.labels.iconName.<icon>`. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
@@ -187,3 +188,8 @@ export const LABEL_ICON_OPTIONS: LabelIconOption[] = [
|
||||
{ icon: "recycling", label: "Recycling" },
|
||||
{ icon: "agriculture", label: "Agriculture" },
|
||||
];
|
||||
|
||||
/** Set of valid palette keys, for validating a stored/imported icon. */
|
||||
export const LABEL_ICON_KEYS: ReadonlySet<string> = new Set(
|
||||
LABEL_ICON_OPTIONS.map((option) => option.icon),
|
||||
);
|
||||
|
||||
@@ -5,13 +5,18 @@
|
||||
* user's real files, not defined here.
|
||||
*/
|
||||
|
||||
import LayersIcon from "@mui/icons-material/Layers";
|
||||
import ShieldIcon from "@mui/icons-material/Shield";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import DescriptionIcon from "@mui/icons-material/Description";
|
||||
import ComputerIcon from "@mui/icons-material/Computer";
|
||||
import PublicIcon from "@mui/icons-material/Public";
|
||||
import CloudIcon from "@mui/icons-material/Cloud";
|
||||
import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
|
||||
import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined";
|
||||
import type {
|
||||
PolicyCategory,
|
||||
PolicyConfigDef,
|
||||
@@ -25,7 +30,7 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "ingestion",
|
||||
label: "Ingestion",
|
||||
icon: policyCategoryIcon("ingestion", ICON_SX),
|
||||
icon: <LayersIcon sx={ICON_SX} />,
|
||||
desc: "Classify documents, extract structured data, enforce naming conventions, and normalize pages.",
|
||||
// The classifier the wizard's "Set up Classification" action routes to.
|
||||
providesClassification: true,
|
||||
@@ -35,13 +40,13 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "security",
|
||||
label: "Security",
|
||||
icon: policyCategoryIcon("security", ICON_SX),
|
||||
icon: <ShieldIcon sx={ICON_SX} />,
|
||||
desc: "Detect PII, encrypt, verify authenticity, control access, and certify documents.",
|
||||
},
|
||||
{
|
||||
id: "classification",
|
||||
label: "Classification",
|
||||
icon: policyCategoryIcon("classification", ICON_SX),
|
||||
icon: <LabelOutlinedIcon sx={ICON_SX} />,
|
||||
desc: "Identify each document's type on upload and tag its metadata for filing and search.",
|
||||
// Needs the AI engine to classify; hidden from the policy list when it's off.
|
||||
requiresAiEngine: true,
|
||||
@@ -49,21 +54,21 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "compliance",
|
||||
label: "Compliance",
|
||||
icon: policyCategoryIcon("compliance", ICON_SX),
|
||||
icon: <CheckCircleIcon sx={ICON_SX} />,
|
||||
desc: "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document.",
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: "routing",
|
||||
label: "Routing",
|
||||
icon: policyCategoryIcon("routing", ICON_SX),
|
||||
icon: <ArrowForwardIcon sx={ICON_SX} />,
|
||||
desc: "Auto-route documents to the right team, folder, or system.",
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: "retention",
|
||||
label: "Retention",
|
||||
icon: policyCategoryIcon("retention", ICON_SX),
|
||||
icon: <StorageIcon sx={ICON_SX} />,
|
||||
desc: "Set how long documents are kept, when to archive, and when to delete.",
|
||||
comingSoon: true,
|
||||
},
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
import type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
|
||||
|
||||
export type { ProcessorSearchEntry };
|
||||
|
||||
// Mirrors the admin-route seam's gate: the portal route-set is only mounted in
|
||||
// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not
|
||||
// offer destinations that would 404 elsewhere.
|
||||
const includePortal =
|
||||
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
|
||||
|
||||
/**
|
||||
* The portal's in-app views. Deliberately a static mirror of the portal's nav
|
||||
* (labels via the same portal.nav.* keys its sidebar uses) rather than an
|
||||
* import from @portal/* — referencing the portal package here would pull the
|
||||
* lazy portal chunk into the main bundle.
|
||||
*/
|
||||
const VIEWS: ProcessorSearchEntry[] = [
|
||||
{
|
||||
id: "home",
|
||||
labelKey: "portal.nav.home",
|
||||
labelFallback: "Home",
|
||||
path: PORTAL_BASENAME,
|
||||
keywords: ["portal", "processor", "admin"],
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
labelKey: "portal.nav.users",
|
||||
labelFallback: "Users",
|
||||
path: `${PORTAL_BASENAME}/users`,
|
||||
keywords: ["team", "members", "roles", "admin"],
|
||||
},
|
||||
{
|
||||
id: "sources",
|
||||
labelKey: "portal.nav.sources",
|
||||
labelFallback: "Sources",
|
||||
path: `${PORTAL_BASENAME}/sources`,
|
||||
keywords: ["s3", "connections", "webhooks", "folders"],
|
||||
},
|
||||
{
|
||||
id: "policies",
|
||||
labelKey: "portal.nav.policies",
|
||||
labelFallback: "Policies",
|
||||
path: `${PORTAL_BASENAME}/policies`,
|
||||
keywords: ["enforcement", "redact", "compliance"],
|
||||
},
|
||||
{
|
||||
id: "pipelines",
|
||||
labelKey: "portal.nav.pipelines",
|
||||
labelFallback: "Pipelines",
|
||||
path: `${PORTAL_BASENAME}/pipelines`,
|
||||
keywords: ["automation", "workflows", "operations"],
|
||||
},
|
||||
{
|
||||
id: "documents",
|
||||
labelKey: "portal.nav.documents",
|
||||
labelFallback: "Documents",
|
||||
path: `${PORTAL_BASENAME}/documents`,
|
||||
keywords: ["audit", "files"],
|
||||
},
|
||||
{
|
||||
id: "components",
|
||||
labelKey: "portal.nav.components",
|
||||
labelFallback: "Components",
|
||||
path: `${PORTAL_BASENAME}/components`,
|
||||
keywords: ["sdk", "embed"],
|
||||
},
|
||||
{
|
||||
id: "infrastructure",
|
||||
labelKey: "portal.nav.infrastructure",
|
||||
labelFallback: "Infrastructure",
|
||||
path: `${PORTAL_BASENAME}/infrastructure`,
|
||||
keywords: ["deployment", "instances", "health"],
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
labelKey: "portal.nav.usage",
|
||||
labelFallback: "Usage & Billing",
|
||||
path: `${PORTAL_BASENAME}/usage`,
|
||||
keywords: ["billing", "invoices", "plan", "wallet"],
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
labelKey: "portal.nav.docs",
|
||||
labelFallback: "Developer Docs",
|
||||
path: "",
|
||||
externalUrl: "https://docs.stirlingpdf.com/",
|
||||
keywords: ["api", "documentation", "reference"],
|
||||
},
|
||||
];
|
||||
|
||||
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = includePortal
|
||||
? VIEWS
|
||||
: [];
|
||||
@@ -1,151 +0,0 @@
|
||||
import {
|
||||
SETTINGS_SECTION_REGISTRY as CORE_SETTINGS_SECTION_REGISTRY,
|
||||
type SettingsSectionEntry,
|
||||
} from "@core/data/settingsSectionRegistry";
|
||||
|
||||
export type { SettingsSectionEntry };
|
||||
|
||||
/**
|
||||
* Self-hosted (proprietary) settings sections. Extends the core list with the
|
||||
* account tab, the developer API keys tab, and the admin area — mirroring the
|
||||
* sections added by the proprietary nav builder (`configNavSections`). Keep the
|
||||
* two in sync: a section here that the modal can't render would deep-link to a
|
||||
* dead tab, and a modal section missing here isn't searchable.
|
||||
*
|
||||
* Gating flags mirror the builder: `account`/`api-keys` need login; the admin
|
||||
* sections are surfaced when the user is an admin OR login mode is off.
|
||||
*/
|
||||
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
|
||||
...CORE_SETTINGS_SECTION_REGISTRY,
|
||||
{
|
||||
key: "account",
|
||||
labelKey: "account.accountSettings",
|
||||
labelFallback: "Account",
|
||||
keywords: ["profile", "email", "password", "user"],
|
||||
requiresLogin: true,
|
||||
},
|
||||
{
|
||||
key: "api-keys",
|
||||
labelKey: "settings.developer.apiKeys",
|
||||
labelFallback: "API Keys",
|
||||
keywords: ["api", "token", "developer", "key"],
|
||||
requiresLogin: true,
|
||||
},
|
||||
// --- Workspace ---
|
||||
{
|
||||
key: "people",
|
||||
labelKey: "settings.workspace.people",
|
||||
labelFallback: "People",
|
||||
keywords: ["members", "users", "invite", "add member", "roles"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "teams",
|
||||
labelKey: "settings.workspace.teams",
|
||||
labelFallback: "Teams",
|
||||
keywords: ["team", "group", "create team"],
|
||||
adminArea: true,
|
||||
},
|
||||
// --- Configuration ---
|
||||
{
|
||||
key: "adminGeneral",
|
||||
labelKey: "settings.configuration.systemSettings",
|
||||
labelFallback: "System Settings",
|
||||
keywords: ["system", "config", "server"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminFeatures",
|
||||
labelKey: "settings.configuration.features",
|
||||
labelFallback: "Features",
|
||||
keywords: ["features", "toggles", "flags"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminStorageSharing",
|
||||
labelKey: "settings.configuration.storageSharing",
|
||||
labelFallback: "File Storage & Sharing",
|
||||
keywords: ["storage", "sharing", "files"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminEndpoints",
|
||||
labelKey: "settings.configuration.endpoints",
|
||||
labelFallback: "Endpoints",
|
||||
keywords: ["endpoints", "api", "routes"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminMcp",
|
||||
labelKey: "settings.configuration.mcp",
|
||||
labelFallback: "MCP Server",
|
||||
keywords: ["mcp", "server", "ai", "model context protocol"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminDatabase",
|
||||
labelKey: "settings.configuration.database",
|
||||
labelFallback: "Database",
|
||||
keywords: ["database", "db", "backup"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminAdvanced",
|
||||
labelKey: "settings.configuration.advanced",
|
||||
labelFallback: "Advanced",
|
||||
keywords: ["advanced", "expert"],
|
||||
adminArea: true,
|
||||
},
|
||||
// --- Security & Authentication ---
|
||||
{
|
||||
key: "adminSecurity",
|
||||
labelKey: "settings.securityAuth.security",
|
||||
labelFallback: "Security",
|
||||
keywords: ["security", "authentication", "auth", "password", "sessions"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminConnections",
|
||||
labelKey: "settings.securityAuth.connections",
|
||||
labelFallback: "Connections",
|
||||
keywords: ["connections", "oauth", "sso", "integrations"],
|
||||
adminArea: true,
|
||||
},
|
||||
// --- Licensing & Analytics ---
|
||||
{
|
||||
key: "adminPlan",
|
||||
labelKey: "settings.licensingAnalytics.plan",
|
||||
labelFallback: "Plan",
|
||||
keywords: ["plan", "license", "billing", "subscription", "enterprise"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminAudit",
|
||||
labelKey: "settings.licensingAnalytics.audit",
|
||||
labelFallback: "Audit",
|
||||
keywords: ["audit", "logs", "events", "history"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminUsage",
|
||||
labelKey: "settings.licensingAnalytics.usageAnalytics",
|
||||
labelFallback: "Usage Analytics",
|
||||
keywords: ["usage", "analytics", "stats", "metrics"],
|
||||
adminArea: true,
|
||||
},
|
||||
// --- Policies & Privacy ---
|
||||
{
|
||||
key: "adminLegal",
|
||||
labelKey: "settings.policiesPrivacy.legal",
|
||||
labelFallback: "Legal",
|
||||
keywords: ["legal", "terms", "agreement"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "adminPrivacy",
|
||||
labelKey: "settings.policiesPrivacy.privacy",
|
||||
labelFallback: "Privacy",
|
||||
keywords: ["privacy", "gdpr", "data"],
|
||||
adminArea: true,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Loads and persists the team's classification labels
|
||||
* (`/api/v1/classification/labels`) — one server-truth set shared by the whole
|
||||
* team. When the team has none, this shows the built-in default as a starting
|
||||
* point for the editor/sidebar; note the classifier itself does NOT use that
|
||||
* default — the backend only classifies against a team's saved set (an unsaved
|
||||
* team is not classified). Editing is gated to team leaders / admins by the
|
||||
* backend — callers pass `canConfigure` (the policy gate) to keep read-only
|
||||
* users out of the save path.
|
||||
*
|
||||
* `teamLabels` is also what the sidebar/editor display uses — the team set, or
|
||||
* the built-in default when the team has none.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
DEFAULT_CLASSIFICATION_LABELS,
|
||||
type ClassificationLabel,
|
||||
} from "@app/data/classificationLabels";
|
||||
import { fetchTeamLabels, saveTeamLabels } from "@app/services/labelsBackend";
|
||||
|
||||
export interface UseClassificationLabels {
|
||||
/** Server-truth team labels (or the built-in default when the team has none). */
|
||||
teamLabels: ClassificationLabel[];
|
||||
/** Whether the team has a stored set (vs. the built-in default). */
|
||||
isCustom: boolean;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
/** Last save failure, cleared on the next attempt. */
|
||||
error: string | null;
|
||||
/** Persist the team set; resolves once server state is updated. */
|
||||
saveTeam: (next: ClassificationLabel[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useClassificationLabels(
|
||||
enabled: boolean,
|
||||
): UseClassificationLabels {
|
||||
const [teamLabels, setTeamLabels] = useState<ClassificationLabel[]>(
|
||||
DEFAULT_CLASSIFICATION_LABELS,
|
||||
);
|
||||
const [isCustom, setIsCustom] = useState(false);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
void (async () => {
|
||||
const team = await fetchTeamLabels().catch(() => null);
|
||||
if (cancelled) return;
|
||||
setTeamLabels(team ?? DEFAULT_CLASSIFICATION_LABELS);
|
||||
setIsCustom(team != null);
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
const saveTeam = useCallback(async (next: ClassificationLabel[]) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await saveTeamLabels(next);
|
||||
setTeamLabels(saved);
|
||||
setIsCustom(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn't save the labels.");
|
||||
throw e;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
teamLabels,
|
||||
isCustom,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
saveTeam,
|
||||
};
|
||||
}
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
removePolicy,
|
||||
} from "@app/services/policyBackend";
|
||||
import { reorderPolicies as reorderBackendPolicies } from "@app/services/policyApi";
|
||||
import { seedTeamLabelsIfEmpty } from "@app/services/labelsBackend";
|
||||
import { getPolicyToolChain } from "@app/components/policies/policyToolChains";
|
||||
import type { PolicyToStore } from "@app/services/policyPipeline";
|
||||
import type {
|
||||
PoliciesByCategory,
|
||||
@@ -137,6 +139,13 @@ export function usePolicies() {
|
||||
async (id: string, result: PolicyWizardResult) => {
|
||||
const category = loadPolicyCatalog().categories.find((c) => c.id === id);
|
||||
if (!category) throw new Error(`Unknown policy category: ${id}`);
|
||||
// A policy whose chain classifies runs against the team's stored label set
|
||||
// (the engine has no default). Seed it with the built-in defaults now so
|
||||
// the very first enforced file has a vocabulary to classify against; no-op
|
||||
// if the team already has a set (never clobbers admin edits).
|
||||
if (getPolicyToolChain(id)?.includes("classify")) {
|
||||
await seedTeamLabelsIfEmpty();
|
||||
}
|
||||
// One policy per category, ever: reuse any existing backend record.
|
||||
const existingBackendId =
|
||||
loadPolicies()[id]?.backendId ??
|
||||
@@ -216,6 +225,12 @@ export function usePolicies() {
|
||||
async (id: string, result: PolicyConfigResult) => {
|
||||
const category = loadPolicyCatalog().categories.find((c) => c.id === id);
|
||||
if (!category) throw new Error(`Unknown policy category: ${id}`);
|
||||
// Seed the team's default label set on first classification-policy setup
|
||||
// (see enablePolicy) — the engine has no default, so the stored set is the
|
||||
// only vocabulary. No-op once the team has any set.
|
||||
if (getPolicyToolChain(id)?.includes("classify")) {
|
||||
await seedTeamLabelsIfEmpty();
|
||||
}
|
||||
const current = loadPolicies()[id];
|
||||
// One policy per category, ever: reuse the existing backend record (even
|
||||
// if the local link was lost) so a save never creates a duplicate.
|
||||
|
||||
@@ -13,9 +13,8 @@ const ALL_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[];
|
||||
|
||||
describe("POLICY_OPERATIONS", () => {
|
||||
test("every category operation is a typed descriptor with a known endpoint", () => {
|
||||
// The catalogue uses these across all categories; each must be wired.
|
||||
// The catalogue uses these six across all categories; each must be wired.
|
||||
expect(ALL_TOOL_IDS.sort()).toEqual([
|
||||
"classify",
|
||||
"compress",
|
||||
"flatten",
|
||||
"ocr",
|
||||
|
||||
@@ -11,39 +11,10 @@ import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAd
|
||||
import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation";
|
||||
import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
|
||||
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
|
||||
import type { ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
import type { ToolOperationDescriptor } from "@app/hooks/tools/shared/toolOperationDescriptor";
|
||||
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
import type { WirePipelineStep } from "@app/policies/types";
|
||||
|
||||
/**
|
||||
* Endpoints for AI-dispatched policy tools. Intentionally NOT part of the generated
|
||||
* {@link ToolEndpoint} union: the backend controllers are `@Hidden` and `/api/v1/ai/tools/` is
|
||||
* deliberately excluded from tool-model generation, so they can't be typed like standard tools.
|
||||
*/
|
||||
export type AiPolicyEndpoint = "/api/v1/ai/tools/classify-and-label";
|
||||
|
||||
/** An AI tool usable in a policy — the AI-endpoint analogue of a standard tool descriptor. */
|
||||
export interface AiToolDescriptor<TParams> {
|
||||
readonly endpoint: AiPolicyEndpoint;
|
||||
readonly defaultParameters: TParams;
|
||||
toApi(params: TParams): Record<string, unknown>;
|
||||
fromApi(api: Record<string, unknown>): TParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe an AI-dispatched policy tool. AI tools carry no tunable parameters today, so params are
|
||||
* empty and the (de)serializers are identity over an empty object.
|
||||
*/
|
||||
export function describeAiToolOperation(
|
||||
endpoint: AiPolicyEndpoint,
|
||||
): AiToolDescriptor<Record<string, never>> {
|
||||
return {
|
||||
endpoint,
|
||||
defaultParameters: {},
|
||||
toApi: () => ({}),
|
||||
fromApi: () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
export const POLICY_OPERATIONS = {
|
||||
redact: describeToolOperation(
|
||||
"/api/v1/security/auto-redact",
|
||||
@@ -66,13 +37,17 @@ export const POLICY_OPERATIONS = {
|
||||
"/api/v1/misc/compress-pdf",
|
||||
compressOperationConfig,
|
||||
),
|
||||
classify: describeAiToolOperation("/api/v1/ai/tools/classify-and-label"),
|
||||
} as const;
|
||||
|
||||
export type PolicyToolId = keyof typeof POLICY_OPERATIONS;
|
||||
|
||||
export type PolicyParams<Id extends PolicyToolId> =
|
||||
(typeof POLICY_OPERATIONS)[Id]["defaultParameters"];
|
||||
(typeof POLICY_OPERATIONS)[Id] extends ToolOperationDescriptor<
|
||||
ToolEndpoint,
|
||||
infer P
|
||||
>
|
||||
? P
|
||||
: never;
|
||||
|
||||
/** Discriminated on `toolId` so `params` matches the tool. */
|
||||
export type PolicyToolStep = {
|
||||
@@ -90,10 +65,7 @@ const TOOL_ID_BY_ENDPOINT = new Map<string, PolicyToolId>(
|
||||
POLICY_TOOL_IDS.map((id) => [POLICY_OPERATIONS[id].endpoint, id]),
|
||||
);
|
||||
|
||||
/** A policy step's endpoint: a standard {@link ToolEndpoint} or an {@link AiPolicyEndpoint}. */
|
||||
export type PolicyEndpoint = ToolEndpoint | AiPolicyEndpoint;
|
||||
|
||||
export function policyEndpoint(toolId: PolicyToolId): PolicyEndpoint {
|
||||
export function policyEndpoint(toolId: PolicyToolId): ToolEndpoint {
|
||||
return POLICY_OPERATIONS[toolId].endpoint;
|
||||
}
|
||||
|
||||
@@ -118,29 +90,19 @@ export function policyStepToWire(step: PolicyToolStep): WirePipelineStep {
|
||||
return serializeStep(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal runtime shape shared by standard tool descriptors and {@link AiToolDescriptor}s — enough
|
||||
* to (de)serialize a step at the wire boundary regardless of how its endpoint is typed.
|
||||
*/
|
||||
interface PolicyOperation<TParams> {
|
||||
readonly endpoint: string;
|
||||
readonly defaultParameters: TParams;
|
||||
toApi(params: TParams): Record<string, unknown>;
|
||||
fromApi(api: Record<string, unknown>): TParams;
|
||||
}
|
||||
|
||||
// Generic over the id so `params` stays correlated with the descriptor; TS can't do that through
|
||||
// the union, so `op` is widened here (a contained cast at the wire boundary).
|
||||
function serializeStep<Id extends PolicyToolId>(step: {
|
||||
toolId: Id;
|
||||
params: PolicyParams<Id>;
|
||||
}): WirePipelineStep {
|
||||
const op = POLICY_OPERATIONS[step.toolId] as unknown as PolicyOperation<
|
||||
const op = POLICY_OPERATIONS[step.toolId] as ToolOperationDescriptor<
|
||||
ToolEndpoint,
|
||||
PolicyParams<Id>
|
||||
>;
|
||||
return {
|
||||
operation: op.endpoint,
|
||||
parameters: op.toApi(step.params),
|
||||
parameters: op.toApi(step.params) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -157,10 +119,13 @@ function deserializeStep<Id extends PolicyToolId>(
|
||||
toolId: Id,
|
||||
parameters: Record<string, unknown>,
|
||||
): PolicyToolStepOf<Id> {
|
||||
const op = POLICY_OPERATIONS[toolId] as unknown as PolicyOperation<
|
||||
const op = POLICY_OPERATIONS[toolId] as ToolOperationDescriptor<
|
||||
ToolEndpoint,
|
||||
PolicyParams<Id>
|
||||
>;
|
||||
// Wire params are untyped JSON; this is the one point they enter the typed model.
|
||||
const params = op.fromApi(parameters);
|
||||
const params = op.fromApi(
|
||||
parameters as unknown as ToolApiParams[ToolEndpoint],
|
||||
);
|
||||
return { toolId, params } as unknown as PolicyToolStepOf<Id>;
|
||||
}
|
||||
|
||||
@@ -1,80 +1,95 @@
|
||||
// The Files-sidebar categories are a fixed, built-in set shared by everyone (derived from
|
||||
// LABEL_FAMILIES) — the team can't create, rename, or re-group them. The only per-user choice is
|
||||
// which categories to SHOW or HIDE in the sidebar, kept device-local in localStorage. A hidden
|
||||
// category isn't rendered as a group; its files fall to "Other". Presentational only — the
|
||||
// classifier never sees categories.
|
||||
// Device-local (localStorage) category structure for the Files sidebar: which parent
|
||||
// categories exist, their name/icon/order, and which labels roll up into each. It's an editable
|
||||
// override of the built-in LABEL_FAMILIES default — until the user customizes it, the default is
|
||||
// used verbatim (so the store stays empty and the default can evolve). A label may sit in more
|
||||
// than one category (multi-membership); a category with `hidden` set stays defined but isn't shown
|
||||
// as a sidebar group. This is presentational only — the classifier never sees categories.
|
||||
|
||||
import { LABEL_FAMILIES } from "@app/data/classificationLabels";
|
||||
|
||||
export interface SidebarCategory {
|
||||
/** Stable id (the label family's id). */
|
||||
/** Stable id — built-ins reuse their family id; custom ones get `custom:<n>`. */
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
/** Label ids in this category (matches a file's stored classification ids). */
|
||||
labelKeys: string[];
|
||||
/** Hidden from the sidebar (device-local, personal). */
|
||||
/** Defined but not rendered as a sidebar group. */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
const HIDDEN_STORAGE_KEY = "stirling.fileSidebarHiddenCategories.v1";
|
||||
// v2: labelKeys hold label ids (was lower-cased names in v1); bumping discards
|
||||
// stale name-keyed prefs so they don't silently stop matching.
|
||||
const STORAGE_KEY = "stirling.fileSidebarCategories.v2";
|
||||
|
||||
/** The fixed, shared category set — never mutated. */
|
||||
const BASE_CATEGORIES: readonly Omit<SidebarCategory, "hidden">[] =
|
||||
LABEL_FAMILIES.map((family) => ({
|
||||
/** The built-in default, derived from LABEL_FAMILIES. Fresh copy per call (callers may mutate). */
|
||||
export function defaultCategories(): SidebarCategory[] {
|
||||
return LABEL_FAMILIES.map((family) => ({
|
||||
id: family.id,
|
||||
name: family.name,
|
||||
icon: family.icon,
|
||||
labelKeys: family.labels.map((label) => label.id),
|
||||
}));
|
||||
}
|
||||
|
||||
function readHidden(): Set<string> {
|
||||
function readStorage(): SidebarCategory[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(HIDDEN_STORAGE_KEY);
|
||||
if (!raw) return new Set();
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return Array.isArray(parsed)
|
||||
? new Set(parsed.filter((id): id is string => typeof id === "string"))
|
||||
: new Set();
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed
|
||||
.filter((c): c is SidebarCategory => {
|
||||
const cat = c as Partial<SidebarCategory>;
|
||||
return (
|
||||
typeof cat.id === "string" &&
|
||||
typeof cat.name === "string" &&
|
||||
typeof cat.icon === "string" &&
|
||||
Array.isArray(cat.labelKeys)
|
||||
);
|
||||
})
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
icon: c.icon,
|
||||
labelKeys: c.labelKeys.filter((k) => typeof k === "string"),
|
||||
hidden: c.hidden === true,
|
||||
}));
|
||||
} catch {
|
||||
return new Set();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let hiddenIds = readHidden();
|
||||
// null = using the built-in default (not yet customized). Cached so useSyncExternalStore sees a
|
||||
// stable reference between writes.
|
||||
let stored: SidebarCategory[] | null = readStorage();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
// Recomputed only on write so its identity is stable for memo/useSyncExternalStore.
|
||||
let effective: SidebarCategory[] = compute();
|
||||
// Effective list, recomputed only on write so its identity is stable for memo/useSyncExternalStore.
|
||||
let effective: SidebarCategory[] = stored ?? defaultCategories();
|
||||
|
||||
function compute(): SidebarCategory[] {
|
||||
return BASE_CATEGORIES.map((c) => ({
|
||||
...c,
|
||||
labelKeys: [...c.labelKeys],
|
||||
hidden: hiddenIds.has(c.id),
|
||||
}));
|
||||
function recompute() {
|
||||
effective = stored ?? defaultCategories();
|
||||
}
|
||||
|
||||
function persist() {
|
||||
function write(next: SidebarCategory[]) {
|
||||
stored = next;
|
||||
recompute();
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_STORAGE_KEY, JSON.stringify([...hiddenIds]));
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// Quota/private-mode failures degrade to session-only visibility.
|
||||
// Quota/private-mode failures degrade to session-only categories.
|
||||
}
|
||||
effective = compute();
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** The shared categories, each flagged with the user's device-local visibility. */
|
||||
export function getSidebarCategories(): SidebarCategory[] {
|
||||
return effective;
|
||||
/** Mutate the current effective list (snapshotting the default on first edit). */
|
||||
function mutate(fn: (categories: SidebarCategory[]) => SidebarCategory[]) {
|
||||
write(fn(effective.map((c) => ({ ...c, labelKeys: [...c.labelKeys] }))));
|
||||
}
|
||||
|
||||
/** Show or hide a category in this device's sidebar. */
|
||||
export function setCategoryHidden(id: string, hidden: boolean) {
|
||||
if (hidden) hiddenIds.add(id);
|
||||
else hiddenIds.delete(id);
|
||||
persist();
|
||||
export function getSidebarCategories(): SidebarCategory[] {
|
||||
return effective;
|
||||
}
|
||||
|
||||
export function subscribeSidebarCategories(listener: () => void) {
|
||||
@@ -84,8 +99,98 @@ export function subscribeSidebarCategories(listener: () => void) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Show every category again (clear the device-local hidden set). */
|
||||
export function resetHiddenCategories() {
|
||||
hiddenIds = new Set();
|
||||
persist();
|
||||
export function isCustomized(): boolean {
|
||||
return stored !== null;
|
||||
}
|
||||
|
||||
/** Map each label key to the ids of every VISIBLE category it belongs to. */
|
||||
export function labelCategoryMap(
|
||||
categories: SidebarCategory[],
|
||||
): Map<string, string[]> {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const category of categories) {
|
||||
if (category.hidden) continue;
|
||||
for (const key of category.labelKeys) {
|
||||
const ids = map.get(key);
|
||||
if (ids) ids.push(category.id);
|
||||
else map.set(key, [category.id]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Set of every label key that belongs to any category (visible or not). */
|
||||
export function categorizedLabelKeys(
|
||||
categories: SidebarCategory[],
|
||||
): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const category of categories) {
|
||||
for (const key of category.labelKeys) keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// ---- editing ----
|
||||
|
||||
/** Create a new empty category; returns its id. */
|
||||
export function addCategory(name: string, icon: string): string {
|
||||
const id = `custom:${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${
|
||||
effective.length
|
||||
}`;
|
||||
mutate((categories) => [...categories, { id, name, icon, labelKeys: [] }]);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function renameCategory(id: string, name: string) {
|
||||
mutate((categories) =>
|
||||
categories.map((c) => (c.id === id ? { ...c, name } : c)),
|
||||
);
|
||||
}
|
||||
|
||||
export function setCategoryIcon(id: string, icon: string) {
|
||||
mutate((categories) =>
|
||||
categories.map((c) => (c.id === id ? { ...c, icon } : c)),
|
||||
);
|
||||
}
|
||||
|
||||
export function setCategoryHidden(id: string, hidden: boolean) {
|
||||
mutate((categories) =>
|
||||
categories.map((c) => (c.id === id ? { ...c, hidden } : c)),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCategory(id: string) {
|
||||
mutate((categories) => categories.filter((c) => c.id !== id));
|
||||
}
|
||||
|
||||
export function addLabelToCategory(id: string, labelId: string) {
|
||||
mutate((categories) =>
|
||||
categories.map((c) =>
|
||||
c.id === id && !c.labelKeys.includes(labelId)
|
||||
? { ...c, labelKeys: [...c.labelKeys, labelId] }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function removeLabelFromCategory(id: string, labelId: string) {
|
||||
mutate((categories) =>
|
||||
categories.map((c) =>
|
||||
c.id === id
|
||||
? { ...c, labelKeys: c.labelKeys.filter((k) => k !== labelId) }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Restore the built-in default and clear the customized flag (undoes any prior `mutate`). */
|
||||
export function resetSidebarCategories() {
|
||||
stored = null;
|
||||
recompute();
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Quota/private-mode failures degrade to session-only categories.
|
||||
}
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user