Skip to content

Conversation

@appflowy
Copy link
Contributor

@appflowy appflowy commented Dec 11, 2025

Summary by Sourcery

Record original field types on cells and lazily decode/transform data when switching database field types, adding support for time fields and checklist/select/checkbox conversions while updating sorting, filtering, grouping, and selectors to use the new decoding logic.

New Features:

  • Add a Time field type with labels, icons, and property selection support.
  • Support lazy, source-aware conversion between field types (e.g. RichText, Checklist, Select, Checkbox, Number, DateTime, URL, Time) when rendering and editing cells.

Bug Fixes:

  • Preserve and correctly interpret checkbox values, time strings, and checklist markdown/plain text when changing field types or viewing as different types.
  • Fix select option filtering and grouping to handle data originating from checklist or name-based IDs.
  • Ensure database sorting and filtering use decoded cell values rather than raw stored data, avoiding incorrect ordering for converted cells.

Enhancements:

  • Store the original source field type on each cell to avoid destructive data mutation when switching property types.
  • Refactor cell parsing into reusable transform/decoding utilities for display, sorting, and filtering.
  • Improve checklist parsing to handle both JSON and markdown/plain text formats and to stringify back to human-readable text.
  • Simplify lodash-es mocks and use UUID v4 for option ID generation.

Tests:

  • Add comprehensive unit tests for lazy cell parsing, type transformations, and decode-based sorting/filtering to keep web behavior aligned with desktop.

@sourcery-ai
Copy link

sourcery-ai bot commented Dec 11, 2025

Reviewer's Guide

Refactors database field-type switching to be lazy and source-aware, adding a source_field_type marker, centralized decode/transform utilities, and full support for new Time and flexible Checklist behavior across parsing, rendering, sorting, filtering, grouping, and UI, with new unit tests verifying data-preserving conversions.

Class diagram for lazy field type switching and decoding

classDiagram
  class YDatabaseCell {
    +get(key)
    +set(key, value)
  }

  class YDatabaseField {
    +get(key)
    +set(key, value)
  }

  class User

  class FieldType {
    <<enumeration>>
    RichText
    Number
    Checkbox
    SingleSelect
    MultiSelect
    Checklist
    DateTime
    Relation
    FileMedia
    Person
    Time
  }

  class YjsDatabaseKey {
    <<enumeration>>
    field_type
    source_field_type
    data
    type
  }

  class Cell {
    +fieldType FieldType
    +data unknown
  }

  class DateTimeCell {
    +fieldType FieldType
    +data unknown
    +endTimestamp number
    +includeTime boolean
    +isRange boolean
    +reminderId string
  }

  class ChecklistCellData {
    +options SelectOption[]
    +selectedOptionIds string[]
    +percentage number
  }

  class SelectOption {
    +id string
    +name string
    +color number
  }

  class CellParser {
    +parseYDatabaseCommonCellToCell(cell YDatabaseCell) Cell
    +parseYDatabaseCellToCell(cell YDatabaseCell, field YDatabaseField) Cell
    +parseYDatabaseDateTimeCellToCell(cell YDatabaseCell) DateTimeCell
    +transformCellData(cell YDatabaseCell, sourceType FieldType, targetType FieldType, field YDatabaseField) unknown
    +stringifyFromSource(cell YDatabaseCell, field YDatabaseField, sourceType FieldType, currentUser User) string
    +stringifyChecklistStruct(checklist ChecklistCellData) string
  }

  class ChecklistUtils {
    +parseChecklistData(data string) ChecklistCellData
    +parseChecklistFlexible(data string) ChecklistCellData
    +stringifyChecklist(options SelectOption[], selected string[]) string
    +parseChecklistTextToStruct(text string) ChecklistCellData
  }

  class TextUtils {
    +parseCheckboxValue(data) boolean
    +parseTimeStringToMs(text string) string
  }

  class DecodeUtils {
    +decodeCellToText(cell YDatabaseCell, field YDatabaseField, currentUser User) string
    +decodeCellForSort(cell YDatabaseCell, field YDatabaseField, currentUser User) boolean string number
  }

  class SortUtils {
    +sortBy(rows, sorts, fields) Row[]
    +defaultValueForSort(fieldType FieldType, condition number) boolean string number
  }

  class FilterUtils {
    +filterBy(rows, filters, fields, rowMetas) Row[]
    +checkboxFilterCheck(data, condition number) boolean
    +checklistFilterCheck(data, content string, condition number) boolean
    +selectOptionFilterCheck(field YDatabaseField, data, content string, condition number) boolean
  }

  class GroupUtils {
    +groupByCheckbox(rows, fieldId string, rowMetas) Map
    +groupBySelectOption(rows, fieldId string, rowMetas, field YDatabaseField) Map
  }

  CellParser --> YDatabaseCell : reads
  CellParser --> YDatabaseField : reads
  CellParser --> FieldType : uses
  CellParser --> YjsDatabaseKey : uses
  CellParser --> ChecklistUtils : uses
  CellParser --> TextUtils : uses
  CellParser --> DateTimeCell : creates
  CellParser --> Cell : creates

  DecodeUtils --> YDatabaseCell : reads
  DecodeUtils --> YDatabaseField : reads
  DecodeUtils --> FieldType : uses
  DecodeUtils --> ChecklistUtils : uses
  DecodeUtils --> TextUtils : uses

  SortUtils --> DecodeUtils : uses
  SortUtils --> FieldType : uses

  FilterUtils --> DecodeUtils : uses decodeCellToText
  FilterUtils --> TextUtils : uses parseCheckboxValue
  FilterUtils --> ChecklistUtils : uses parseChecklistFlexible

  GroupUtils --> TextUtils : uses parseCheckboxValue
  GroupUtils --> ChecklistUtils : uses parseChecklistFlexible

  ChecklistUtils --> SelectOption : creates
  ChecklistUtils --> ChecklistCellData : creates
Loading

Flow diagram for lazy field type switching and on-demand conversion

flowchart TD
  A[User changes field type in UI] --> B[useSwitchPropertyType hook]
  B --> C[Iterate all rows in database]
  C --> D[For each row, get cells map]
  D --> E[Get cell for switched fieldId]
  E --> F{Cell exists?}
  F -- No --> C
  F -- Yes --> G[Read oldCellType from field_type]
  G --> H[Read current data from data]
  H --> I[Write oldCellType to source_field_type]
  I --> J[Clone data if Y.Array, else keep as is]
  J --> K[Update cell.field_type to new FieldType]
  K --> L[Persist cloned original data into data]
  L --> M[Update last_modified on cell and row]
  M --> C

  subgraph RenderAndInteraction
    N[Selector hooks useCellSelector] --> O[parseYDatabaseCellToCell with cell and field]
    O --> P[Read source_field_type and field_type]
    P --> Q{sourceType equals targetType?}
    Q -- No --> R[transformCellData with cell, sourceType, targetType, field]
    R --> S[Return converted Cell value]
    Q -- Yes --> S[Return parsed Cell value]

    T[Sorting: sortBy] --> U[decodeCellForSort with cell and field]
    U --> V[Use FieldType-specific logic and source_field_type]

    W[Filtering: filterBy] --> X[decodeCellToText with cell and field]
    X --> Y[Apply type-specific filter checks]

    Z[Grouping: groupByCheckbox / groupBySelectOption] --> AA[Read source_field_type and data]
    AA --> AB[Map Checklist, Checkbox, Select values appropriately]
  end
Loading

File-Level Changes

Change Details Files
Introduce lazy, source-aware cell parsing and transformation when switching field types, including DateTime, Checklist, Select, Checkbox, Number, URL, and Time.
  • Extend parseYDatabaseCellToCell to accept the owning field, read source_field_type, and invoke a new transformCellData helper when source and target field types differ.
  • Implement transformCellData to convert underlying data across field types (RichText, Checklist, Select, Checkbox, Number, URL, Time), using helpers like parseChecklistFlexible, parseSelectOptionTypeOptions, parseCheckboxValue, and parseTimeStringToMs.
  • Adjust DateTime conversion logic to initialize sensible defaults when converting into DateTime from a non-DateTime field and retain existing parse path when the source is already DateTime.
  • Add stringifyFromSource and stringifyChecklistStruct helpers to render data for RichText/Checklist when the source field type differs from the current type.
  • Update getCellDataText to be aware of source_field_type, handle new Checkbox/Time/URL/RichText-from-source cases, and to use checklist-aware select rendering.
src/application/database-yjs/cell.parse.ts
Switch property type lazily by recording source field type and preserving raw cell data instead of eagerly transforming it.
  • Simplify useSwitchPropertyType to stop mutating cell data on switch and instead clone any Y.Array data, write source_field_type, update field_type, and timestamps only.
  • Remove the hook dependency on useCurrentUser now that transformation is deferred to parsing/rendering time.
src/application/database-yjs/dispatch.ts
Centralize lazy decoding for sorting and filtering, replacing the old parse-for-sort logic with decodeCellForSort/decodeCellToText and default sort values.
  • Replace parseCellDataForSort with a simpler defaultValueForSort helper that returns type-appropriate defaults for missing values.
  • Introduce decodeCellForSort and decodeCellToText to compute sort/filter primitives using both target and source field types, including checklist percentages, select name mapping, checkbox booleans, numbers, and date/time handling.
  • Update sortBy to use decodeCellForSort for non-empty cells and defaultValueForSort for missing/empty ones.
src/application/database-yjs/sort.ts
src/application/database-yjs/decode.ts
Make filters and grouping source-aware, especially for Select, Checklist, Checkbox, Time, and Person fields.
  • Change URL/RichText/Time/Number filtering to rely on decodeCellToText, ensuring consistent text input regardless of underlying representation.
  • Rework checkboxFilterCheck to use parseCheckboxValue on arbitrary input types and treat missing values as unchecked.
  • Rework checklistFilterCheck to use parseChecklistFlexible and handle non-string inputs safely.
  • Upgrade selectOptionFilterCheck to accept the field, derive options, and support checklist-backed selects by mapping checklist selections to option IDs before applying option conditions.
  • Update grouping helpers to fetch full cells, use parseCheckboxValue for checkbox grouping, and use parseChecklistFlexible plus name/ID matching for select group membership when source data comes from a checklist.
src/application/database-yjs/filter.ts
src/application/database-yjs/group.ts
Add flexible checklist parsing/stringifying that works with both JSON-structured and markdown/plain-text representations.
  • Extend checklist parsing utilities with parseChecklistFlexible (tries JSON first, then markdown/plain text via parseChecklistTextToStruct) and stringifyChecklist, so checklists can be round-tripped between structured and textual formats.
  • Update all checklist cell components/properties to use parseChecklistFlexible instead of the JSON-only parser.
src/application/database-yjs/fields/checklist/parse.ts
src/components/database/components/cell/checklist/ChecklistCell.tsx
src/components/database/components/database-row/checklist/ChecklistCell.tsx
src/components/database/components/property/cheklist/ChecklistProperty.tsx
Introduce shared text utilities for checkbox truthiness and time parsing and use them across decoding, filters, and grouping.
  • Add parseCheckboxValue to normalize boolean-like values (strings, numbers, booleans) into a boolean, covering common variants such as 'yes', 'true', '1', '[x]'.
  • Add parseTimeStringToMs to parse HH:MM and HH:MM:SS strings into millisecond strings while passing numeric-like text through and returning null on invalid input.
  • Export these utilities as part of the text field utilities module and wire them into cell parsing, filters, grouping, and decode helpers.
src/application/database-yjs/fields/text/utils.ts
src/application/database-yjs/fields/text/index.ts
src/application/database-yjs/decode.ts
src/application/database-yjs/filter.ts
src/application/database-yjs/group.ts
src/application/database-yjs/cell.parse.ts
Wire source_field_type into the Yjs database types and add the new Time field type across backend and UI.
  • Extend YjsDatabaseKey and YDatabaseCell typings to include source_field_type and allow get to read either field_type or source_field_type.
  • Introduce FieldType.Time in the core enum and propagate it through Cypress selectors, property selector options, labels, icons, and tooltips.
  • Update field-related imports to reference FieldType from database.type instead of the barrel to avoid circular deps.
src/application/types.ts
src/application/database-yjs/database.type.ts
cypress/support/selectors.ts
src/components/database/components/property/PropertySelectTrigger.tsx
src/components/database/components/field/FieldLabel.tsx
src/components/database/components/field/FieldTypeIcon.tsx
src/application/database-yjs/fields/index.ts
Ensure selectors and relation UI use the new source-aware parsing so dependent views render converted data correctly.
  • Update useCellSelector, calendar selectors, and primary-content selector hooks to pass the owning field into parseYDatabaseCellToCell and include the field in effect deps.
  • Update relation cell menu to cache the primary field object and pass it into parseYDatabaseCellToCell when generating display strings for related rows.
src/application/database-yjs/selector.ts
src/components/database/components/cell/relation/RelationCellMenuContent.tsx
Adjust lodash-es mocks and add test coverage to validate lazy conversion and decoding behavior.
  • Enhance the lodash-es Jest mock to export real implementations of some, every, and filter on arrays while keeping isEqual and debounce mocked.
  • Add cell.parse.lazy.test.ts to cover getCellDataText behavior across many type-conversion scenarios, with emphasis on Number, DateTime, Time, URL, Checkbox, Select, Checklist, and multi-hop conversions.
  • Add cell.transform.test.ts to validate parseYDatabaseCellToCell structural transformations for RichText↔Checklist and Checklist↔Select, including checkbox-from-text cases.
  • Add decode.lazy.test.ts to exercise decodeCellForSort, selectOptionFilterCheck, and parseChecklistFlexible under lazy conversion, ensuring sorting and filtering are consistent with desktop behavior.
__mocks__/lodash-es.ts
src/application/database-yjs/__tests__/cell.parse.lazy.test.ts
src/application/database-yjs/__tests__/cell.transform.test.ts
src/application/database-yjs/__tests__/decode.lazy.test.ts
cypress/e2e/database/field-type-lazy.cy.ts
Miscellaneous small improvements and cleanups.
  • Change generateUUID to use uuidv4 instead of uuidv5(Date.now()) for more standard random UUID generation.
  • Tidy minor style issues such as removing unused imports, ensuring newline at EOF, and minor comment/tooltip additions.
src/application/database-yjs/const.ts
src/application/database-yjs/dispatch.ts
src/__mocks__/lodash-es.ts
src/components/database/components/property/PropertySelectTrigger.tsx
src/components/database-yjs/fields/text/utils.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions
Copy link

github-actions bot commented Dec 11, 2025

🥷 Ninja i18n – 🛎️ Translations need to be updated

Project /project.inlang

lint rule new reports level link
Missing translation 28 warning contribute (via Fink 🐦)

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey there - I've reviewed your changes - here's some feedback:

  • There is now overlapping conversion logic between getCellDataText/stringifyFromSource and the new decodeCellToText/decodeCellForSort helpers; consider centralizing the type-conversion rules in a single module to avoid future drift between display, filtering, and sorting behavior.
  • Changing generateUUID from a v5 (time-seeded) UUID to a random v4 UUID alters the determinism characteristics of generated IDs; double-check that any callers (e.g., checklist option regeneration or diffing logic) do not rely on predictable/stable IDs across sessions.
  • Several of the new Jest tests assert tautologies like expect(result.length >= 0).toBe(true) or accept multiple very loose outcomes; tightening these expectations to the actual intended behavior would make the tests a more reliable safety net for future refactors.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- There is now overlapping conversion logic between `getCellDataText`/`stringifyFromSource` and the new `decodeCellToText`/`decodeCellForSort` helpers; consider centralizing the type-conversion rules in a single module to avoid future drift between display, filtering, and sorting behavior.
- Changing `generateUUID` from a v5 (time-seeded) UUID to a random v4 UUID alters the determinism characteristics of generated IDs; double-check that any callers (e.g., checklist option regeneration or diffing logic) do not rely on predictable/stable IDs across sessions.
- Several of the new Jest tests assert tautologies like `expect(result.length >= 0).toBe(true)` or accept multiple very loose outcomes; tightening these expectations to the actual intended behavior would make the tests a more reliable safety net for future refactors.

## Individual Comments

### Comment 1
<location> `src/application/database-yjs/decode.ts:21-22` </location>
<code_context>
+  const sourceType = Number(cell.get(YjsDatabaseKey.source_field_type) ?? cell.get(YjsDatabaseKey.field_type)) as FieldType;
+  const data = cell.get(YjsDatabaseKey.data);
+
+  // If types match and data is string/number, return raw string.
+  if (sourceType === targetType && (typeof data === 'string' || typeof data === 'number')) {
+    return String(data);
+  }
</code_context>

<issue_to_address>
**issue (bug_risk):** The early return for matching source/target types bypasses the specialized per-type decoding logic.

This short‑circuit causes DateTime, URL, Time, Checklist, and Select cells to skip their specialized decoding when `sourceType === targetType` and `data` is a string/number (e.g. DateTime won’t use `getDateCellStr`, URL won’t be trimmed, Time won’t be parsed). As a result, `decodeCellToText` won’t apply the new logic for these types when filtering. Consider removing this early return or limiting it to plain text types only, so other field types always pass through the switch‑based decoding.
</issue_to_address>

### Comment 2
<location> `src/application/database-yjs/decode.ts:103-105` </location>
<code_context>
+      return '';
+    }
+
+    case FieldType.Number:
+      if (typeof data === 'string' || typeof data === 'number') {
+        return String(data);
</code_context>

<issue_to_address>
**suggestion (bug_risk):** decodeCellForSort returns mixed types (numbers and empty strings) for numeric-like fields, which can lead to inconsistent sorting.

For `FieldType.Number` and `FieldType.DateTime`, failures return `''` while successes return numbers, so the sort key can mix `number`, `string`, and `boolean` (for Checkbox). Depending on the comparator, this number/string mix can cause unpredictable ordering or coercion. Consider using a numeric sentinel (e.g. `Infinity`, `-Infinity`, or `NaN`) for unparsable numeric/date values so those sort keys stay consistently numeric.

Suggested implementation:

```typescript
    case FieldType.Number: {
      // Always return a numeric sort key for numeric fields; use NaN as a sentinel for invalid/unparsable values
      if (typeof data === 'number') {
        return data;
      }

      if (typeof data === 'string') {
        const parsed = Number(data);
        return Number.isNaN(parsed) ? NaN : parsed;
      }

      return NaN;
    }

```

```typescript
    case FieldType.DateTime: {
      // Always return a numeric sort key (timestamp in ms); use NaN for invalid dates
      if (typeof data === 'string' || typeof data === 'number' || data instanceof Date) {
        const date = data instanceof Date ? data : new Date(data);
        const time = date.getTime();
        return Number.isNaN(time) ? NaN : time;
      }

      return NaN;
    }

```

1. If the existing `FieldType.DateTime` implementation differs from the `SEARCH` block above, adapt the `SEARCH` section to match the current code around the `DateTime` case and apply the same replacement logic.
2. Ensure that the comparator used with `decodeCellForSort` handles `NaN` appropriately (e.g. by normalizing `NaN` to sort first/last), since `NaN` is not directly comparable with `===` and standard numeric comparisons.
3. If `decodeCellForSort` is used in any non-sorting context that expects strings for numeric/date fields, verify that those call sites are updated (or use a different decode function) so they do not rely on the previous string-returning behavior.
</issue_to_address>

### Comment 3
<location> `src/application/database-yjs/fields/text/utils.ts:38-41` </location>
<code_context>
+  return false;
+}
+
+export function parseTimeStringToMs(text: string): string | null {
+  const trimmed = text.trim();
+
+  if (!trimmed) return '';
+
+  if (!Number.isNaN(Number(trimmed))) {
</code_context>

<issue_to_address>
**nitpick (bug_risk):** parseTimeStringToMs’s return type doesn’t match its semantics for empty input.

The function is typed as `string | null`, where `null` indicates an unparseable value, but an empty string currently returns `''`. This makes the distinction between `''` and `null` subtle and error-prone. Please either return `null` for empty input to match the existing semantics, or update the return type and docs to explicitly include `''` as a valid “no time” value.
</issue_to_address>

### Comment 4
<location> `src/application/database-yjs/cell.parse.ts:66` </location>
<code_context>
   return value;
 }

+function transformCellData(
+  cell: YDatabaseCell,
+  sourceType: FieldType,
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting shared helpers for checkbox, select, checklist, and time conversions to deduplicate logic across `transformCellData`, `getCellDataText`, and `stringifyFromSource` and reduce their coupling.

You’ve added a lot of valuable conversion paths, but `transformCellData`, `getCellDataText`, and `stringifyFromSource` are now tightly coupled and duplicating logic. You can keep all functionality while reducing complexity by extracting a few small, shared helpers and reusing them across these switches.

### 1. Extract reusable checkbox helpers

You stringify checkbox values in at least three places (`transformCellData`, `getCellDataText`, `stringifyFromSource`):

```ts
if (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean') {
  const isChecked = parseCheckboxValue(data);
  return isChecked ? 'Yes' : 'No';
}
```

Create a tiny helper:

```ts
function stringifyCheckboxData(data: unknown): string {
  if (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean') {
    return parseCheckboxValue(data) ? 'Yes' : 'No';
  }
  return '';
}
```

Then use it in all three locations:

```ts
// transformCellData → Checkbox from RichText
case FieldType.Checkbox:
  if (sourceType === FieldType.RichText) {
    return stringifyCheckboxData(data);
  }
  // ...

// getCellDataText → Checkbox display
case FieldType.Checkbox: {
  const data = cell.get(YjsDatabaseKey.data);
  return stringifyCheckboxData(data);
}

// stringifyFromSource → Checkbox as RichText
case FieldType.Checkbox:
  return stringifyCheckboxData(data);
```

This trims duplicate branches and makes future checkbox changes safe and centralized.

### 2. Extract select ID ↔ name mapping helper

You repeat the “select ids to names” logic with slight variations:

```ts
data
  .split(',')
  .map((item) => {
    const option = options.find((option) => option?.id === item || option?.name === item);
    return option?.name || '';
  })
  .filter(Boolean)
  .join(',');
```

and similarly in `stringifyFromSource`.

Extract one helper and use it in both `getCellDataText` and `stringifyFromSource`:

```ts
function selectIdsToNamesCSV(data: unknown, options: SelectOption[]): string {
  if (typeof data !== 'string') return '';
  return data
    .split(',')
    .map((idOrName) => options.find((o) => o.id === idOrName || o.name === idOrName)?.name)
    .filter(Boolean)
    .join(',');
}
```

Usage:

```ts
// getCellDataText
case FieldType.SingleSelect:
case FieldType.MultiSelect: {
  const data = cell.get(YjsDatabaseKey.data);
  const options = parseSelectOptionTypeOptions(field)?.options || [];

  // checklist-origin special-case stays
  if (typeof data === 'string') {
    const checklist = parseChecklistFlexible(data);
    if (checklist && sourceType === FieldType.Checklist) {
      const selectedNames = checklist.selectedOptionIds
        ?.map((id) => options.find((opt) => opt.id === id || opt.name === id)?.name)
        .filter(Boolean);
      if (selectedNames?.length) return selectedNames.join(',');
    }
  }

  return selectIdsToNamesCSV(data, options);
}

// stringifyFromSource
case FieldType.SingleSelect:
case FieldType.MultiSelect: {
  if (!field) return '';
  const options = parseSelectOptionTypeOptions(field)?.options || [];
  return selectIdsToNamesCSV(data, options);
}
```

This removes duplication and keeps the checklist special case intact.

### 3. Extract checklist stringify helper (struct vs options + ids)

You now have both:

- `stringifyChecklistStruct(checklist: ChecklistCellData)` (new),
- `stringifyChecklist(options, selectedOptionIds)` (existing).

You can avoid the extra mental overhead by wrapping the existing function instead of re-implementing the shape in multiple places:

```ts
function stringifyChecklistFromStruct(checklist: ChecklistCellData): string {
  return stringifyChecklist(checklist.options || [], checklist.selectedOptionIds || []);
}
```

Then:

```ts
// transformCellData – RichText → Checklist
if (sourceType === FieldType.RichText) {
  const parsed = parseChecklistFlexible(data);
  if (!parsed) return '';
  // if you truly need the struct JSON, keep stringifyChecklistStruct;
  // otherwise use the new wrapper:
  return stringifyChecklistFromStruct(parsed);
}

// getCellDataText – Checklist
case FieldType.Checklist: {
  const cellData = cell.get(YjsDatabaseKey.data);
  if (typeof cellData === 'string') {
    const parsed = parseChecklistFlexible(cellData);
    if (!parsed) return '';
    return stringifyChecklistFromStruct(parsed);
  }
  return '';
}

// stringifyFromSource – Checklist
case FieldType.Checklist: {
  if (typeof data === 'string') {
    const parsed = parseChecklistFlexible(data);
    if (!parsed) return '';
    return stringifyChecklistFromStruct(parsed);
  }
  return '';
}
```

If you do need both shapes (struct JSON vs rendered string), keep both helpers but ensure each goes through a single code path (`ChecklistCellData` → underlying format) instead of duplicating the object shape inline.

### 4. Extract time conversion helper

`Time` handling is also duplicated in `transformCellData`, `getCellDataText`, and `stringifyFromSource`:

```ts
if (typeof data === 'string') return parseTimeStringToMs(data) ?? '';
if (typeof data === 'number') return String(data);
```

Create a shared helper:

```ts
function normalizeTimeDataToMsString(data: unknown): string {
  if (typeof data === 'number') return String(data);
  if (typeof data === 'string') {
    const parsed = parseTimeStringToMs(data);
    return parsed ?? '';
  }
  return '';
}
```

Then:

```ts
// transformCellData → Time
case FieldType.Time:
  return normalizeTimeDataToMsString(data);

// getCellDataText → Time
case FieldType.Time: {
  const data = cell.get(YjsDatabaseKey.data);
  if (data === undefined || data === null) return '';
  return normalizeTimeDataToMsString(data);
}

// stringifyFromSource → Time
case FieldType.Time:
  return normalizeTimeDataToMsString(data) || (typeof data === 'string' ? data : '');
```

This keeps all current behaviour but collapses 3 similar branches into a single well‑named abstraction.

---

By pulling out these small helpers and calling them from `transformCellData`, `getCellDataText`, and `stringifyFromSource`, you keep the new cross‑type conversion features but reduce the “god switch” effect and duplication.
</issue_to_address>

### Comment 5
<location> `src/application/database-yjs/filter.ts:358` </location>
<code_context>
 }

-export function selectOptionFilterCheck(data: string, content: string, condition: number) {
+export function selectOptionFilterCheck(field: YDatabaseField, data: unknown, content: string, condition: number) {
+  const filterOptionIds = content.split(',').filter((item) => item.trim() !== '');
+  const typeOption = parseSelectOptionTypeOptions(field);
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting the logic that converts a cell’s raw value into normalized select option IDs into a reusable helper so `selectOptionFilterCheck` only handles filter conditions.

You can simplify and de‑duplicate the added logic by extracting the “cell → selected option IDs” behavior into a small helper and reusing it inside `selectOptionFilterCheck` (and later in grouping/decoding).

Right now `selectOptionFilterCheck`:

- Parses checklist vs comma‑separated string.
- Resolves checklist option IDs/names to names.
- Resolves those names/IDs again against `typeOption.options`.
- Then re‑normalizes them a second time with `selectedIdsByName`.

You can move this into a single helper that returns a clean `string[]` of normalized option IDs and remove the double mapping:

```ts
function getSelectedOptionIdsFromCell(field: YDatabaseField, data: unknown): string[] {
  if (typeof data !== 'string' || !data.trim()) return [];

  const typeOption = parseSelectOptionTypeOptions(field);
  const options = typeOption?.options || [];

  const checklist = parseChecklistFlexible(data);
  if (checklist) {
    const checkedNamesOrIds =
      checklist.selectedOptionIds
        ?.map((idOrName) => {
          const fromChecklist = checklist.options?.find((opt) => opt.id === idOrName)?.name;
          return fromChecklist ?? idOrName;
        })
        .filter(Boolean) ?? [];

    return checkedNamesOrIds
      .map((idOrName) => options.find((opt) => opt.id === idOrName || opt.name === idOrName)?.id)
      .filter((item): item is string => Boolean(item));
  }

  // non-checklist encoding: assume comma-separated IDs or names
  const raw = data
    .split(',')
    .map((item) => item.trim())
    .filter(Boolean);

  return raw
    .map((idOrName) => options.find((opt) => opt.id === idOrName || opt.name === idOrName)?.id)
    .filter((item): item is string => Boolean(item));
}
```

`selectOptionFilterCheck` can then be reduced to pure filtering logic:

```ts
export function selectOptionFilterCheck(
  field: YDatabaseField,
  data: unknown,
  content: string,
  condition: number,
) {
  const selectedOptionIds = getSelectedOptionIdsFromCell(field, data);
  const filterOptionIds = content
    .split(',')
    .map((item) => item.trim())
    .filter(Boolean);

  if (SelectOptionFilterCondition.OptionIsEmpty === condition) {
    return selectedOptionIds.length === 0;
  }

  if (SelectOptionFilterCondition.OptionIsNotEmpty === condition) {
    return selectedOptionIds.length > 0;
  }

  switch (condition) {
    case SelectOptionFilterCondition.OptionIs:
    case SelectOptionFilterCondition.OptionContains:
      if (!content) return true;
      return some(filterOptionIds, (option) => selectedOptionIds.includes(option));

    case SelectOptionFilterCondition.OptionIsNot:
    case SelectOptionFilterCondition.OptionDoesNotContain:
      if (!content) return true;
      return every(filterOptionIds, (option) => !selectedOptionIds.includes(option));

    default:
      return false;
  }
}
```

This keeps all current behavior (including checklist support and name/ID resolution), but:

- Removes duplicated “id or name → id” mapping.
- Keeps `selectOptionFilterCheck` focused on filter conditions only.
- Gives you a reusable `getSelectedOptionIdsFromCell` that can be shared with grouping and other decoding logic later.
</issue_to_address>

### Comment 6
<location> `src/application/database-yjs/group.ts:87` </location>
<code_context>
-    const cellData = getCellData(row.id, fieldId, rowMetas);
-
-    const checked = getChecked(cellData as string);
+    const cell = getCell(row.id, fieldId, rowMetas);
+    const cellData = cell?.get(YjsDatabaseKey.data);
+    const checked = parseCheckboxValue(cellData as string);
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting the new select/checklist and checkbox decoding logic into shared normalization helpers so grouping and filtering functions only handle already-normalized values.

You can reduce the added complexity by extracting the cross‑type selection/checkbox decoding into shared helpers, then using them both here and in filters.

### 1. Normalize select/checklist option IDs in a shared helper

Create a helper (e.g. in a shared `select`/`options` utils module) that encapsulates `source_field_type`, checklist parsing, and ID/name reconciliation:

```ts
// selectOptionNormalization.ts
import { FieldType } from '@/application/database-yjs/database.type';
import { parseChecklistFlexible } from '@/application/database-yjs/fields/checklist/parse';
import { YDoc, YjsDatabaseKey, YDatabaseField } from '@/application/types';
import { getCell } from '@/application/database-yjs/const';
import { parseSelectOptionTypeOptions } from '@/application/database-yjs/fields';

export function getSelectedOptionIdsFromRow(
  rowId: string,
  field: YDatabaseField,
  rowMetas: Record<string, YDoc>
): string[] {
  const fieldId = field.get(YjsDatabaseKey.id) as string;
  const cell = getCell(rowId, fieldId, rowMetas);
  if (!cell) return [];

  const cellData = cell.get(YjsDatabaseKey.data);
  const sourceType = Number(
    cell.get(YjsDatabaseKey.source_field_type) ?? cell.get(YjsDatabaseKey.field_type)
  ) as FieldType;

  const typeOption = parseSelectOptionTypeOptions(field);
  if (!typeOption) return [];

  const resolveId = (idOrName: string) =>
    typeOption.options.find((opt) => opt.id === idOrName || opt.name === idOrName)?.id;

  if (sourceType === FieldType.Checklist && typeof cellData === 'string') {
    const checklist = parseChecklistFlexible(cellData);
    return (
      checklist?.selectedOptionIds
        ?.map(resolveId)
        .filter((id): id is string => Boolean(id)) ?? []
    );
  }

  if (typeof cellData === 'string') {
    return (
      cellData
        .split(',')
        .map((v) => v.trim())
        .map(resolveId)
        .filter((id): id is string => Boolean(id)) ?? []
    );
  }

  return [];
}
```

Then `groupBySelectOption` can focus only on grouping logic:

```ts
// in grouping file
import { getSelectedOptionIdsFromRow } from '@/application/database-yjs/fields/selectOptionNormalization';

rows.forEach((row) => {
  if (!rowMetas[row.id]) {
    return;
  }

  const selectedIds = getSelectedOptionIdsFromRow(row.id, field, rowMetas);

  if (selectedIds.length === 0) {
    if (!result.has(fieldId)) {
      return;
    }
    const group = result.get(fieldId) ?? [];
    group.push(row);
    result.set(fieldId, group);
    return;
  }

  selectedIds.forEach((id) => {
    if (!result.has(id)) {
      return;
    }
    const group = result.get(id) ?? [];
    group.push(row);
    result.set(id, group);
  });
});
```

You can similarly replace any duplicated logic in `selectOptionFilterCheck` to call `getSelectedOptionIdsFromRow` or a variant that works directly from a `cell`/`rawData`, keeping the ID/name reconciliation logic in one place.

### 2. Extract checkbox decoding from grouping

`groupByCheckbox` now knows about `getCell` and `parseCheckboxValue`. You can hide that behind a small helper that is reusable in filters or elsewhere:

```ts
// checkboxNormalization.ts
import { getCell } from '@/application/database-yjs/const';
import { YDoc, YjsDatabaseKey, YDatabaseField } from '@/application/types';
import { parseCheckboxValue } from '@/application/database-yjs/fields/text/utils';

export function getCheckboxValueFromRow(
  rowId: string,
  field: YDatabaseField,
  rowMetas: Record<string, YDoc>
): boolean {
  const fieldId = field.get(YjsDatabaseKey.id) as string;
  const cell = getCell(rowId, fieldId, rowMetas);
  const cellData = cell?.get(YjsDatabaseKey.data);
  return parseCheckboxValue(cellData as string);
}
```

Then simplify `groupByCheckbox`:

```ts
// in grouping file
import { getCheckboxValueFromRow } from '@/application/database-yjs/fields/checkboxNormalization';

rows.forEach((row) => {
  if (!rowMetas[row.id]) {
    return;
  }

  const checked = getCheckboxValueFromRow(row.id, field, rowMetas);
  const groupName = checked ? 'Yes' : 'No';

  if (!result.has(groupName)) {
    return;
  }

  const group = result.get(groupName) ?? [];
  group.push(row);
  result.set(groupName, group);
});
```

This keeps all existing behavior (including checklist and ID/name handling) but centralizes the complex normalization. The grouping and filtering modules then only handle grouping/filtering logic on already-normalized IDs/booleans, which lowers cognitive load and avoids future divergence.
</issue_to_address>

### Comment 7
<location> `src/application/database-yjs/decode.ts:12` </location>
<code_context>
+ * Decode a cell to a string representation for rendering/filtering/sorting,
+ * using the cell's recorded source type when it differs from the field's current type.
+ */
+export function decodeCellToText(
+  cell: YDatabaseCell,
+  field: YDatabaseField,
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting shared helpers for select/checklist name resolution and delegating more sorting logic to `decodeCellToText` to simplify both switch statements and avoid duplication.

You can reduce duplication and make this easier to follow by extracting small helpers and reusing `decodeCellToText` more aggressively inside `decodeCellForSort`.

### 1. Deduplicate checklist → names and select ID/name resolution

Both functions do:

- `parseChecklistFlexible(data)`
- `parseSelectOptionTypeOptions(field)`
- `options.find((opt) => opt.id === id || opt.name === id)?.name`
- `join(',')`

You can centralize that into a couple of primitives:

```ts
function getSelectOptions(field: YDatabaseField) {
  return parseSelectOptionTypeOptions(field)?.options || [];
}

function resolveSelectNamesFromIdsOrNames(
  field: YDatabaseField,
  idsOrNames: string[]
): string[] {
  const options = getSelectOptions(field);
  return idsOrNames
    .map((idOrName) => options.find((opt) => opt.id === idOrName || opt.name === idOrName)?.name)
    .filter((name): name is string => Boolean(name));
}

function getChecklistSelectedNamesFromSelectField(
  field: YDatabaseField,
  data: string
): string[] {
  const parsed = parseChecklistFlexible(data);
  if (!parsed?.selectedOptionIds?.length) return [];
  return resolveSelectNamesFromIdsOrNames(field, parsed.selectedOptionIds);
}
```

Then `decodeCellToText` select branches become much simpler and reuse the same logic:

```ts
case FieldType.Checklist: {
  if (typeof data !== 'string') return '';
  const parsed = parseChecklistFlexible(data);
  if (!parsed) return '';
  return stringifyChecklist(parsed.options || [], parsed.selectedOptionIds || []);
}

case FieldType.SingleSelect:
case FieldType.MultiSelect: {
  if (typeof data !== 'string') return '';
  if (sourceType === FieldType.Checklist) {
    const names = getChecklistSelectedNamesFromSelectField(field, data);
    if (names.length) return names.join(',');
  }
  const idsOrNames = data.split(',');
  const names = resolveSelectNamesFromIdsOrNames(field, idsOrNames);
  return names.join(',');
}
```

`decodeCellForSort` can then reuse the same primitives instead of re‑implementing the mapping:

```ts
case FieldType.SingleSelect:
case FieldType.MultiSelect: {
  if (typeof data !== 'string') return '';
  if (sourceType === FieldType.Checklist) {
    return getChecklistSelectedNamesFromSelectField(field, data).join(',');
  }
  const idsOrNames = data.split(',');
  return resolveSelectNamesFromIdsOrNames(field, idsOrNames).join(',');
}
```

### 2. Make `decodeCellForSort` leaner by delegating to `decodeCellToText`

You already fall back to `decodeCellToText` in `default`. You can extend that pattern to all text‑sortable types, keeping only genuinely non‑string sort keys in the switch:

```ts
export function decodeCellForSort(
  cell: YDatabaseCell,
  field: YDatabaseField,
  currentUser?: User
): string | number | boolean {
  const targetType = Number(field.get(YjsDatabaseKey.type)) as FieldType;
  const data = cell.get(YjsDatabaseKey.data);

  switch (targetType) {
    case FieldType.Checkbox:
      if (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean') {
        return parseCheckboxValue(data);
      }
      return false;

    case FieldType.Number:
    case FieldType.DateTime:
      if (typeof data === 'number') return data;
      if (typeof data === 'string' && data.trim() !== '' && !Number.isNaN(Number(data))) {
        return Number(data);
      }
      return '';

    case FieldType.Checklist: {
      if (typeof data !== 'string') return 0;
      const parsed = parseChecklistFlexible(data);
      return parsed?.percentage ?? 0;
    }

    default:
      // All other types: use text representation as sort key
      return decodeCellToText(cell, field, currentUser);
  }
}
```

If you still need specialized behavior for selects, you can either:

- Keep a small select case that *delegates* to the helper (`resolveSelectNamesFromIdsOrNames`), or
- Rely on `decodeCellToText` for those and treat them as plain text sort keys.

These two refactors should reduce the size of both switches, eliminate repeated logic for select/checklist handling, and make this module the canonical place for ID→name/string conversions without changing behavior.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +21 to +22
// If types match and data is string/number, return raw string.
if (sourceType === targetType && (typeof data === 'string' || typeof data === 'number')) {
Copy link

Choose a reason for hiding this comment

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

issue (bug_risk): The early return for matching source/target types bypasses the specialized per-type decoding logic.

This short‑circuit causes DateTime, URL, Time, Checklist, and Select cells to skip their specialized decoding when sourceType === targetType and data is a string/number (e.g. DateTime won’t use getDateCellStr, URL won’t be trimmed, Time won’t be parsed). As a result, decodeCellToText won’t apply the new logic for these types when filtering. Consider removing this early return or limiting it to plain text types only, so other field types always pass through the switch‑based decoding.

Comment on lines +103 to +105
case FieldType.Number:
if (typeof data === 'number') return data;
if (typeof data === 'string' && data.trim() !== '' && !Number.isNaN(Number(data))) {
Copy link

Choose a reason for hiding this comment

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

suggestion (bug_risk): decodeCellForSort returns mixed types (numbers and empty strings) for numeric-like fields, which can lead to inconsistent sorting.

For FieldType.Number and FieldType.DateTime, failures return '' while successes return numbers, so the sort key can mix number, string, and boolean (for Checkbox). Depending on the comparator, this number/string mix can cause unpredictable ordering or coercion. Consider using a numeric sentinel (e.g. Infinity, -Infinity, or NaN) for unparsable numeric/date values so those sort keys stay consistently numeric.

Suggested implementation:

    case FieldType.Number: {
      // Always return a numeric sort key for numeric fields; use NaN as a sentinel for invalid/unparsable values
      if (typeof data === 'number') {
        return data;
      }

      if (typeof data === 'string') {
        const parsed = Number(data);
        return Number.isNaN(parsed) ? NaN : parsed;
      }

      return NaN;
    }
    case FieldType.DateTime: {
      // Always return a numeric sort key (timestamp in ms); use NaN for invalid dates
      if (typeof data === 'string' || typeof data === 'number' || data instanceof Date) {
        const date = data instanceof Date ? data : new Date(data);
        const time = date.getTime();
        return Number.isNaN(time) ? NaN : time;
      }

      return NaN;
    }
  1. If the existing FieldType.DateTime implementation differs from the SEARCH block above, adapt the SEARCH section to match the current code around the DateTime case and apply the same replacement logic.
  2. Ensure that the comparator used with decodeCellForSort handles NaN appropriately (e.g. by normalizing NaN to sort first/last), since NaN is not directly comparable with === and standard numeric comparisons.
  3. If decodeCellForSort is used in any non-sorting context that expects strings for numeric/date fields, verify that those call sites are updated (or use a different decode function) so they do not rely on the previous string-returning behavior.

Comment on lines +38 to +41
export function parseTimeStringToMs(text: string): string | null {
const trimmed = text.trim();

if (!trimmed) return '';
Copy link

Choose a reason for hiding this comment

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

nitpick (bug_risk): parseTimeStringToMs’s return type doesn’t match its semantics for empty input.

The function is typed as string | null, where null indicates an unparseable value, but an empty string currently returns ''. This makes the distinction between '' and null subtle and error-prone. Please either return null for empty input to match the existing semantics, or update the return type and docs to explicitly include '' as a valid “no time” value.

@appflowy appflowy merged commit 1042e51 into main Dec 11, 2025
12 checks passed
@appflowy appflowy deleted the field_type_switching branch December 11, 2025 12:24
josue693 pushed a commit to josue693/AppFlowy-Web that referenced this pull request Dec 21, 2025
* refactor: field type switching

* chore: add test

* chore: use nanoid
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants