feat(filter): support space placement in saved views (#6267)
This commit is contained in:
parent
230e3a1d11
commit
dfa0fda766
8 changed files with 47 additions and 8 deletions
|
|
@ -162,7 +162,7 @@ Space responses returned through membership-authorized operations include the au
|
|||
|
||||
Memo responses gain an optional Space resource name, and `Visibility` adds `SPACE = 4` without renumbering existing values. The domain-to-v1 mapping is Author to `PRIVATE`, Instance to `PROTECTED`, Public to `PUBLIC`, and Space to `SPACE`. `VISIBILITY_UNSPECIFIED` remains an input sentinel: create treats it as `PRIVATE`, an explicit visibility update rejects it, and responses never return it. Visibility values are named domains and must not be compared numerically. The global default memo visibility setting continues to accept only `PRIVATE`, `PROTECTED`, and `PUBLIC`, because it cannot identify a Space.
|
||||
|
||||
Memo listing gains explicit all-readable, Unassigned, and Space scopes. Space scope requires active membership. Existing global and Space feeds exclude comments by default, and Space identity is not added to the CEL filter schema.
|
||||
Memo listing expresses placement through the CEL filter: `space == "spaces/{space}"` selects one Space, `space == null` selects Unassigned memos, and `space != null` selects every placed memo. Membership is enforced by the read policy, which the filter can only narrow. Saved views accept the same expressions. Existing global and Space feeds exclude comments by default.
|
||||
|
||||
Placement and audience use the existing memo update mechanism so the memo author can change them atomically. The Space API does not add an operation for an `ADMIN` to move, withdraw, or otherwise mutate an individual memo.
|
||||
|
||||
|
|
|
|||
|
|
@ -96,8 +96,15 @@ func TestRenderSpaceFilters(t *testing.T) {
|
|||
require.Empty(t, unassigned.Args, schema.Name, dialect)
|
||||
}
|
||||
|
||||
_, err = engine.Compile(context.Background(), `space != null`)
|
||||
require.ErrorContains(t, err, `operator != not allowed for field "space"`)
|
||||
for _, dialect := range []DialectName{DialectSQLite, DialectMySQL, DialectPostgres} {
|
||||
assigned, err := engine.CompileToStatement(context.Background(), `space != null`, RenderOptions{Dialect: dialect})
|
||||
require.NoError(t, err, schema.Name, dialect)
|
||||
require.Contains(t, assigned.SQL, "IS NOT NULL", schema.Name, dialect)
|
||||
require.Empty(t, assigned.Args, schema.Name, dialect)
|
||||
}
|
||||
|
||||
_, err = engine.Compile(context.Background(), `space != "spaces/team"`)
|
||||
require.ErrorContains(t, err, `operator != not allowed for field "space"`, "NULL != value would silently drop unassigned memos")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ func buildComparisonCondition(call *exprv1.Expr_Call, pc parseContext) (Conditio
|
|||
}
|
||||
}
|
||||
if def.AllowedComparisonOps != nil {
|
||||
if _, allowed := def.AllowedComparisonOps[op]; !allowed {
|
||||
if _, allowed := def.AllowedComparisonOps[op]; !allowed && !isNullComplement(def, op, right) {
|
||||
return nil, errors.Errorf("operator %s not allowed for field %q", op, field.Name)
|
||||
}
|
||||
}
|
||||
|
|
@ -1018,3 +1018,15 @@ func buildContainsPredicate(call *exprv1.Expr_Call, iterVar string) (PredicateEx
|
|||
|
||||
return &ContainsPredicate{Substring: substringStr}, nil
|
||||
}
|
||||
|
||||
// isNullComplement reports whether op is a `!= null` test on a field that
|
||||
// permits `== null`. IS NOT NULL is the exact complement of IS NULL, so it is
|
||||
// safe even where `!=` against a value is rejected because SQL null semantics
|
||||
// would silently drop rows.
|
||||
func isNullComplement(def Field, op ComparisonOperator, right ValueExpr) bool {
|
||||
if op != CompareNeq || !def.AllowedComparisonOps[CompareEq] {
|
||||
return false
|
||||
}
|
||||
lit, ok := right.(*LiteralValue)
|
||||
return ok && lit.Value == nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -340,7 +340,8 @@ message ListMemosRequest {
|
|||
// content (string), creator (string, e.g. "users/1"),
|
||||
// created_ts / updated_ts (timestamp), pinned (bool),
|
||||
// visibility (string: PRIVATE | PROTECTED | PUBLIC | SPACE),
|
||||
// space (string resource name or null when the memo has no space),
|
||||
// space (string resource name, or null when the memo has no space;
|
||||
// supports == and comparisons against null, e.g. space != null),
|
||||
// tags (list<string>; match with `"work" in tags`, not `tag == "work"`),
|
||||
// has_task_list / has_link / has_code / has_incomplete_tasks (bool),
|
||||
// has_location (bool; true when the memo has a location attached).
|
||||
|
|
|
|||
|
|
@ -556,7 +556,8 @@ type ListMemosRequest struct {
|
|||
// content (string), creator (string, e.g. "users/1"),
|
||||
// created_ts / updated_ts (timestamp), pinned (bool),
|
||||
// visibility (string: PRIVATE | PROTECTED | PUBLIC | SPACE),
|
||||
// space (string resource name or null when the memo has no space),
|
||||
// space (string resource name, or null when the memo has no space;
|
||||
// supports == and comparisons against null, e.g. space != null),
|
||||
// tags (list<string>; match with `"work" in tags`, not `tag == "work"`),
|
||||
// has_task_list / has_link / has_code / has_incomplete_tasks (bool),
|
||||
// has_location (bool; true when the memo has a location attached).
|
||||
|
|
|
|||
|
|
@ -667,7 +667,8 @@ paths:
|
|||
content (string), creator (string, e.g. "users/1"),
|
||||
created_ts / updated_ts (timestamp), pinned (bool),
|
||||
visibility (string: PRIVATE | PROTECTED | PUBLIC | SPACE),
|
||||
space (string resource name or null when the memo has no space),
|
||||
space (string resource name, or null when the memo has no space;
|
||||
supports == and comparisons against null, e.g. space != null),
|
||||
tags (list<string>; match with `"work" in tags`, not `tag == "work"`),
|
||||
has_task_list / has_link / has_code / has_incomplete_tasks (bool),
|
||||
has_location (bool; true when the memo has a location attached).
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { create } from "@bufbuild/protobuf";
|
|||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AstroidIcon,
|
||||
CheckCircle2Icon,
|
||||
ClipboardCheckIcon,
|
||||
Clock3Icon,
|
||||
|
|
@ -77,6 +78,18 @@ const memoViewExamples = [
|
|||
description: "Match hierarchical tags by prefix.",
|
||||
icon: TagsIcon,
|
||||
},
|
||||
{
|
||||
title: "Unassigned",
|
||||
filter: "space == null",
|
||||
description: "Memos that are not placed in any space.",
|
||||
icon: AstroidIcon,
|
||||
},
|
||||
{
|
||||
title: "In one space",
|
||||
filter: 'space == "spaces/your-space-id"',
|
||||
description: "Memos placed in a space. Use the space ID from its URL or settings.",
|
||||
icon: AstroidIcon,
|
||||
},
|
||||
{
|
||||
title: "Open tasks",
|
||||
filter: "has_task_list && has_incomplete_tasks",
|
||||
|
|
@ -164,6 +177,9 @@ const filterFields = [
|
|||
"content.matches(...)",
|
||||
"visibility",
|
||||
"pinned",
|
||||
"space == null",
|
||||
"space != null",
|
||||
'space == "spaces/..."',
|
||||
"tag in [...]",
|
||||
"tags.exists(...)",
|
||||
"tags.all(...)",
|
||||
|
|
|
|||
|
|
@ -358,7 +358,8 @@ export type ListMemosRequest = Message<"memos.api.v1.ListMemosRequest"> & {
|
|||
* content (string), creator (string, e.g. "users/1"),
|
||||
* created_ts / updated_ts (timestamp), pinned (bool),
|
||||
* visibility (string: PRIVATE | PROTECTED | PUBLIC | SPACE),
|
||||
* space (string resource name or null when the memo has no space),
|
||||
* space (string resource name, or null when the memo has no space;
|
||||
* supports == and comparisons against null, e.g. space != null),
|
||||
* tags (list<string>; match with `"work" in tags`, not `tag == "work"`),
|
||||
* has_task_list / has_link / has_code / has_incomplete_tasks (bool),
|
||||
* has_location (bool; true when the memo has a location attached).
|
||||
|
|
|
|||
Loading…
Reference in a new issue