Skip to content

feat(database): a column of the linked rows that pass a condition - #15633

Open
ThanhDangNgo wants to merge 4 commits into
toeverything:canaryfrom
ThanhDangNgo:feat/database-relation-filter
Open

ThanhDangNgo wants to merge 4 commits into
toeverything:canaryfrom
ThanhDangNgo:feat/database-relation-filter

Conversation

@ThanhDangNgo

@ThanhDangNgo ThanhDangNgo commented Sep 21, 2026 •

Copy link
Copy Markdown

Stacked on #15630, #15631 and #15632. Their commits appear here too; only the last commit, feat(database): a column of the linked rows that pass a condition, is new.

A rollup answers how many of these are done. This answers which ones, rendering the linked rows that satisfy a condition as chips instead of reducing them to a number — a "Finished" column lists the completed tasks rather than counting them.

It walks the same chain a rollup does and understands rollups along the way, so a filter can sit above one. Derived and registered in spacialProperties for the same reasons as the others.

Try it in about thirty seconds

Open any doc, add one database (slash menu → Table View), then paste this into the browser console:

Demo script — a Finished column listing the done tasks as chips
/*
 * Demo for: feat(database): a column of the linked rows that pass a condition
 *
 * A rollup answers "how many of these are done". This answers "which ones".
 * Builds Projects and Tasks, then a Finished column on Projects that lists
 * the completed tasks as chips instead of counting them.
 *
 * Run it: open a doc, add one database (slash menu -> Table View), paste.
 */
(async () => {
  const editor = globalThis.currentEditor ?? globalThis.editor;
  if (!editor) throw new Error('Open a doc first: no editor on globalThis');
  const store = editor.host.std.store;
  const seed = document.querySelector('affine-database');
  if (!seed?.dataSource) {
    throw new Error('Add one database to this doc first (slash menu -> Table View)');
  }
  const DataSource = seed.dataSource.constructor;
  const note = store.getModelsByFlavour('affine:note')[0];
  if (!note) throw new Error('No note block on this page');
  const ds = model => new DataSource(model);

  const makeTable = (title, rowTitles) => {
    const id = store.addBlock('affine:database', {}, note.id);
    const model = store.getBlock(id).model;
    const d = ds(model);
    rowTitles.forEach(() => d.rowAdd('end'));
    d.viewManager.viewAdd('table');
    model.props.title.replace(0, model.props.title.length, title);
    rowTitles.forEach((t, i) => {
      const row = model.children[i];
      if (row?.text) row.text.replace(0, row.text.length, t);
    });
    return model;
  };
  const addProp = (model, type, name, data) => {
    const d = ds(model);
    const id = d.propertyAdd('end', { type, name });
    if (data) d.propertyDataSet(id, data);
    return id;
  };

  const Projects = makeTable('Projects', [
    'Website redesign',
    'Mobile app',
    'Search rewrite',
  ]);
  const Tasks = makeTable('Tasks', [
    'Audit current pages',
    'Draft new layout',
    'Ship the style guide',
    'Pick a chart library',
    'Wire up push notifications',
    'Benchmark the index',
  ]);

  const taskProject = addProp(Tasks, 'relation', 'Project', {
    targetDatabaseId: Projects.id,
  });
  const projectTasks = addProp(Projects, 'relation-reverse', 'Tasks', {
    sourceDatabaseId: Tasks.id,
    sourcePropertyId: taskProject,
  });
  const taskDone = addProp(Tasks, 'checkbox', 'Done');

  const dTasks = ds(Tasks);
  const P = i => Projects.children[i].id;
  [[0], [0], [0], [1], [1], [2]].forEach((p, i) =>
    dTasks.cellValueChange(Tasks.children[i].id, taskProject, p.map(P))
  );
  [true, true, false, true, false, false].forEach((done, i) =>
    dTasks.cellValueChange(Tasks.children[i].id, taskDone, done)
  );

  // Same relation the reverse column walks, narrowed to the checked rows.
  const finished = addProp(Projects, 'relation-filter', 'Finished', {
    relationPropertyId: projectTasks,
    targetPropertyId: taskDone,
    condition: 'checked',
    threshold: 100,
  });

  console.log(
    'Built. What to look at:\n' +
      '  1. Projects has Tasks (all of them) and Finished (only the checked\n' +
      '     ones), as chips rather than a number.\n' +
      '  2. Tick or untick a task: the chip appears and disappears from\n' +
      '     Finished immediately, with nothing stored on Projects.\n' +
      '  3. Switch the condition on the column to unchecked, or to one of the\n' +
      '     numeric ones with a threshold.\n' +
      '  4. Each chip carries the target row\'s icon and title, the same\n' +
      '     rendering the plain relation column uses.'
  );
  return { projects: Projects.id, tasks: Tasks.id, finished };
})();

Tick or untick a task and the chip appears or disappears immediately, with nothing stored on the parent. The condition can be switched to unchecked, or to one of the numeric ones with a threshold.

Notes

  • Each chip carries the target row's icon and title, the same rendering the plain relation column uses.
  • yarn typecheck gives the same error count as canary does on this machine (a clean, cache-cleared run on both).
  • Written with AI assistance (Claude); the design decisions, review and testing are mine.

Summary by CodeRabbit

  • New Features
    • Added database icon columns with emoji and custom icon support, including icon pickers for rows and title cells.
    • Added relation and reverse-relation columns for linking rows across databases.
    • Added rollup columns with count, sum, average, minimum, maximum, and percentage calculations, shown as values or progress bars.
    • Added relation-filter columns for displaying linked rows matching conditions.
  • Improvements
    • Linked icons, relations, filters, and rollups now update consistently across cells, sorting, filtering, and summaries.
    • Improved title-cell icon alignment and presentation.

A database row is a page, and every page in AFFiNE has an icon — except in
the table, where the title cell always draws the block's own glyph. This
adds an `icon` property so a row can carry its own, and renders it where a
page icon belongs: inside the title cell, not in a column of its own.

`spacialProperties.type` is what the title cell draws before the text, so
that is where the override goes. It returns the row's icon when one is set
and falls back to the block glyph otherwise, which means a table nobody has
decorated looks exactly as it did before, and every view reading
`iconColumn` — table, kanban, the detail panel — picks the emoji up with no
further work. Clicking it opens the editor's own icon picker.

The picker element is borrowed rather than copied: the callout block already
owns the `icon-picker-wrapper` tag, and two classes cannot claim one tag
name.

`utils/database-lookup.ts` is new here because the title cell needs to find
the database a row belongs to. It holds only store-level lookups —
`getDatabases`, `getDatabase`, `findDatabaseContainingRow`, `getColumn` —
next to the existing `block-utils.ts`, which works inside a single database.
@coderabbitai

coderabbitai Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Understand this PR’s impact

Explore downstream dependencies and potential security impact with Blast Radius.

View blast radius →

📝 Walkthrough

Walkthrough

Changes

Database property extensions

Layer / File(s) Summary
Icon property and database lookup
blocksuite/affine/blocks/database/src/properties/icon/*, blocksuite/affine/blocks/database/src/utils/database-lookup.ts, blocksuite/affine/blocks/database/src/properties/title/*
Adds icon storage, rendering, picker behavior, database lookup helpers, and title-cell icon selection.
Relation properties
blocksuite/affine/blocks/database/src/properties/relation/*
Adds forward and reverse relation models, linked-row resolution, reverse-property creation, relation chips, and editable relation cells.
Derived relation properties
blocksuite/affine/blocks/database/src/properties/relation-filter/*, blocksuite/affine/blocks/database/src/properties/rollup/*
Adds relation-filter and rollup configuration, computation, and rendering.
Database integration
blocksuite/affine/blocks/database/src/data-source.ts, blocksuite/affine/blocks/database/src/effects.ts, blocksuite/affine/blocks/database/src/properties/index.ts
Registers the new cell elements and property configurations. Data-source reads now expose row icons and derived relation values.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant RelationCell
  participant resolveLinkedRows
  participant DatabaseBlockModel
  participant TargetDatabase
  User->>RelationCell: configure relation or select rows
  RelationCell->>resolveLinkedRows: resolve relation property
  resolveLinkedRows->>DatabaseBlockModel: read relation metadata and row IDs
  resolveLinkedRows->>TargetDatabase: resolve live linked rows
  TargetDatabase-->>RelationCell: return linked row models
  RelationCell-->>User: render relation chips
Loading

Suggested reviewers: akumatus

Merge Risk: 🟡 Moderate · up to 96bd7

Icon editing can lose forward-compatible data and exclude keyboard-only users, while relation and rollup edge cases can show stale links or degrade database responsiveness. Resolve these issues before merging unless the risks are explicitly accepted.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 25 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: adding a database column that displays linked rows matching a condition.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 3b083a6c-d87b-43f1-bfd8-f72c299ed157

📥 Commits

Reviewing files that changed from the base of the PR and between d897bb3 and b71f243.

📒 Files selected for processing (25)
  • blocksuite/affine/blocks/database/src/data-source.ts
  • blocksuite/affine/blocks/database/src/effects.ts
  • blocksuite/affine/blocks/database/src/properties/icon/cell-renderer-css.ts
  • blocksuite/affine/blocks/database/src/properties/icon/cell-renderer.ts
  • blocksuite/affine/blocks/database/src/properties/icon/define.ts
  • blocksuite/affine/blocks/database/src/properties/icon/open-picker.ts
  • blocksuite/affine/blocks/database/src/properties/icon/read.ts
  • blocksuite/affine/blocks/database/src/properties/icon/render.ts
  • blocksuite/affine/blocks/database/src/properties/index.ts
  • blocksuite/affine/blocks/database/src/properties/relation-filter/cell-renderer.ts
  • blocksuite/affine/blocks/database/src/properties/relation-filter/compute.ts
  • blocksuite/affine/blocks/database/src/properties/relation-filter/define.ts
  • blocksuite/affine/blocks/database/src/properties/relation/cell-renderer-css.ts
  • blocksuite/affine/blocks/database/src/properties/relation/cell-renderer.ts
  • blocksuite/affine/blocks/database/src/properties/relation/chips.ts
  • blocksuite/affine/blocks/database/src/properties/relation/define.ts
  • blocksuite/affine/blocks/database/src/properties/relation/resolve.ts
  • blocksuite/affine/blocks/database/src/properties/relation/reverse-define.ts
  • blocksuite/affine/blocks/database/src/properties/rollup/cell-renderer-css.ts
  • blocksuite/affine/blocks/database/src/properties/rollup/cell-renderer.ts
  • blocksuite/affine/blocks/database/src/properties/rollup/compute.ts
  • blocksuite/affine/blocks/database/src/properties/rollup/define.ts
  • blocksuite/affine/blocks/database/src/properties/title/cell-renderer-css.ts
  • blocksuite/affine/blocks/database/src/properties/title/text.ts
  • blocksuite/affine/blocks/database/src/utils/database-lookup.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review.

Comment on lines +29 to +41
if (this.closePicker) {
this.closePicker();
this.closePicker = undefined;
return;
}
this.closePicker = openIconPicker(
this.std,
(event.currentTarget as HTMLElement) ?? this,
icon => {
this.valueSetImmediate(icon ? (icon as unknown as IconValue) : null);
this.selectCurrentCell(false);
}
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear closePicker when the picker closes.

After selection or an outside click, openIconPicker removes the wrapper but closePicker remains assigned. The next click calls the stale close function and returns without opening the picker.

Add a close notification to openIconPicker, or clear this state from every close path.

Comment on lines +41 to +46
toString: ({ value }) => value?.unicode ?? '',
fromString: ({ value }) => ({
value: value ? { type: 'emoji', unicode: value } : null,
}),
toJson: ({ value }) => value?.unicode ?? '',
fromJson: ({ value }) => (value ? { type: 'emoji', unicode: value } : null),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve AFFiNE icons during JSON serialization.

The picker can store { type: 'affine-icon', name, color }, but toJson writes only unicode. It therefore serializes an AFFiNE icon as an empty string. fromJson also reconstructs every non-empty value as an emoji.

Use a JSON schema and conversion that preserve the complete IconValue, or restrict the stored value to emoji data.

Comment on lines +119 to +142
private readonly setTargetDatabase = (e: Event) => {
const targetDatabaseId = (e.target as HTMLSelectElement).value;
this.property.dataUpdate(() => ({ targetDatabaseId }));
// Rows of the old target mean nothing under the new one.
this.valueSetImmediate([]);

// Give the other database its half straight away. A relation people have
// to wire twice is one they will wire inconsistently.
const store = this.std?.store;
const own = this.ownDatabase$.value;
if (!store || !own) return;
const target = getDatabase(store, targetDatabaseId);
if (!target) return;
ensureReverseProperty(
own,
this.cell.propertyId,
target,
(db, name, data) => {
const dataSource = this.view.manager.dataSource;
const meta = dataSource.propertyMetaGet(REVERSE_RELATION_TYPE);
if (!meta) return;
addProperty(db, 'end', { ...meta.create(name), data });
}
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '30,125p' blocksuite/affine/blocks/database/src/properties/relation/resolve.ts
sed -n '105,155p' blocksuite/affine/blocks/database/src/properties/relation/cell-renderer.ts
rg -n "relation-reverse|ensureReverseProperty|propertyDelete|deleteProperty|removeProperty" blocksuite/affine/blocks/database/src

Repository: toeverything/AFFiNE

Length of output: 6165


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- property deletion implementation ---'
sed -n '410,480p' blocksuite/affine/blocks/database/src/data-source.ts
printf '%s\n' '--- relation files and tests ---'
fd -i 'relation|database.*test|property.*test' blocksuite/affine/blocks/database --type f | sort | head -200
printf '%s\n' '--- reverse/relation lifecycle references ---'
rg -n -C 4 "REVERSE_RELATION_TYPE|relation-reverse|ensureReverseProperty|propertyDelete\\(|addProperty\\(" blocksuite/affine/blocks/database --glob '!**/node_modules/**' --glob '!**/dist/**' | head -400

Repository: toeverything/AFFiNE

Length of output: 24744


🏁 Script executed:

#!/bin/bash
set -e
sed -n '410,480p' blocksuite/affine/blocks/database/src/data-source.ts
fd -i 'relation|database.*test|property.*test' blocksuite/affine/blocks/database --type f | sort | head -200
rg -n -C 4 "REVERSE_RELATION_TYPE|relation-reverse|ensureReverseProperty|propertyDelete\\(|addProperty\\(" blocksuite/affine/blocks/database --glob '!**/node_modules/**' --glob '!**/dist/**' | head -400

Repository: toeverything/AFFiNE

Length of output: 24624


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- relation renderer value update context ---'
sed -n '1,190p' blocksuite/affine/blocks/database/src/properties/relation/cell-renderer.ts
printf '%s\n' '--- valueSetImmediate definitions and uses ---'
rg -n -C 5 "valueSetImmediate|dataUpdate\\(" blocksuite/affine/blocks/database/src blocksuite/affine | head -300

Repository: toeverything/AFFiNE

Length of output: 35836


Remove the old reverse property when the target changes.

When targetDatabaseId changes or is cleared, this handler does not remove the reverse property from the previous target. The previous database keeps a visible relation-reverse column that still scans the source relation. Because valueSetImmediate([]) clears only the current cell, that column can expose stale links from other source rows.

Capture the previous target before dataUpdate. During the target change, remove the reverse property matching both the source database and this.cell.propertyId through propertyDelete. Do not remove it when the target remains unchanged.

Comment on lines +104 to +106
const values = rowIds.map(id =>
readValue(store, target, id, targetPropertyId, depth + 1, nextSeen)
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

🔎 Supported by static analysis

🏁 Script executed:

sed -n '43,125p' blocksuite/affine/blocks/database/src/properties/rollup/compute.ts
sed -n '105,145p' blocksuite/affine/blocks/database/src/data-source.ts
rg -n "computeRollup|MAX_RELATION_DEPTH" blocksuite/affine/blocks/database/src

Repository: toeverything/AFFiNE

Length of output: 5594


🏁 Script executed:

set -e
printf '%s\n' '--- relation resolver ---'
cat -n blocksuite/affine/blocks/database/src/properties/relation/resolve.ts
printf '%s\n' '--- rollup implementation ---'
cat -n blocksuite/affine/blocks/database/src/properties/rollup/compute.ts
printf '%s\n' '--- relation-filter implementation ---'
cat -n blocksuite/affine/blocks/database/src/properties/relation-filter/compute.ts
printf '%s\n' '--- related definitions and call sites ---'
rg -n -C 3 "resolveLinkedRows|type Linked|interface Linked|rowIds|relationPropertyId|targetPropertyId|valueGet\\(" blocksuite/affine/blocks/database/src/properties blocksuite/affine/blocks/database/src | head -240
printf '%s\n' '--- cache and tests ---'
rg -n -i "memo|cache|computeRollup|rollup" blocksuite/affine/blocks/database/src blocksuite/affine/blocks/database/test blocksuite/affine/blocks/database/tests 2>/dev/null | head -260

Repository: toeverything/AFFiNE

Length of output: 44508


🏁 Script executed:

set -e
cat -n blocksuite/affine/blocks/database/src/properties/relation/resolve.ts
cat -n blocksuite/affine/blocks/database/src/properties/relation-filter/compute.ts
rg -n -C 3 "resolveLinkedRows|computeRollup|rowIds|relationPropertyId|targetPropertyId" blocksuite/affine/blocks/database/src/properties blocksuite/affine/blocks/database/src | head -260
rg -n -i "memo|cache|computeRollup|rollup" blocksuite/affine/blocks/database/src blocksuite/affine/blocks/database/test blocksuite/affine/blocks/database/tests 2>/dev/null | head -260

Repository: toeverything/AFFiNE

Length of output: 40621


Memoize repeated non-count rollup nodes within one traversal.

When linked rows overlap across a rollup-of-rollup chain, seen prevents cycles but does not reuse completed values. The same { database, row, property } node can therefore be evaluated repeatedly. With fan-out b, this can produce O(b⁸) recursive calls before MAX_RELATION_DEPTH stops traversal and can make derived editor reads expensive.

Use a traversal-local memo, but do not key it only by the node key. The result can also depend on depth and the path-local seen set when a descendant reaches an ancestor. Cache only results that are independent of that path context, or include the relevant context in the memo key while retaining seen for cycle detection.

AFFiNE's database block has no way to point one table at another, so a
structure whose levels depend on each other cannot be expressed in it.
Issue toeverything#14068 asks for exactly this and is still open.

This adds two property types. `relation` is stored: an array of row ids
pointing into another database on the same page, rendered as chips that
carry each target row's icon and title. `relation-reverse` is its other
half and stores nothing — it scans the owning side for rows that point
back. Creating a relation creates the reverse property on the target
automatically, so linking two databases gives both directions at once
instead of leaving the second to be wired by hand.

The reverse side is derived rather than stored on purpose. Two stored
arrays can drift apart; one array with a computed mirror cannot. It is
registered in `spacialProperties`, the mechanism upstream already uses for
`created-time` and `title`, which routes it through `cellValueGet` — so
filters, sorts and the column footer see the same value the cell shows, and
the signals read while resolving make it re-evaluate on its own. Storing it
would have meant inventing an invalidation scheme and getting it wrong.

Deleted rows are dropped when resolving rather than left to each caller,
and `findOrphanIds` reports ids that no longer resolve so the editor can
show them.
With relations in place, a column can now aggregate a property of the rows
it reaches: count, countChecked, percentChecked, sum, avg, min, max. It
renders as a number, or as a filled bar when the result is a percentage.

A rollup may target another rollup, which is what lets a figure climb
several levels — Tasks to Goals to Yearly Goals to Pillars, four hops,
each recomputed the moment a checkbox changes. Following a chain means a
cycle is possible (A rolls up B rolls up A), so the walk carries a visited
set and stops at `MAX_RELATION_DEPTH` rather than hanging the editor.

Like `relation-reverse`, this is derived and registered in
`spacialProperties`, so filters, sorts and the column footer see the same
value the cell shows and the signals read while computing make it
re-evaluate on its own.

`ROLLUP_TYPE` sits in `compute.ts` rather than `define.ts` so that code
which only needs to recognise a rollup while walking a chain does not pull
in the renderer and its lit dependencies.
A rollup answers "how many of these are done". This answers "which ones",
rendering the linked rows that satisfy a condition as chips instead of
reducing them to a number — a "Done Tasks" column lists the finished tasks
rather than counting them.

It walks the same chain a rollup does and understands rollups along the
way, so a filter can sit above one. Derived and registered in
`spacialProperties` for the same reasons as the others.
@ThanhDangNgo
ThanhDangNgo force-pushed the feat/database-relation-filter branch from b71f243 to 96bd732 Compare September 21, 2026 16:35

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: a6a78459-b180-49a3-a587-a390134db027

📥 Commits

Reviewing files that changed from the base of the PR and between b71f243 and 96bd732.

📒 Files selected for processing (3)
  • blocksuite/affine/blocks/database/src/properties/icon/cell-renderer.ts
  • blocksuite/affine/blocks/database/src/properties/icon/define.ts
  • blocksuite/affine/blocks/database/src/properties/icon/open-picker.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 2 remain after this review.

Comment on lines +65 to +68
return html`<span
class="${glyph ? iconGlyphStyle : iconEmptyStyle}"
@click="${this.openPicker}"
>${glyph ?? '+'}</span

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1,110p' blocksuite/affine/blocks/database/src/properties/icon/cell-renderer.ts
rg -n "class BaseCellRenderer|keydown|Enter|Space|selectCurrentCell|beforeEnterEditingMode" blocksuite/affine/data-view/src blocksuite/affine/blocks/database/src | head -240

Repository: toeverything/AFFiNE

Length of output: 16789


🏁 Script executed:

sed -n '1,180p' blocksuite/affine/data-view/src/core/property/base-cell.ts
printf '\n--- table pc cell ---\n'
sed -n '1,180p' blocksuite/affine/data-view/src/view-presets/table/pc/cell.ts
printf '\n--- table virtual cell ---\n'
sed -n '1,210p' blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/cell.ts
printf '\n--- kanban pc cell ---\n'
sed -n '1,155p' blocksuite/affine/data-view/src/view-presets/kanban/pc/cell.ts
printf '\n--- table hotkeys ---\n'
sed -n '1,190p' blocksuite/affine/data-view/src/view-presets/table/pc/controller/hotkeys.ts
printf '\n--- virtual hotkeys ---\n'
sed -n '1,190p' blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/hotkeys.ts

Repository: toeverything/AFFiNE

Length of output: 30608


🏁 Script executed:

sed -n '1,180p' blocksuite/affine/data-view/src/core/property/base-cell.ts
sed -n '1,180p' blocksuite/affine/data-view/src/view-presets/table/pc/cell.ts
sed -n '1,210p' blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/cell.ts
sed -n '1,155p' blocksuite/affine/data-view/src/view-presets/kanban/pc/cell.ts
sed -n '1,190p' blocksuite/affine/data-view/src/view-presets/table/pc/controller/hotkeys.ts
sed -n '1,190p' blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/hotkeys.ts

Repository: toeverything/AFFiNE

Length of output: 30485


Make the icon picker trigger keyboard operable.

Keyboard interaction can select the containing cell and enter editing mode, but it does not invoke IconCell.openPicker. The span itself has only a click handler and is not focusable. Use a labeled button, or add button semantics with tabindex="0" and Enter/Space handling.

Comment on lines +11 to +18
export const IconValueSchema = zod
.object({
type: zod.string(),
unicode: zod.string().optional(),
name: zod.string().optional(),
color: zod.string().optional(),
})
.nullable();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

rg -n "IconValueSchema|encodeIconValue|decodeIconValue|IconData|affine-icon|iconPickerComponent" blocksuite/affine packages 2>/dev/null | head -200
sed -n '1,100p' blocksuite/affine/blocks/database/src/properties/icon/define.ts

Repository: toeverything/AFFiNE

Length of output: 23438


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- picker type and producers ---'
cat -n packages/frontend/component/src/ui/icon-picker/type.ts
sed -n '1,240p' packages/frontend/component/src/ui/icon-picker/picker/affine-icon/affine-icon-picker.tsx
sed -n '1,180p' packages/frontend/component/src/ui/icon-picker/picker/emoji/emoji-picker.tsx 2>/dev/null || true
printf '%s\n' '--- shared service type ---'
cat -n blocksuite/affine/shared/src/services/icon-picker-service/index.ts
printf '%s\n' '--- database icon paths ---'
cat -n blocksuite/affine/blocks/database/src/properties/icon/define.ts
cat -n blocksuite/affine/blocks/database/src/properties/icon/render.ts
printf '%s\n' '--- consumers and tests ---'
rg -n -C 5 "fromJson|toJson|decodeIconValue|IconValue|rawValue|IconData" blocksuite/affine/blocks/database/src/properties/icon blocksuite/affine/data-view packages/frontend/core/src/modules/db/schema packages/frontend/component/src/ui/icon-picker -g '*.{ts,tsx}' | head -260

Repository: toeverything/AFFiNE

Length of output: 39024


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- picker type and producers ---'
cat -n packages/frontend/component/src/ui/icon-picker/type.ts
sed -n '1,240p' packages/frontend/component/src/ui/icon-picker/picker/affine-icon/affine-icon-picker.tsx
printf '%s\n' '--- shared service type ---'
cat -n blocksuite/affine/shared/src/services/icon-picker-service/index.ts
printf '%s\n' '--- database icon paths ---'
cat -n blocksuite/affine/blocks/database/src/properties/icon/define.ts
cat -n blocksuite/affine/blocks/database/src/properties/icon/render.ts
printf '%s\n' '--- consumers and tests ---'
rg -n -C 5 "fromJson|toJson|decodeIconValue|IconValue|rawValue|IconData" blocksuite/affine/blocks/database/src/properties/icon blocksuite/affine/data-view packages/frontend/core/src/modules/db/schema packages/frontend/component/src/ui/icon-picker -g '*.{ts,tsx}' | head -260

Repository: toeverything/AFFiNE

Length of output: 36192


🏁 Script executed:

printf '%s\n' '--- icon picker output binding ---'
cat -n packages/frontend/component/src/ui/icon-picker/icon-picker.tsx
printf '%s\n' '--- blob icon references ---'
rg -n -C 3 "IconType\.Blob|type:\s*['\"]blob['\"]|blob:\s*" packages/frontend blocksuite/affine --glob '*.{ts,tsx}' | head -180

Repository: toeverything/AFFiNE

Length of output: 16980


Preserve unknown icon fields in IconValueSchema.

fromJson calls decodeIconValue(), which parses JSON with IconValueSchema.safeParse(). The default zod.object(...) behavior strips keys that the schema does not declare. A newer picker value can therefore lose fields during deserialization.

Use .passthrough() before .nullable().

Proposed fix
 export const IconValueSchema = zod
   .object({
     type: zod.string(),
     unicode: zod.string().optional(),
     name: zod.string().optional(),
     color: zod.string().optional(),
   })
+  .passthrough()
   .nullable();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const IconValueSchema = zod
.object({
type: zod.string(),
unicode: zod.string().optional(),
name: zod.string().optional(),
color: zod.string().optional(),
})
.nullable();
export const IconValueSchema = zod
.object({
type: zod.string(),
unicode: zod.string().optional(),
name: zod.string().optional(),
color: zod.string().optional(),
})
.passthrough()
.nullable();

borderRadius: '8px',
});
document.body.append(wrapper);
setTimeout(() => document.addEventListener('click', onOutside, true), 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Do not register the outside-click listener after closure.

If the caller invokes close() before the timer runs, close() removes a listener that does not exist yet. The queued callback then installs the listener. Later calls to close() return because closed is true, so the document listener remains registered.

Guard the deferred registration with closed.

Proposed fix
-  setTimeout(() => document.addEventListener('click', onOutside, true), 0);
+  setTimeout(() => {
+    if (!closed) {
+      document.addEventListener('click', onOutside, true);
+    }
+  }, 0);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setTimeout(() => document.addEventListener('click', onOutside, true), 0);
setTimeout(() => {
if (!closed) {
document.addEventListener('click', onOutside, true);
}
}, 0);

@CptCurk

CptCurk commented Oct 14, 2026 •

Copy link
Copy Markdown

well thank you for relations and rollup job.

Hope they will add it ASAP, as it's a long time awaited feature.

I did it with claude code on my _ynh package. Don't have the knowledge to publish anything.
https://forgejo.florentcurk.com/cptcurk/affine_ynh

Just if someone wanna check a different path.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants