Attributes UX: Key (type) labels, filter, Overlay without field target.
CE-WP-0010: rename form vocabulary in user-facing strings to attributes, show type next to keys, filter attributes at ≥3 characters, and allow card→highlight guide lines when no attribute is focused.
This commit is contained in:
parent
c07e369bdb
commit
32c5a2ede4
5 changed files with 137 additions and 53 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* Shared label + type editor for add-field and edit-field flows (CE-WP-0007-T10/T11).
|
* Shared key + type editor for add/edit attribute flows
|
||||||
|
* (CE-WP-0007-T10/T11; CE-WP-0010 attributes vocabulary).
|
||||||
* Styled to match EvidenceFormBody / InlineCaptureForm.
|
* Styled to match EvidenceFormBody / InlineCaptureForm.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -48,7 +49,7 @@ export function FieldDefinitionForm(p: FieldDefinitionFormProps) {
|
||||||
<div style={{ marginBottom: 6, fontWeight: 600 }}>{p.badge}</div>
|
<div style={{ marginBottom: 6, fontWeight: 600 }}>{p.badge}</div>
|
||||||
)}
|
)}
|
||||||
<label style={labelStyle} htmlFor={`${p.testidPrefix}-label`}>
|
<label style={labelStyle} htmlFor={`${p.testidPrefix}-label`}>
|
||||||
Field label
|
Attribute key
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id={`${p.testidPrefix}-label`}
|
id={`${p.testidPrefix}-label`}
|
||||||
|
|
@ -59,7 +60,7 @@ export function FieldDefinitionForm(p: FieldDefinitionFormProps) {
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
<label style={labelStyle} htmlFor={`${p.testidPrefix}-type`}>
|
<label style={labelStyle} htmlFor={`${p.testidPrefix}-type`}>
|
||||||
Field type
|
Attribute type
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id={`${p.testidPrefix}-type`}
|
id={`${p.testidPrefix}-type`}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import {
|
||||||
|
|
||||||
const SCHEMA: FormSchema = {
|
const SCHEMA: FormSchema = {
|
||||||
id: "demo",
|
id: "demo",
|
||||||
title: "Demo form",
|
title: "Attributes",
|
||||||
fields: [
|
fields: [
|
||||||
{ type: "textarea", id: "summary", label: "Summary" },
|
{ type: "textarea", id: "summary", label: "Summary" },
|
||||||
{ type: "date", id: "deadline", label: "Deadline" },
|
{ type: "date", id: "deadline", label: "Deadline" },
|
||||||
|
|
@ -59,11 +59,12 @@ describe("FormRenderer (CE-WP-0003-T04)", () => {
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders each schema field with its label", () => {
|
it("renders each schema field with key (type) labels", () => {
|
||||||
renderWithProviders({ schema: SCHEMA });
|
renderWithProviders({ schema: SCHEMA });
|
||||||
expect(screen.getByLabelText("Summary")).toBeTruthy();
|
expect(screen.getByLabelText(/Summary \(textarea\)/)).toBeTruthy();
|
||||||
expect(screen.getByLabelText("Deadline")).toBeTruthy();
|
expect(screen.getByLabelText(/Deadline \(date\)/)).toBeTruthy();
|
||||||
expect(screen.getByLabelText("Amount")).toBeTruthy();
|
expect(screen.getByLabelText(/Amount \(text\)/)).toBeTruthy();
|
||||||
|
expect(screen.getByRole("heading", { name: "Attributes" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers each field with the rect registry as kind=field", () => {
|
it("registers each field with the rect registry as kind=field", () => {
|
||||||
|
|
@ -79,7 +80,7 @@ describe("FormRenderer (CE-WP-0003-T04)", () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { events, ctxValue } = renderWithProviders({ schema: SCHEMA });
|
const { events, ctxValue } = renderWithProviders({ schema: SCHEMA });
|
||||||
cleanupCtx = () => ctxValue.observer.disconnect();
|
cleanupCtx = () => ctxValue.observer.disconnect();
|
||||||
await user.click(screen.getByLabelText("Summary"));
|
await user.click(screen.getByLabelText(/Summary/));
|
||||||
const fieldEvents = events.filter((e) => e.type === "FormFieldActivated");
|
const fieldEvents = events.filter((e) => e.type === "FormFieldActivated");
|
||||||
expect(fieldEvents).toHaveLength(1);
|
expect(fieldEvents).toHaveLength(1);
|
||||||
expect(fieldEvents[0]).toMatchObject({
|
expect(fieldEvents[0]).toMatchObject({
|
||||||
|
|
@ -95,13 +96,29 @@ describe("FormRenderer (CE-WP-0003-T04)", () => {
|
||||||
onValueChange: (id, value) => changes.push([id, value]),
|
onValueChange: (id, value) => changes.push([id, value]),
|
||||||
});
|
});
|
||||||
cleanupCtx = () => ctxValue.observer.disconnect();
|
cleanupCtx = () => ctxValue.observer.disconnect();
|
||||||
await user.type(screen.getByLabelText("Amount"), "42");
|
await user.type(screen.getByLabelText(/Amount/), "42");
|
||||||
expect(changes).toEqual([
|
expect(changes).toEqual([
|
||||||
["amount", "4"],
|
["amount", "4"],
|
||||||
["amount", "2"],
|
["amount", "2"],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("filters attributes when the query has 3+ characters", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { ctxValue } = renderWithProviders({
|
||||||
|
schema: SCHEMA,
|
||||||
|
values: { amount: "1500 EUR" },
|
||||||
|
});
|
||||||
|
cleanupCtx = () => ctxValue.observer.disconnect();
|
||||||
|
const filter = screen.getByTestId("attributes-filter");
|
||||||
|
await user.type(filter, "am");
|
||||||
|
expect(document.querySelector('[data-field-id="summary"]')).not.toBeNull();
|
||||||
|
expect(document.querySelector('[data-field-id="amount"]')).not.toBeNull();
|
||||||
|
await user.type(filter, "o"); // "amo" — matches Amount
|
||||||
|
expect(document.querySelector('[data-field-id="summary"]')).toBeNull();
|
||||||
|
expect(document.querySelector('[data-field-id="amount"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the link-count chip when linkCounts[fieldId] > 0", () => {
|
it("renders the link-count chip when linkCounts[fieldId] > 0", () => {
|
||||||
const { ctxValue } = renderWithProviders({
|
const { ctxValue } = renderWithProviders({
|
||||||
schema: SCHEMA,
|
schema: SCHEMA,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,23 @@
|
||||||
/**
|
/**
|
||||||
* FormRenderer — renders a FormSchema as a small evidence-backed form.
|
* FormRenderer — renders a FormSchema as evidence-backed attributes.
|
||||||
*
|
*
|
||||||
* Each field registers itself with the rect registry under
|
* Each attribute registers itself with the rect registry under
|
||||||
* `kind="field"` and the field's `id`, so the SVG visual guide (T07) can
|
* `kind="field"` and the field's `id`, so the SVG visual guide can
|
||||||
* draw curves from the active field to its linked evidence card and on
|
* draw curves from the active attribute to its linked evidence card and
|
||||||
* to the source highlight.
|
* on to the source highlight.
|
||||||
*
|
*
|
||||||
* CE-WP-0007-T10/T11: add-field and edit-field flows use FieldDefinitionForm.
|
* CE-WP-0007-T10/T11: add/edit flows use FieldDefinitionForm.
|
||||||
|
* CE-WP-0010: user-facing "Attributes" vocabulary, `Key (type)` labels,
|
||||||
|
* and a ≥3-character filter next to the caption.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useRef, useState, type ChangeEvent, type CSSProperties } from "react";
|
import {
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
type CSSProperties,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
|
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
|
||||||
|
|
||||||
|
|
@ -17,6 +25,8 @@ import { FieldDefinitionForm, type FieldType } from "./FieldDefinitionForm";
|
||||||
import { useActiveState, type ActiveState } from "./state/active";
|
import { useActiveState, type ActiveState } from "./state/active";
|
||||||
import { useRegisterRect } from "./visual-guide/react-hooks";
|
import { useRegisterRect } from "./visual-guide/react-hooks";
|
||||||
|
|
||||||
|
const FILTER_MIN_CHARS = 3;
|
||||||
|
|
||||||
function isFieldActive(state: ActiveState, fieldId: string): boolean {
|
function isFieldActive(state: ActiveState, fieldId: string): boolean {
|
||||||
return (
|
return (
|
||||||
state.activeTarget?.targetType === "form-field" &&
|
state.activeTarget?.targetType === "form-field" &&
|
||||||
|
|
@ -24,6 +34,18 @@ function isFieldActive(state: ActiveState, fieldId: string): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Case-insensitive substring match; empty / short queries match everything. */
|
||||||
|
export function matchesTextFilter(
|
||||||
|
query: string,
|
||||||
|
parts: readonly (string | undefined | null)[],
|
||||||
|
minChars = FILTER_MIN_CHARS,
|
||||||
|
): boolean {
|
||||||
|
const q = query.trim();
|
||||||
|
if (q.length < minChars) return true;
|
||||||
|
const needle = q.toLowerCase();
|
||||||
|
return parts.some((p) => p != null && String(p).toLowerCase().includes(needle));
|
||||||
|
}
|
||||||
|
|
||||||
export interface FormFieldSchema {
|
export interface FormFieldSchema {
|
||||||
readonly type: "text" | "textarea" | "date";
|
readonly type: "text" | "textarea" | "date";
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
|
|
@ -113,8 +135,8 @@ function FieldRow({
|
||||||
onChangeType={onChangeEditType}
|
onChangeType={onChangeEditType}
|
||||||
onSave={onSaveEdit}
|
onSave={onSaveEdit}
|
||||||
onCancel={onCancelEdit}
|
onCancel={onCancelEdit}
|
||||||
saveLabel="Save field"
|
saveLabel="Save attribute"
|
||||||
badge="Editing field"
|
badge="Editing attribute"
|
||||||
testidPrefix={`field-edit-${field.id}`}
|
testidPrefix={`field-edit-${field.id}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -147,9 +169,9 @@ function FieldRow({
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Edit field ${field.label}`}
|
aria-label={`Edit ${field.id}`}
|
||||||
data-testid={`field-edit-toggle-${field.id}`}
|
data-testid={`field-edit-toggle-${field.id}`}
|
||||||
title="Edit field label and type"
|
title="Edit attribute key and type"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onBeginEdit();
|
onBeginEdit();
|
||||||
|
|
@ -174,7 +196,8 @@ function FieldRow({
|
||||||
paddingRight: 28,
|
paddingRight: 28,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{field.label}
|
{field.label}{" "}
|
||||||
|
<span style={{ fontWeight: 400, color: "#666" }}>({field.type})</span>
|
||||||
{linkCount > 0 ? (
|
{linkCount > 0 ? (
|
||||||
<span
|
<span
|
||||||
data-testid={`field-${field.id}-chip`}
|
data-testid={`field-${field.id}-chip`}
|
||||||
|
|
@ -218,10 +241,11 @@ export function FormRenderer({
|
||||||
onCancelFieldEdit,
|
onCancelFieldEdit,
|
||||||
}: FormRendererProps) {
|
}: FormRendererProps) {
|
||||||
const { state, focusTarget } = useActiveState();
|
const { state, focusTarget } = useActiveState();
|
||||||
const [addLabel, setAddLabel] = useState("New field");
|
const [addLabel, setAddLabel] = useState("New attribute");
|
||||||
const [addType, setAddType] = useState<FieldType>("text");
|
const [addType, setAddType] = useState<FieldType>("text");
|
||||||
const [editLabel, setEditLabel] = useState("");
|
const [editLabel, setEditLabel] = useState("");
|
||||||
const [editType, setEditType] = useState<FieldType>("text");
|
const [editType, setEditType] = useState<FieldType>("text");
|
||||||
|
const [filterQuery, setFilterQuery] = useState("");
|
||||||
|
|
||||||
const handleFocus = (fieldId: string) => {
|
const handleFocus = (fieldId: string) => {
|
||||||
const target: EvidenceTarget = { targetType: "form-field", targetId: fieldId };
|
const target: EvidenceTarget = { targetType: "form-field", targetId: fieldId };
|
||||||
|
|
@ -234,12 +258,21 @@ export function FormRenderer({
|
||||||
onBeginEditField?.(field.id);
|
onBeginEditField?.(field.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const visibleFields = useMemo(
|
||||||
|
() =>
|
||||||
|
schema.fields.filter((field) =>
|
||||||
|
matchesTextFilter(filterQuery, [
|
||||||
|
field.label,
|
||||||
|
field.type,
|
||||||
|
field.id,
|
||||||
|
values?.[field.id],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
[schema.fields, filterQuery, values],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<div data-form-id={schema.id} style={{ padding: 12 }} role="group" aria-label="Attributes">
|
||||||
data-form-id={schema.id}
|
|
||||||
style={{ padding: 12 }}
|
|
||||||
onSubmit={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
@ -247,16 +280,41 @@ export function FormRenderer({
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
|
flexWrap: "wrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 style={{ fontSize: 14, margin: 0, fontFamily: "system-ui, sans-serif" }}>
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{schema.title}
|
{schema.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={filterQuery}
|
||||||
|
onChange={(e) => setFilterQuery(e.target.value)}
|
||||||
|
placeholder="Filter…"
|
||||||
|
aria-label="Filter attributes"
|
||||||
|
data-testid="attributes-filter"
|
||||||
|
style={{
|
||||||
|
flex: "1 1 100px",
|
||||||
|
minWidth: 80,
|
||||||
|
maxWidth: 160,
|
||||||
|
fontSize: 12,
|
||||||
|
padding: "4px 6px",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="add-field-button"
|
data-testid="add-field-button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddLabel(`New field ${schema.fields.length + 1}`);
|
setAddLabel(`New attribute ${schema.fields.length + 1}`);
|
||||||
setAddType("text");
|
setAddType("text");
|
||||||
onRequestAddField?.();
|
onRequestAddField?.();
|
||||||
}}
|
}}
|
||||||
|
|
@ -269,7 +327,7 @@ export function FormRenderer({
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add field
|
Add attribute
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -286,13 +344,22 @@ export function FormRenderer({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onCancel={() => onCancelAddField?.()}
|
onCancel={() => onCancelAddField?.()}
|
||||||
saveLabel="Add field"
|
saveLabel="Add attribute"
|
||||||
badge="New form field"
|
badge="New attribute"
|
||||||
testidPrefix="field-add"
|
testidPrefix="field-add"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{schema.fields.map((field) => (
|
{visibleFields.length === 0 && schema.fields.length > 0 && (
|
||||||
|
<p
|
||||||
|
style={{ fontSize: 12, color: "#888", margin: "8px 0" }}
|
||||||
|
data-testid="attributes-filter-empty"
|
||||||
|
>
|
||||||
|
No matches.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleFields.map((field) => (
|
||||||
<FieldRow
|
<FieldRow
|
||||||
key={field.id}
|
key={field.id}
|
||||||
field={field}
|
field={field}
|
||||||
|
|
@ -319,6 +386,6 @@ export function FormRenderer({
|
||||||
onCancelEdit={() => onCancelFieldEdit?.()}
|
onCancelEdit={() => onCancelFieldEdit?.()}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</form>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ export * from "./repos";
|
||||||
export * from "./services";
|
export * from "./services";
|
||||||
export * from "./state";
|
export * from "./state";
|
||||||
export * from "./visual-guide";
|
export * from "./visual-guide";
|
||||||
export { FormRenderer } from "./FormRenderer";
|
export { FormRenderer, matchesTextFilter } from "./FormRenderer";
|
||||||
export type {
|
export type {
|
||||||
|
FieldDefinitionPatch,
|
||||||
FormFieldSchema,
|
FormFieldSchema,
|
||||||
FormRendererProps,
|
FormRendererProps,
|
||||||
FormSchema,
|
FormSchema,
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@
|
||||||
* `useSyncExternalStore` subscription via `useRectRegistryVersion` picks
|
* `useSyncExternalStore` subscription via `useRectRegistryVersion` picks
|
||||||
* up that single tick and React re-renders once per frame.
|
* up that single tick and React re-renders once per frame.
|
||||||
*
|
*
|
||||||
* Active-only: only the currently active triple is drawn. If any leg's
|
* Active-only: draws legs for the active evidence card. Attribute→card
|
||||||
* rect is missing (e.g. the viewer hasn't reported a highlight rect for
|
* when a Capture attribute is focused; card→highlight whenever the
|
||||||
* the active annotation yet), that leg is omitted but the other one
|
* highlight rect is available (Annotate or Capture). Missing legs are
|
||||||
* still renders.
|
* omitted independently.
|
||||||
*
|
*
|
||||||
* MVP-sufficient. Future polish: easing the curve direction by source
|
* MVP-sufficient. Future polish: easing the curve direction by source
|
||||||
* type, animating the transition between active states, dimming
|
* type, animating the transition between active states, dimming
|
||||||
|
|
@ -33,14 +33,6 @@ function rectCenter(rect: DOMRect): { x: number; y: number } {
|
||||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function rectBottomCenter(rect: DOMRect): { x: number; y: number } {
|
|
||||||
return { x: rect.left + rect.width / 2, y: rect.bottom };
|
|
||||||
}
|
|
||||||
|
|
||||||
function rectTopCenter(rect: DOMRect): { x: number; y: number } {
|
|
||||||
return { x: rect.left + rect.width / 2, y: rect.top };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a quadratic bezier from `a` to `b` whose control point bulges
|
* Build a quadratic bezier from `a` to `b` whose control point bulges
|
||||||
* horizontally between them. The horizontal-bulge style is right for a
|
* horizontally between them. The horizontal-bulge style is right for a
|
||||||
|
|
@ -72,18 +64,24 @@ export function Overlay({
|
||||||
const version = useRectRegistryVersion();
|
const version = useRectRegistryVersion();
|
||||||
|
|
||||||
const paths = useMemo<readonly string[]>(() => {
|
const paths = useMemo<readonly string[]>(() => {
|
||||||
if (!state.activeTarget || !state.activeEvidenceItemId) return [];
|
// CE-WP-0010: card → highlight is enough for Annotate mode (no attribute
|
||||||
const fieldRect = registry.getRect("field", state.activeTarget.targetId);
|
// target). Field → card still draws when Capture has an active target.
|
||||||
|
if (!state.activeEvidenceItemId) return [];
|
||||||
|
const fieldRect = state.activeTarget
|
||||||
|
? registry.getRect("field", state.activeTarget.targetId)
|
||||||
|
: null;
|
||||||
const cardRect = registry.getRect("evidence-card", state.activeEvidenceItemId);
|
const cardRect = registry.getRect("evidence-card", state.activeEvidenceItemId);
|
||||||
const highlightRect = state.activeAnnotationId
|
const highlightRect = state.activeAnnotationId
|
||||||
? registry.getRect("highlight", state.activeAnnotationId)
|
? registry.getRect("highlight", state.activeAnnotationId)
|
||||||
: null;
|
: null;
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
if (fieldRect && cardRect) {
|
if (fieldRect && cardRect) {
|
||||||
out.push(bezierPath(rectBottomCenter(fieldRect), rectTopCenter(cardRect)));
|
// Horizontal layout (Evidence | Attributes): centre-to-centre reads better
|
||||||
|
// than the old bottom-strip geometry.
|
||||||
|
out.push(bezierPath(rectCenter(fieldRect), rectCenter(cardRect)));
|
||||||
}
|
}
|
||||||
if (cardRect && highlightRect) {
|
if (cardRect && highlightRect) {
|
||||||
out.push(bezierPath(rectTopCenter(cardRect), rectCenter(highlightRect)));
|
out.push(bezierPath(rectCenter(cardRect), rectCenter(highlightRect)));
|
||||||
}
|
}
|
||||||
void version; // memo invalidator
|
void version; // memo invalidator
|
||||||
return out;
|
return out;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue