Copilot commented on code in PR #1233: URL: https://github.com/apache/skywalking-banyandb/pull/1233#discussion_r3662066560
########## canopy/web/src/query/property-bydbql.ts: ########## @@ -0,0 +1,333 @@ +/* + * Licensed to Apache Software Foundation (ASF) under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Apache Software Foundation (ASF) licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// property-bydbql.ts — pure BydbQL codegen/parsing + the builder-state -> the +// property/v1 Query RPC request translation, for the property-scoped query +// console (PropertyQuery.tsx). Ported from +// .handoff-import/banyandb/project/property-query.jsx's non-React parts +// (PROP_OPS, buildPropertyBydbQL, pqParseCode et al) plus a NEW +// pqBuildQueryRequest that the handoff didn't need (it filtered an in-memory +// `entries` array instead of calling a real Query RPC — see +// docs/property-design.md §5). +// +// The WHERE tree reuses bydbql.ts's node shape (QBWhereNode / +// QBWhereGroupWithConn / QBWhereLeafWithConn) verbatim — the handoff's own +// comment on pqNodeSQL says children's `conn` follows "qbConn in +// query-builder.jsx", i.e. property's WHERE tree was always meant to be the +// same shape as the main builder's, just with a different (smaller) operator +// set and a special PQ_ID sentinel tag. Reusing the type avoids a parallel +// tree implementation; qbConnSegments/qbConnSummary/QB_COMBINATORS are +// imported from bydbql.ts rather than re-implemented. + +import { + QB_COMBINATORS, qbIsGroup, qbConn, qbConnSegments, + type QBWhereNode, type QBWhereGroupWithConn, type QBWhereLeafWithConn, +} from './bydbql.js'; +import type { PropertyCriteria, PropertyQueryRequest, PropertyQueryOrder, PropertyTagValue } from 'canopy-shared'; + +export { QB_COMBINATORS, qbIsGroup, qbConn }; +export type { QBWhereNode, QBWhereGroupWithConn, QBWhereLeafWithConn }; + +/** Sentinel tag identifier for the document id (maps to the request's `ids`, + * never to `criteria`). Rendered/parsed as the literal "ID" — mirrors the + * handoff's PQ_ID exactly. */ +export const PQ_ID = 'ID'; + +export interface PropOpDef { + readonly value: string; + readonly sql: string; + readonly label: string; +} + +/** model.v1.Condition.BinaryOp minus MATCH/HAVING/NOT_HAVING — property + * criteria only supports comparison + set membership (see + * pkg/query/logical/parser.go's ParseExpr, which property's + * BuildPropertyQuery routes through). */ +export const PROP_OPS: readonly PropOpDef[] = [ + { value: 'BINARY_OP_EQ', sql: '=', label: 'equals =' }, + { value: 'BINARY_OP_NE', sql: '!=', label: 'not equals ≠' }, + { value: 'BINARY_OP_GT', sql: '>', label: 'greater >' }, + { value: 'BINARY_OP_GE', sql: '>=', label: 'greater or equal ≥' }, + { value: 'BINARY_OP_LT', sql: '<', label: 'less <' }, + { value: 'BINARY_OP_LE', sql: '<=', label: 'less or equal ≤' }, + { value: 'BINARY_OP_IN', sql: 'IN', label: 'in (a, b)' }, + { value: 'BINARY_OP_NOT_IN', sql: 'NOT IN', label: 'not in (a, b)' }, +]; + +export const PROP_OP = (v: string): PropOpDef => PROP_OPS.find((o) => o.value === v) ?? PROP_OPS[0]; + +// ── builder leaf / group factories ────────────────────────────────────────── + +export function pqNewCond(): QBWhereLeafWithConn { + return { tag: PQ_ID, op: 'BINARY_OP_EQ', value: '' }; +} +export function pqNewGroup(): QBWhereGroupWithConn { + return { combinator: 'AND', children: [pqNewCond()] }; +} +export function pqWhereRoot(s: { readonly where?: QBWhereNode }): QBWhereGroupWithConn { + if (s.where && qbIsGroup(s.where)) return s.where; + return { combinator: 'AND', children: [] }; +} + +// ── value helpers ──────────────────────────────────────────────────────────── + +const PQ_NUM = (s: string): boolean => /^-?\d+(\.\d+)?$/.test(String(s).trim()); Review Comment: PQ_NUM treats decimal values (e.g. "1.2") as numeric, which then causes BydbQL generation to omit quotes and pqConditionValue to encode the value as an `int`/`intArray`. Property tags only support integer `TagValue.int`/`intArray` (no float), so decimals should be treated as strings (quoted, and encoded as `str`/`strArray`) rather than as ints. ########## canopy/web/src/query/PropertyQuery.tsx: ########## @@ -0,0 +1,456 @@ +/* + * Licensed to Apache Software Foundation (ASF) under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Apache Software Foundation (ASF) licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// PropertyQuery.tsx — BydbQL query console scoped to a single Property +// collection. Ported from +// .handoff-import/banyandb/project/property-query.jsx: same QBSection +// accordion clauses (fold to one-line summaries after the first run), same +// pinned qb-foot with a one-line generated preview, same code-mode chrome — +// reusing QBSection/QBChips (qb-parts.js) and CodeEditor verbatim. +// +// ADAPTATION FROM THE HANDOFF: the mock's pqExecuteState filtered an +// in-memory `entries` prop; this port calls the real property/v1 Query RPC +// (queryPropertyDocuments) via property-bydbql.ts's pqBuildQueryRequest. Per +// docs/property-design.md §5, the Code tab is display-only in v1 (the +// builder is the source of truth for what actually executes) — code mode +// still round-trips through pqParseCode so hand-edited queries run too. Review Comment: This file header says “the Code tab is display-only in v1”, but the component’s `run()` path in code mode parses the editor contents (pqParseCode) and executes it via the Query RPC. Updating this comment to match the actual behavior will avoid confusion when maintaining code/code-mode semantics. ########## canopy/web/src/query/results/DocList.tsx: ########## @@ -0,0 +1,343 @@ +/* + * Licensed to Apache Software Foundation (ASF) under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Apache Software Foundation (ASF) licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// DocList.tsx — Property document list, "field rows" style (option A of the +// Property Document Styles exploration). One row per tag with an adaptive +// value cell: scalars inline, str values that parse as JSON get an embedded +// highlighted block (pretty <-> raw, copy, expand/collapse), long prose wraps +// with a clamp. BanyanDB has no JSON type — the stored type pill stays str; a +// dashed badge marks detection. The list paginates past DL_PAGE_SIZE. +// Ported from .handoff-import/banyandb/project/doc-list.jsx (window-global +// JSX -> ES module TSX). `RoleContext.canWrite` -> useCanWrite() (AuthContext.js). + +import React from 'react'; +import { useCanWrite } from '../../auth/AuthContext.js'; +import { IconEdit, IconTrash, IconCheck } from '../../components/icons.js'; +import { looksLikeJSON, PROP_VALUE_LABEL } from '../property-util.js'; +import type { PropertyDocument, PropertyDocTag } from 'canopy-shared'; + +const DL_PAGE_SIZE = 10; + +const IconExpand = (p: React.SVGProps<SVGSVGElement>) => ( + <svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"> + <path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3" /> + </svg> +); +const IconCopy = (p: React.SVGProps<SVGSVGElement>) => ( + <svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"> + <rect x="9" y="9" width="12" height="12" rx="2" /> + <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /> + </svg> +); +const IconBraces = (p: React.SVGProps<SVGSVGElement>) => ( + <svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"> + <path d="M8 3a2 2 0 0 0-2 2v3a2 2 0 0 1-2 2 2 2 0 0 1 2 2v3a2 2 0 0 0 2 2M16 3a2 2 0 0 1 2 2v3a2 2 0 0 0 2 2 2 2 0 0 0-2 2v3a2 2 0 0 1-2 2" /> + </svg> +); + +interface HighlightPart { readonly cls: string; readonly s: string; } + +/* ---------------- value detection (presentation, not schema) ---------------- */ +interface Detected { + readonly kind: 'json' | 'text' | 'scalar'; + readonly val: string; + readonly parsed?: unknown; +} +function dlDetect(tag: PropertyDocTag): Detected { + const val = tag.value ?? ''; + if (tag.valueType !== 'str') return { kind: 'scalar', val }; + if (looksLikeJSON(val)) { + try { + return { kind: 'json', val, parsed: JSON.parse(val) as unknown }; + } catch { /* fall through */ } + } + if (val.length > 80 || val.indexOf('\n') !== -1) return { kind: 'text', val }; + return { kind: 'scalar', val }; +} + +/* ---------------- JSON pretty-printer + highlighter ---------------- */ +function dlHighlight(value: unknown): HighlightPart[][] { + const out: HighlightPart[][] = []; + let line: HighlightPart[] = []; + const push = (cls: string, s: string) => line.push({ cls, s }); + const nl = () => { out.push(line); line = []; }; + const pad = (d: number) => push('tok-punc', ' '.repeat(d)); + const walk = (v: unknown, d: number) => { + if (v === null) { push('tok-kw', 'null'); return; } + if (typeof v === 'boolean') { push('tok-kw', String(v)); return; } + if (typeof v === 'number') { push('tok-num', String(v)); return; } + if (typeof v === 'string') { push('tok-str', JSON.stringify(v)); return; } + if (Array.isArray(v)) { + const flat = v.every((x) => typeof x !== 'object' || x === null); + push('tok-punc', '['); + if (v.length && (!flat || JSON.stringify(v).length > 48)) { + nl(); + v.forEach((x, i) => { pad(d + 1); walk(x, d + 1); if (i < v.length - 1) push('tok-punc', ','); nl(); }); + pad(d); + } else { + v.forEach((x, i) => { walk(x, d); if (i < v.length - 1) push('tok-punc', ', '); }); + } + push('tok-punc', ']'); + return; + } + const obj = v as Record<string, unknown>; + const keys = Object.keys(obj); + push('tok-punc', '{'); + if (keys.length) { + nl(); + keys.forEach((k, i) => { + pad(d + 1); + push('tok-key', JSON.stringify(k)); + push('tok-punc', ': '); + const child = obj[k]; + if (child && typeof child === 'object' && !Array.isArray(child) && JSON.stringify(child).length <= 76) { + push('tok-punc', '{ '); + const ck = Object.keys(child as Record<string, unknown>); + ck.forEach((c, ci) => { + push('tok-key', JSON.stringify(c)); + push('tok-punc', ': '); + walk((child as Record<string, unknown>)[c], d + 1); + if (ci < ck.length - 1) push('tok-punc', ', '); + }); + push('tok-punc', ' }'); + } else { + walk(child, d + 1); + } + if (i < keys.length - 1) push('tok-punc', ','); + nl(); + }); + pad(d); + } + push('tok-punc', '}'); + }; + walk(value, 0); + nl(); + return out.filter((l) => l.length); +} + +function DLCode({ lines }: { readonly lines: readonly (readonly HighlightPart[])[] }) { + return ( + <pre className="pd-code"> + {lines.map((parts, i) => ( + <span key={i} className="ln"> + <span className="ln-n">{i + 1}</span> + <span className="ln-c">{parts.map((p, j) => <span key={j} className={p.cls}>{p.s}</span>)}</span> + </span> + ))} + </pre> + ); +} + +/* ---------------- small controls ---------------- */ +function DLCopy({ text }: { readonly text: string }) { + const [state, setState] = React.useState<'ok' | 'fail' | null>(null); + const flash = (s: 'ok' | 'fail') => { setState(s); setTimeout(() => setState(null), 1400); }; + const copy = async () => { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } else { + const ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(ta); + if (!ok) throw new Error('copy rejected'); + } + flash('ok'); + } catch { flash('fail'); } + }; + return ( + <button type="button" className="pd-ibtn" onClick={() => void copy()}> + {state === 'ok' ? <IconCheck size={12} /> : <IconCopy width={12} height={12} />}{' '} + {state === 'ok' ? 'copied' : state === 'fail' ? 'copy failed' : 'copy'} + </button> + ); +} + +function DLDet({ kind }: { readonly kind: 'json' | 'text' }) { + if (kind === 'json') return <span className="pd-det" title="This str parses as JSON — pretty-printed for display">{'{}'} json</span>; + return <span className="pd-det is-text" title="Long text — wrapped for display">¶ text</span>; +} + +/* ---------------- adaptive value cells ---------------- */ +function DLJsonValue({ det }: { readonly det: Detected }) { + const lines = React.useMemo(() => dlHighlight(det.parsed), [det.val]); // eslint-disable-line react-hooks/exhaustive-deps + const rawLines = React.useMemo(() => det.val.split('\n').map((s) => [{ cls: 'tok-punc', s }]), [det.val]); + const big = lines.length > 8; + const [open, setOpen] = React.useState(!big); + const [raw, setRaw] = React.useState(false); + const shown = raw ? rawLines : lines; + return ( + <div className={'fr-block' + (open ? '' : ' is-collapsed')}> + <div className="fr-block-bar"> + <span style={{ color: 'var(--tok-key)' }}><IconBraces width={12} height={12} /></span> + <span className="pd-meta">str · parses as JSON · {lines.length} lines</span> + <span className="pd-gap" /> + <div className="pd-seg" role="group" aria-label="JSON display mode"> + <button type="button" className={raw ? '' : 'is-on'} onClick={() => setRaw(false)}>pretty</button> + <button type="button" className={raw ? 'is-on' : ''} onClick={() => setRaw(true)}>raw</button> + </div> + <DLCopy text={det.val} /> + {big && ( + <button type="button" className="pd-ibtn" onClick={() => setOpen(!open)}> + <IconExpand width={12} height={12} /> {open ? 'collapse' : 'expand'} + </button> + )} + </div> + <div + className="fr-block-body" + onClick={open ? undefined : () => setOpen(true)} + style={open ? undefined : { cursor: 'pointer' }} + title={open ? undefined : 'Click to expand'} + > + <DLCode lines={shown} /> + </div> + </div> + ); +} + +function DLTextValue({ det }: { readonly det: Detected }) { + const paras = det.val.split(/\n+/).filter(Boolean); + const long = det.val.length > 220 || paras.length > 1; + const [open, setOpen] = React.useState(!long); + return ( + <div> + <div className={'fr-prose' + (open ? '' : ' is-clamped')}> + {open ? paras.map((p, i) => <span key={i}>{i > 0 && <><br /><br /></>}{p}</span>) : paras[0]} + </div> + {long && ( + <button type="button" className="fr-more" onClick={() => setOpen(!open)}>{open ? 'collapse ▴' : 'show all ▾'}</button> + )} + </div> + ); +} + +function DLValue({ tag }: { readonly tag: PropertyDocTag }) { + const det = dlDetect(tag); + if (det.kind === 'json') return <DLJsonValue det={det} />; + if (det.kind === 'text') return <DLTextValue det={det} />; + if (tag.valueType === 'int') return <span className="fr-val-int mono">{det.val}</span>; + return <span className="mono">{det.val === '' ? '∅' : det.val}</span>; +} Review Comment: DLValue recomputes dlDetect(tag), but DocCard already computed `det` for each tag to decide whether to render the JSON/text detection badge. For JSON values this means doing looksLikeJSON/JSON.parse twice per cell, which is avoidable overhead when rendering large documents. ########## canopy/web/src/query/PropertyForms.tsx: ########## @@ -0,0 +1,491 @@ +/* + * Licensed to Apache Software Foundation (ASF) under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Apache Software Foundation (ASF) licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// PropertyForms.tsx — Property CRUD for the schema-free group/name/id model. +// Ported from .handoff-import/banyandb/project/property-form.jsx +// (window-global JSX -> ES module TSX). A Property collection is a +// schema-free document container (group/name); documents are keyed by `id` +// and carry key-value Tags. Mirrors docs/concept/data-model.md (Properties) Review Comment: The header comment describes Property as “schema-free”, but the data layer now explicitly documents that Apply rejects undeclared tag keys and the client auto-grows TagSpecs before writing. Consider adjusting the wording here to reflect the current reality (schema-light from the UI, but schema-enforced server-side) so future maintainers don’t miss why the auto-grow step exists. ########## canopy/docs/property-design.md: ########## @@ -0,0 +1,261 @@ +<!-- + Licensed to Apache Software Foundation (ASF) under one or more contributor + license agreements. See the NOTICE file distributed with this work for + additional information regarding copyright ownership. Apache Software + Foundation (ASF) licenses this file to you under the Apache License, + Version 2.0 (the "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + License for the specific language governing permissions and limitations + under the License. +--> + +# Property (MP milestone) — design + +Property support for Canopy: browse property groups → collections → **documents**, +CRUD documents, and **query documents by reusing the Query console's builder + +code editor**. Grounded in `.handoff-import/banyandb/project/screenshots-property/` +and the BanyanDB property API (`api/proto/banyandb/property/v1/{rpc,property}.proto`). + +## 0. Port the handoff source — don't rebuild + +The handoff bundle already ships the property UI. **Port these three files +verbatim** (JSX + window-globals → ES-module TSX); the only real change is +swapping the prototype's in-memory mock engine for the live API. + +| Handoff file | → Canopy | What it gives us | +|---|---|---| +| `property-query.jsx` | `web/src/query/PropertyQuery.tsx` | the whole builder⇄code console scoped to one property: `PROP_OPS`, `buildPropertyBydbQL`, `pqParseCode` (code-mode parser → **code mode executes**), `PQWhereGroup`/`PQCondition`, accordion + pinned foot + `Run`. Already reuses the shared `QBSection`/`QBChips`/`QB_COMBINATORS`/`qbConn*`. | +| `doc-list.jsx` | `web/src/query/results/DocList.tsx` | `DocList` + `DocCard` + adaptive value cells (JSON pretty-print/highlight, long-text clamp, scalar/int), type pills, role-gated Edit/Delete, pagination. | +| `property-form.jsx` | `web/src/query/PropertyForms.tsx` | `PropertyCollectionModal` (create/edit collection), `PropertyEntryModal` (Apply/Edit doc + tag editor w/ type dropdown + `CodeArea` for JSON/long str), `DeletePropertyEntryModal`, `validateEntry`. Reuses M3 `Field`/`Modal`. | + +Styling: reuse `Property Document Styles.html` classes (fold into `canopy.css`). +`prop-doc-patterns.jsx` is a design gallery — styling reference only, do not port. + +### Adaptations during the port +1. **Mock → live.** `property-query.jsx` filters an in-memory `entries` prop via + `pqExecuteState`; replace that with a call to the **property Query RPC** (§5). + `PropertyEntryModal.onSubmit` → **Apply**; `DeletePropertyEntryModal.onConfirm` + → **Delete**; `PropertyCollectionModal.onSubmit` → property **registry** create. +2. **Reuse what canopy already has** (don't re-port): `QBSection`, `QBChips`, + `QB_COMBINATORS`, `qbConnSegments`/`qbConnSummary`, `CodeEditor`, and the M3 + `Field`/`Modal`. `QBSection`/`QBChips` are currently internal to + `QueryBuilder.tsx` → **export them** (or lift to `query/qb-parts.tsx`) so + `PropertyQuery` imports them exactly as the prototype does. +3. **Small utils to port** (missing in canopy): `PROP_VALUE_TYPES` / + `PROP_VALUE_LABEL`, `looksLikeJSON`, `QBSelect` (or use the native + `<select aria-label>` canopy already uses), and `RoleContext.canWrite` → + derive from `useAuth().session.role`. + +## 1. The model (two layers) + +| Layer | What it is | API | UI | +|---|---|---|---| +| **Group** | physical unit, `catalog: CATALOG_PROPERTY` | group registry (`/api/v1/group/schema`) | Properties overview + New group | +| **Property collection** (schema) | a named, schema-free document collection in a group (e.g. `sw/temp_data`) | **`database/v1` PropertyRegistryService** (`/api/v1/property/schema/*`) | group page rows + Create/Delete property | +| **Document** (data) | one entry, keyed `group/name/<id>`, carrying key-value **tags** | **`property/v1` PropertyService** (`/api/v1/property/data/*` — Apply/Delete/Query) | collection detail: DocList + Apply/Edit/Delete | + +> **Two protos, do not conflate them.** The *collection* (a "property" in the +> schema sense) is CRUD'd via `database/v1/rpc.proto` PropertyRegistryService +> (Create/Update/Get/List/Delete, ~L807). The *documents under a collection* are +> CRUD'd via `property/v1/rpc.proto` PropertyService (Apply/Delete/Query, ~L100). + +A document's identity is the **three-level primary key `group/name/id`** and is +**immutable**; only its tags change. Collections are schema-free — each document +carries its own tags. + +## 2. What already exists vs. new + +**Already in canopy** (reuse as-is): the Properties **overview** and **group +page** are already served by the Metadata infra — `App.tsx` maps +`properties → CATALOG_PROPERTY`, `Sidebar` has the Properties nav, and +`GroupPage` handles `isProperties` (breadcrumbs, listing, links to +`/properties/{group}/{name}`). The `properties.png` / `properties-group.png` +screens are essentially done. + +**New for MP:** +1. **Property data API** (Apply / Delete / Query) — DataSource + `api.ts` + DTOs. +2. **Collection detail page** (`/properties/:group/:name`) — the `property-documents` + screen: primary-key banner, New document, the **embedded query builder + code + editor**, and the **DocList** results. +3. **Document CRUD modals** — Apply / Edit / Delete document + the **tag editor**. +4. **Collection schema create/delete** — the New property + Delete property modals + (property registry create/delete; not yet in `api.ts`). +5. **`bydbql.ts` PROPERTY branch** — so the builder/editor generate property BydbQL. +6. **DocList result view** — render documents (id + tags) with row actions. + +## 3. Routes & sidebar + +- `/properties` — overview (exists) — group cards, **New group**. +- `/properties/:group` — group page (exists) — collection rows, filter, **New property**, per-row delete. +- `/properties/:group/:name` — **NEW** collection detail (`PropertyDetailRoute`). +- **Sidebar**: reuse the handoff nav. `sidebar.jsx` is a **generic recursive + `NavRow` tree** (arbitrary depth, `count` badges, collapsed-mode flyout) driven + by a data model — the Properties menu is just a node whose children are groups, + each expanding to its collections (see `sidebar-menu.png`). Today canopy renders + Properties as a **flat link**; the Metadata tree uses a `CatalogNav` component + (section → groups) with the nav-row / `nav-count` / flyout CSS already in place. + + **Structural nuance:** Properties is one level deeper than a Metadata sub-type — + `Properties → group (expandable) → collection (doc-count)`, whereas + `Metadata → Measures → group (leaf, count)`. So `CatalogNav` (section→group-leaf) + isn't a drop-in. + + **Recommended (minimal):** add a small `PropertyNav` subtree modeled on the + handoff `NavRow` shape — groups as expandable rows, each listing its collections + with doc counts — reusing canopy's existing nav CSS. Collection counts come from + the schema list (collection count per group) + a per-collection document count + (a `Query` with `limit`, or a count call). **Alternative (fuller port):** replace + canopy's bespoke `Sidebar` with the handoff generic `NavRow`/`Sidebar` driven by + one `nav` tree covering Metadata + Properties uniformly — DRYer, but a larger + refactor that touches the M2 shell tests, so deferred unless we want the cleanup. + +## 4. API mapping + +### 4a. Collection schema — `database/v1` PropertyRegistryService (`~L807`) + +Endpoints (List/Get already used by `api.ts`; **add Create + Delete**): + +``` +Create POST /api/v1/property/schema body: { property: { metadata:{group,name} [, tags] } } +Delete DELETE /api/v1/property/schema/{group}/{name} +List GET /api/v1/property/schema/lists/{group} (exists) +Get GET /api/v1/property/schema/{group}/{name} (exists) +``` + +Schema-free, so Create's body is minimal (name + group; tag specs optional). + +### 4b. Documents — `property/v1` PropertyService (`~L100`) + +Add to `DataSource` + `ApiDataSource` (`web/src/data/api.ts`); the BFF already +proxies `/api/v1/*` to the liaison. + +``` +Apply (create + update) PUT /api/v1/property/data/{group}/{name}/{id} + body: { property: { metadata:{group,name}, id, tags:[Tag] }, strategy } + → { created: bool, tagsNum: number } +Delete DELETE /api/v1/property/data/{group}/{name}/{id} + → { deleted: bool } +Query POST /api/v1/property/data/query + body: { groups:[group], name, ids?:[], criteria?, tagProjection?:[], + limit?, orderBy?:{tagName,sort}, trace? } + → { properties: [Property], trace? } +``` + +- **Strategy:** `STRATEGY_MERGE` (default — Apply merges/updates tags by key) vs + `STRATEGY_REPLACE` (replace the whole tag set). Edit uses MERGE; a future + "replace" affordance can pass REPLACE. +- **Document shape:** `Property { metadata:{group,name}, id, tags:[{key, value:<typed>}], updatedAt }`. + Reuse the existing `Tag`/`FieldValue` DTOs; tags carry a typed value + (`str`/`int`/`float`/`binary`/`timestamp`) — the tag editor's type dropdown. + +New DTOs in `shared/src/api-dto.ts`: `PropertyDocument`, `PropertyApplyRequest`, +`PropertyQueryRequest`, `PropertyQueryResponse` (replace the `property_result?: unknown` +placeholder). + +## 5. Query builder + code editor reuse (the crux) + +The detail page embeds a **reduced QueryConsole** scoped to one collection +(`property-documents.png`). Reuse the existing components verbatim, parameterized: + +- **FROM** — locked chip `PROPERTY <name> IN <group>` (a `LOCKED` badge; catalog + picker + resource/group selects hidden). Add `'property'` to `QB_CATALOGS` and a + `locked`/`fixedFrom` prop to `QueryBuilder`. +- **SELECT** — tag projection chips (`all tags` / `<tag>` …) → `tagProjection`. +- **WHERE** — **reuse the QueryBuilder WHERE tree as-is** (the recursive AND/OR + + all operators from `where.test.ts`). `id = '…'` maps to the request's `ids`; + other leaves map to `criteria`. `OPTIONAL`. +- **ORDER BY** — one tag + ASC/DESC → `orderBy`. **LIMIT** → `limit`. +- **No TIME clause** (property has no time dimension) — hidden for `property`. +- **Code tab** — reuse `CodeEditor`; `buildBydbQL` gains a PROPERTY branch + emitting `SELECT <tags> FROM PROPERTY <name> IN <group> [WHERE …] [ORDER BY <tag> + ASC|DESC] [LIMIT n]` (matches `test/cases/property/data/input/*.ql`). Builder⇄Code + eject/resync/dirty-warning reused unchanged. + +**Execution — the `property/v1` Query RPC (decided).** Per the API split, document +queries go through the property **Query** RPC, not the generic BydbQL endpoint. +The builder state is translated into a structured `POST /api/v1/property/data/query`: + +| Builder | → Query request field | +|---|---| +| SELECT tag chips | `tagProjection` | +| WHERE `id = '…'` / `id IN (…)` leaves | `ids` | +| WHERE other leaves (the recursive tree) | `criteria` (`model.v1.Criteria`) | +| ORDER BY tag + dir | `orderBy: { tagName, sort }` | +| LIMIT | `limit` | +| (FROM lock) | `groups: [group]`, `name` | + +The **Code tab** shows the generated BydbQL (`buildBydbQL` PROPERTY branch) for +readability/copy; in **v1 the builder is the source of truth and code-mode is +display-only** (a BydbQL→structured parser so code edits execute is a tracked +follow-up). This keeps full builder/editor component reuse while honoring +"use Query for query." Document **writes** always use Apply/Delete. Review Comment: The doc currently says the Code tab is “display-only” in v1, but the implementation executes hand-edited code by parsing it with pqParseCode and then running the structured Query RPC. This is a concrete mismatch that can mislead future changes (e.g., someone might remove pqParseCode execution believing it’s unused). ########## canopy/web/src/query/results/DocList.tsx: ########## @@ -0,0 +1,343 @@ +/* + * Licensed to Apache Software Foundation (ASF) under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Apache Software Foundation (ASF) licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// DocList.tsx — Property document list, "field rows" style (option A of the +// Property Document Styles exploration). One row per tag with an adaptive +// value cell: scalars inline, str values that parse as JSON get an embedded +// highlighted block (pretty <-> raw, copy, expand/collapse), long prose wraps +// with a clamp. BanyanDB has no JSON type — the stored type pill stays str; a +// dashed badge marks detection. The list paginates past DL_PAGE_SIZE. +// Ported from .handoff-import/banyandb/project/doc-list.jsx (window-global +// JSX -> ES module TSX). `RoleContext.canWrite` -> useCanWrite() (AuthContext.js). + +import React from 'react'; +import { useCanWrite } from '../../auth/AuthContext.js'; +import { IconEdit, IconTrash, IconCheck } from '../../components/icons.js'; +import { looksLikeJSON, PROP_VALUE_LABEL } from '../property-util.js'; +import type { PropertyDocument, PropertyDocTag } from 'canopy-shared'; + +const DL_PAGE_SIZE = 10; + +const IconExpand = (p: React.SVGProps<SVGSVGElement>) => ( + <svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"> + <path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3" /> + </svg> +); +const IconCopy = (p: React.SVGProps<SVGSVGElement>) => ( + <svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"> + <rect x="9" y="9" width="12" height="12" rx="2" /> + <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /> + </svg> +); +const IconBraces = (p: React.SVGProps<SVGSVGElement>) => ( + <svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"> + <path d="M8 3a2 2 0 0 0-2 2v3a2 2 0 0 1-2 2 2 2 0 0 1 2 2v3a2 2 0 0 0 2 2M16 3a2 2 0 0 1 2 2v3a2 2 0 0 0 2 2 2 2 0 0 0-2 2v3a2 2 0 0 1-2 2" /> + </svg> +); + +interface HighlightPart { readonly cls: string; readonly s: string; } + +/* ---------------- value detection (presentation, not schema) ---------------- */ +interface Detected { + readonly kind: 'json' | 'text' | 'scalar'; + readonly val: string; + readonly parsed?: unknown; +} +function dlDetect(tag: PropertyDocTag): Detected { + const val = tag.value ?? ''; + if (tag.valueType !== 'str') return { kind: 'scalar', val }; + if (looksLikeJSON(val)) { + try { + return { kind: 'json', val, parsed: JSON.parse(val) as unknown }; + } catch { /* fall through */ } + } + if (val.length > 80 || val.indexOf('\n') !== -1) return { kind: 'text', val }; + return { kind: 'scalar', val }; +} + +/* ---------------- JSON pretty-printer + highlighter ---------------- */ +function dlHighlight(value: unknown): HighlightPart[][] { + const out: HighlightPart[][] = []; + let line: HighlightPart[] = []; + const push = (cls: string, s: string) => line.push({ cls, s }); + const nl = () => { out.push(line); line = []; }; + const pad = (d: number) => push('tok-punc', ' '.repeat(d)); + const walk = (v: unknown, d: number) => { + if (v === null) { push('tok-kw', 'null'); return; } + if (typeof v === 'boolean') { push('tok-kw', String(v)); return; } + if (typeof v === 'number') { push('tok-num', String(v)); return; } + if (typeof v === 'string') { push('tok-str', JSON.stringify(v)); return; } + if (Array.isArray(v)) { + const flat = v.every((x) => typeof x !== 'object' || x === null); + push('tok-punc', '['); + if (v.length && (!flat || JSON.stringify(v).length > 48)) { + nl(); + v.forEach((x, i) => { pad(d + 1); walk(x, d + 1); if (i < v.length - 1) push('tok-punc', ','); nl(); }); + pad(d); + } else { + v.forEach((x, i) => { walk(x, d); if (i < v.length - 1) push('tok-punc', ', '); }); + } + push('tok-punc', ']'); + return; + } + const obj = v as Record<string, unknown>; + const keys = Object.keys(obj); + push('tok-punc', '{'); + if (keys.length) { + nl(); + keys.forEach((k, i) => { + pad(d + 1); + push('tok-key', JSON.stringify(k)); + push('tok-punc', ': '); + const child = obj[k]; + if (child && typeof child === 'object' && !Array.isArray(child) && JSON.stringify(child).length <= 76) { + push('tok-punc', '{ '); + const ck = Object.keys(child as Record<string, unknown>); + ck.forEach((c, ci) => { + push('tok-key', JSON.stringify(c)); + push('tok-punc', ': '); + walk((child as Record<string, unknown>)[c], d + 1); + if (ci < ck.length - 1) push('tok-punc', ', '); + }); + push('tok-punc', ' }'); + } else { + walk(child, d + 1); + } + if (i < keys.length - 1) push('tok-punc', ','); + nl(); + }); + pad(d); + } + push('tok-punc', '}'); + }; + walk(value, 0); + nl(); + return out.filter((l) => l.length); +} + +function DLCode({ lines }: { readonly lines: readonly (readonly HighlightPart[])[] }) { + return ( + <pre className="pd-code"> + {lines.map((parts, i) => ( + <span key={i} className="ln"> + <span className="ln-n">{i + 1}</span> + <span className="ln-c">{parts.map((p, j) => <span key={j} className={p.cls}>{p.s}</span>)}</span> + </span> + ))} + </pre> + ); +} + +/* ---------------- small controls ---------------- */ +function DLCopy({ text }: { readonly text: string }) { + const [state, setState] = React.useState<'ok' | 'fail' | null>(null); + const flash = (s: 'ok' | 'fail') => { setState(s); setTimeout(() => setState(null), 1400); }; + const copy = async () => { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } else { + const ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(ta); + if (!ok) throw new Error('copy rejected'); + } + flash('ok'); + } catch { flash('fail'); } + }; + return ( + <button type="button" className="pd-ibtn" onClick={() => void copy()}> + {state === 'ok' ? <IconCheck size={12} /> : <IconCopy width={12} height={12} />}{' '} + {state === 'ok' ? 'copied' : state === 'fail' ? 'copy failed' : 'copy'} + </button> + ); +} + +function DLDet({ kind }: { readonly kind: 'json' | 'text' }) { + if (kind === 'json') return <span className="pd-det" title="This str parses as JSON — pretty-printed for display">{'{}'} json</span>; + return <span className="pd-det is-text" title="Long text — wrapped for display">¶ text</span>; +} + +/* ---------------- adaptive value cells ---------------- */ +function DLJsonValue({ det }: { readonly det: Detected }) { + const lines = React.useMemo(() => dlHighlight(det.parsed), [det.val]); // eslint-disable-line react-hooks/exhaustive-deps + const rawLines = React.useMemo(() => det.val.split('\n').map((s) => [{ cls: 'tok-punc', s }]), [det.val]); + const big = lines.length > 8; + const [open, setOpen] = React.useState(!big); + const [raw, setRaw] = React.useState(false); + const shown = raw ? rawLines : lines; + return ( + <div className={'fr-block' + (open ? '' : ' is-collapsed')}> + <div className="fr-block-bar"> + <span style={{ color: 'var(--tok-key)' }}><IconBraces width={12} height={12} /></span> + <span className="pd-meta">str · parses as JSON · {lines.length} lines</span> + <span className="pd-gap" /> + <div className="pd-seg" role="group" aria-label="JSON display mode"> + <button type="button" className={raw ? '' : 'is-on'} onClick={() => setRaw(false)}>pretty</button> + <button type="button" className={raw ? 'is-on' : ''} onClick={() => setRaw(true)}>raw</button> + </div> + <DLCopy text={det.val} /> + {big && ( + <button type="button" className="pd-ibtn" onClick={() => setOpen(!open)}> + <IconExpand width={12} height={12} /> {open ? 'collapse' : 'expand'} + </button> + )} + </div> + <div + className="fr-block-body" + onClick={open ? undefined : () => setOpen(true)} + style={open ? undefined : { cursor: 'pointer' }} + title={open ? undefined : 'Click to expand'} + > + <DLCode lines={shown} /> + </div> + </div> + ); +} + +function DLTextValue({ det }: { readonly det: Detected }) { + const paras = det.val.split(/\n+/).filter(Boolean); + const long = det.val.length > 220 || paras.length > 1; + const [open, setOpen] = React.useState(!long); + return ( + <div> + <div className={'fr-prose' + (open ? '' : ' is-clamped')}> + {open ? paras.map((p, i) => <span key={i}>{i > 0 && <><br /><br /></>}{p}</span>) : paras[0]} + </div> + {long && ( + <button type="button" className="fr-more" onClick={() => setOpen(!open)}>{open ? 'collapse ▴' : 'show all ▾'}</button> + )} + </div> + ); +} + +function DLValue({ tag }: { readonly tag: PropertyDocTag }) { + const det = dlDetect(tag); + if (det.kind === 'json') return <DLJsonValue det={det} />; + if (det.kind === 'text') return <DLTextValue det={det} />; + if (tag.valueType === 'int') return <span className="fr-val-int mono">{det.val}</span>; + return <span className="mono">{det.val === '' ? '∅' : det.val}</span>; +} + +/* ---------------- document card ---------------- */ +interface DocCardProps { + readonly entry: PropertyDocument; + readonly projection?: readonly string[]; + readonly groupName: string; + readonly propName: string; + readonly onEdit: () => void; + readonly onDelete: () => void; +} +function DocCard({ entry, projection, groupName, propName, onEdit, onDelete }: DocCardProps) { + const canWrite = useCanWrite(); + const tags = projection && projection.length + ? (entry.tags ?? []).filter((t) => projection.includes(t.key)) + : (entry.tags ?? []); + return ( + <div className="doc-card" data-testid="doc-card"> + <div className="doc-head"> + <span className="doc-id"> + <span className="doc-id-key mono">{groupName}/{propName}/</span> + <span className="doc-id-val mono">{entry.id}</span> + </span> + <span className="doc-actions"> + <span className="doc-tagcount">{(entry.tags ?? []).length} tag{(entry.tags ?? []).length !== 1 ? 's' : ''}</span> + {canWrite && ( + <> + <button type="button" className="rc-act" title="Edit document" onClick={onEdit}><IconEdit size={15} /></button> + <button type="button" className="rc-act is-danger" title="Delete document" onClick={onDelete}><IconTrash size={15} /></button> + </> + )} + </span> + </div> + <div className="fr-rows"> + {tags.map((t, i) => { + const det = dlDetect(t); + return ( + <div key={t.key + i} className="fr-row"> + <span className="fr-keycol"> + <span className="fr-key">{t.key}</span> + <span className={'pd-type' + (t.valueType === 'int' ? ' is-int' : '')}>{PROP_VALUE_LABEL(t.valueType)}</span> + {(det.kind === 'json' || det.kind === 'text') && <DLDet kind={det.kind} />} + </span> + <div className="fr-val"><DLValue tag={t} /></div> Review Comment: After changing DLValue to accept the already-computed `det`, pass it here so dlDetect/JSON.parse isn’t repeated for the same tag. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
