feat(filter): support space placement in saved views (#6267)

This commit is contained in:
Johnny 2026-09-04 09:29:09 +08:00 committed by GitHub
parent 230e3a1d11
commit dfa0fda766
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 47 additions and 8 deletions

View file

@ -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 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. 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.

View file

@ -96,8 +96,15 @@ func TestRenderSpaceFilters(t *testing.T) {
require.Empty(t, unassigned.Args, schema.Name, dialect) require.Empty(t, unassigned.Args, schema.Name, dialect)
} }
_, err = engine.Compile(context.Background(), `space != null`) for _, dialect := range []DialectName{DialectSQLite, DialectMySQL, DialectPostgres} {
require.ErrorContains(t, err, `operator != not allowed for field "space"`) 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")
} }
} }

View file

@ -162,7 +162,7 @@ func buildComparisonCondition(call *exprv1.Expr_Call, pc parseContext) (Conditio
} }
} }
if def.AllowedComparisonOps != nil { 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) 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 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
}

View file

@ -340,7 +340,8 @@ message ListMemosRequest {
// content (string), creator (string, e.g. "users/1"), // content (string), creator (string, e.g. "users/1"),
// created_ts / updated_ts (timestamp), pinned (bool), // created_ts / updated_ts (timestamp), pinned (bool),
// visibility (string: PRIVATE | PROTECTED | PUBLIC | SPACE), // 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"`), // tags (list<string>; match with `"work" in tags`, not `tag == "work"`),
// has_task_list / has_link / has_code / has_incomplete_tasks (bool), // has_task_list / has_link / has_code / has_incomplete_tasks (bool),
// has_location (bool; true when the memo has a location attached). // has_location (bool; true when the memo has a location attached).

View file

@ -556,7 +556,8 @@ type ListMemosRequest struct {
// content (string), creator (string, e.g. "users/1"), // content (string), creator (string, e.g. "users/1"),
// created_ts / updated_ts (timestamp), pinned (bool), // created_ts / updated_ts (timestamp), pinned (bool),
// visibility (string: PRIVATE | PROTECTED | PUBLIC | SPACE), // 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"`), // tags (list<string>; match with `"work" in tags`, not `tag == "work"`),
// has_task_list / has_link / has_code / has_incomplete_tasks (bool), // has_task_list / has_link / has_code / has_incomplete_tasks (bool),
// has_location (bool; true when the memo has a location attached). // has_location (bool; true when the memo has a location attached).

View file

@ -667,7 +667,8 @@ paths:
content (string), creator (string, e.g. "users/1"), content (string), creator (string, e.g. "users/1"),
created_ts / updated_ts (timestamp), pinned (bool), created_ts / updated_ts (timestamp), pinned (bool),
visibility (string: PRIVATE | PROTECTED | PUBLIC | SPACE), 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"`), tags (list<string>; match with `"work" in tags`, not `tag == "work"`),
has_task_list / has_link / has_code / has_incomplete_tasks (bool), has_task_list / has_link / has_code / has_incomplete_tasks (bool),
has_location (bool; true when the memo has a location attached). has_location (bool; true when the memo has a location attached).

View file

@ -2,6 +2,7 @@ import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"; import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { import {
AstroidIcon,
CheckCircle2Icon, CheckCircle2Icon,
ClipboardCheckIcon, ClipboardCheckIcon,
Clock3Icon, Clock3Icon,
@ -77,6 +78,18 @@ const memoViewExamples = [
description: "Match hierarchical tags by prefix.", description: "Match hierarchical tags by prefix.",
icon: TagsIcon, 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", title: "Open tasks",
filter: "has_task_list && has_incomplete_tasks", filter: "has_task_list && has_incomplete_tasks",
@ -164,6 +177,9 @@ const filterFields = [
"content.matches(...)", "content.matches(...)",
"visibility", "visibility",
"pinned", "pinned",
"space == null",
"space != null",
'space == "spaces/..."',
"tag in [...]", "tag in [...]",
"tags.exists(...)", "tags.exists(...)",
"tags.all(...)", "tags.all(...)",

View file

@ -358,7 +358,8 @@ export type ListMemosRequest = Message<"memos.api.v1.ListMemosRequest"> & {
* content (string), creator (string, e.g. "users/1"), * content (string), creator (string, e.g. "users/1"),
* created_ts / updated_ts (timestamp), pinned (bool), * created_ts / updated_ts (timestamp), pinned (bool),
* visibility (string: PRIVATE | PROTECTED | PUBLIC | SPACE), * 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"`), * tags (list<string>; match with `"work" in tags`, not `tag == "work"`),
* has_task_list / has_link / has_code / has_incomplete_tasks (bool), * has_task_list / has_link / has_code / has_incomplete_tasks (bool),
* has_location (bool; true when the memo has a location attached). * has_location (bool; true when the memo has a location attached).