From 4516cadf3df51d1feb1bad0200e70e48a3a4169d Mon Sep 17 00:00:00 2001 From: nathan Date: Sun, 7 Jun 2026 11:55:43 +0800 Subject: [PATCH 1/3] feat: add web board column colors --- src/@types/translations/en.json | 3 + .../database-yjs/__tests__/useGroup.test.tsx | 96 ++++++++- src/application/database-yjs/dispatch.ts | 190 +++++++++++++++--- src/application/database-yjs/group-color.ts | 34 ++++ src/application/database-yjs/index.ts | 1 + src/application/database-yjs/selector.ts | 6 + src/application/types.ts | 10 +- .../components/board/card/CardPrimitive.tsx | 4 +- .../components/board/card/NewCard.tsx | 124 ++++++------ .../components/board/column/CardList.tsx | 24 ++- .../components/board/column/Column.tsx | 27 ++- .../components/board/column/ColumnHeader.tsx | 33 +-- .../board/column/ColumnHeaderPrimitive.tsx | 13 +- .../components/board/column/ColumnMenu.tsx | 88 +++++++- .../board/column/boardColumnColor.ts | 111 ++++++++++ .../components/board/column/columnName.ts | 43 ++++ .../board/column/useRenderColumn.tsx | 44 ++-- .../components/board/group/Columns.tsx | 20 +- .../components/board/group/GroupHeader.tsx | 55 +++-- .../components/settings/BoardSettingGroup.tsx | 30 +-- .../database/components/settings/Layout.tsx | 26 ++- src/styles/app.scss | 13 +- 22 files changed, 798 insertions(+), 197 deletions(-) create mode 100644 src/application/database-yjs/group-color.ts create mode 100644 src/components/database/components/board/column/boardColumnColor.ts create mode 100644 src/components/database/components/board/column/columnName.ts diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index 4a2116567..eae6214a7 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -2559,6 +2559,8 @@ "addToColumnBottomTooltip": "Add a new card at the bottom", "renameColumn": "Rename", "hideColumn": "Hide", + "color": "Color", + "colorColumns": "Color columns", "newGroup": "New group", "deleteColumn": "Delete", "deleteCards": "This will delete all the cards in this group. Are you sure you want to continue?", @@ -2579,6 +2581,7 @@ "propertyName": "Property name", "menuName": "Board", "showUngrouped": "Show ungrouped items", + "colorColumns": "Color columns", "ungroupedButtonText": "Ungrouped", "ungroupedButtonTooltip": "Contains cards that don't belong in any group", "ungroupedItemsTitle": "Click to add to the board", diff --git a/src/application/database-yjs/__tests__/useGroup.test.tsx b/src/application/database-yjs/__tests__/useGroup.test.tsx index fc5e77e66..3c9461f66 100644 --- a/src/application/database-yjs/__tests__/useGroup.test.tsx +++ b/src/application/database-yjs/__tests__/useGroup.test.tsx @@ -1,8 +1,9 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import type React from 'react'; import * as Y from 'yjs'; -import { DatabaseContext, DatabaseContextState, FieldType, useGroup } from '@/application/database-yjs'; +import { DatabaseContext, DatabaseContextState, FieldType, GroupColorOption, useGroup } from '@/application/database-yjs'; +import { useUpdateGroupColumnColorDispatch } from '@/application/database-yjs/dispatch'; import { YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; jest.mock('@/utils/runtime-config', () => ({ @@ -14,11 +15,13 @@ function createDatabaseDoc({ groupId, groupColumns, viewId, + fieldType = FieldType.SingleSelect, }: { fieldId: string; groupId: string; groupColumns: unknown[]; viewId: string; + fieldType?: FieldType; }): YDoc { const doc = new Y.Doc() as unknown as YDoc; const sharedRoot = doc.getMap(YjsEditorKey.data_section); @@ -32,13 +35,13 @@ function createDatabaseDoc({ const columns = new Y.Array(); field.set(YjsDatabaseKey.id, fieldId); - field.set(YjsDatabaseKey.type, FieldType.SingleSelect); + field.set(YjsDatabaseKey.type, fieldType); fields.set(fieldId, field); columns.push(groupColumns); group.set(YjsDatabaseKey.id, groupId); group.set(YjsDatabaseKey.field_id, fieldId); - group.set(YjsDatabaseKey.type, FieldType.SingleSelect); + group.set(YjsDatabaseKey.type, fieldType); group.set(YjsDatabaseKey.groups, columns); groups.push([group]); @@ -53,6 +56,31 @@ function createDatabaseDoc({ return doc; } +function getPersistedColumns(databaseDoc: YDoc, viewId: string, groupId: string) { + const database = databaseDoc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database); + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const group = view + ?.get(YjsDatabaseKey.groups) + ?.toArray() + .find((group) => group.get(YjsDatabaseKey.id) === groupId); + + return group?.get(YjsDatabaseKey.groups)?.toArray() ?? []; +} + +function toColumnData(column: unknown) { + if (column && typeof column === 'object' && 'get' in column && typeof column.get === 'function') { + const mapColumn = column as { get: (key: YjsDatabaseKey) => unknown }; + + return { + id: mapColumn.get(YjsDatabaseKey.id), + visible: mapColumn.get(YjsDatabaseKey.visible), + group_color: mapColumn.get(YjsDatabaseKey.group_color), + }; + } + + return column; +} + function createWrapper(databaseDoc: YDoc, activeViewId: string) { const contextValue: DatabaseContextState = { readOnly: false, @@ -118,4 +146,64 @@ describe('useGroup', () => { expect(result.current.columns).toEqual([{ id: optionId, visible: false }]); }); + + it('persists fallback columns before updating a column color', async () => { + const fieldId = 'checkbox-field-id'; + const groupId = 'group-id'; + const viewId = 'board-view-id'; + const databaseDoc = createDatabaseDoc({ + fieldId, + groupId, + groupColumns: [], + viewId, + fieldType: FieldType.Checkbox, + }); + + const { result } = renderHook(() => useUpdateGroupColumnColorDispatch(groupId), { + wrapper: createWrapper(databaseDoc, viewId), + }); + + act(() => { + result.current('Yes', GroupColorOption.Camellia); + }); + + expect(getPersistedColumns(databaseDoc, viewId, groupId)).toEqual([ + { id: 'Yes', visible: true, group_color: GroupColorOption.Camellia }, + { id: 'No', visible: true }, + ]); + }); + + it('updates color on persisted Y.Map group columns', async () => { + const fieldId = 'checkbox-field-id'; + const groupId = 'group-id'; + const viewId = 'board-view-id'; + const yesColumn = new Y.Map(); + const noColumn = new Y.Map(); + + yesColumn.set(YjsDatabaseKey.id, 'Yes'); + yesColumn.set(YjsDatabaseKey.visible, true); + noColumn.set(YjsDatabaseKey.id, 'No'); + noColumn.set(YjsDatabaseKey.visible, true); + + const databaseDoc = createDatabaseDoc({ + fieldId, + groupId, + groupColumns: [yesColumn, noColumn], + viewId, + fieldType: FieldType.Checkbox, + }); + + const { result } = renderHook(() => useUpdateGroupColumnColorDispatch(groupId), { + wrapper: createWrapper(databaseDoc, viewId), + }); + + act(() => { + result.current('Yes', GroupColorOption.Olive); + }); + + expect(getPersistedColumns(databaseDoc, viewId, groupId).map(toColumnData)).toEqual([ + { id: 'Yes', visible: true, group_color: GroupColorOption.Olive }, + { id: 'No', visible: true, group_color: undefined }, + ]); + }); }); diff --git a/src/application/database-yjs/dispatch.ts b/src/application/database-yjs/dispatch.ts index 07dc55fbb..60a151a8e 100644 --- a/src/application/database-yjs/dispatch.ts +++ b/src/application/database-yjs/dispatch.ts @@ -38,6 +38,7 @@ import { createSelectOptionCell } from '@/application/database-yjs/fields/select import { createDateTimeField } from '@/application/database-yjs/fields/text/utils'; import { getDefaultFilterCondition } from '@/application/database-yjs/filter'; import { DEFAULT_FIELD_WRAP } from '@/application/database-yjs/const'; +import { getGroupColumns } from '@/application/database-yjs/group'; import { getOptionsFromRow } from '@/application/database-yjs/row'; import { getMetaIdMap } from '@/application/database-yjs/row_meta'; import { useBoardLayoutSettings, useCalendarLayoutSetting, useFieldType } from '@/application/database-yjs/selector'; @@ -228,6 +229,24 @@ function generateGroupByField(field: YDatabaseField) { return group; } +function getOrCreateBoardLayoutSetting(view: YDatabaseView) { + let layoutSettings = view.get(YjsDatabaseKey.layout_settings); + + if (!layoutSettings) { + layoutSettings = new Y.Map() as YDatabaseLayoutSettings; + view.set(YjsDatabaseKey.layout_settings, layoutSettings); + } + + let layoutSetting = layoutSettings.get('1'); + + if (!layoutSetting) { + layoutSetting = new Y.Map() as YDatabaseBoardLayoutSetting; + layoutSettings.set('1', layoutSetting); + } + + return layoutSetting; +} + export function useGroupByFieldDispatch() { const view = useDatabaseView(); const database = useDatabase(); @@ -428,10 +447,11 @@ export function useToggleHiddenGroupColumnDispatch(groupId: string, fieldId: str throw new Error('Group columns not found'); } - const index = columns.toArray().findIndex((column) => column.id === columnId); - const column = columns.toArray().find((column) => column.id === columnId); + const columnsArray = columns.toArray(); + const index = columnsArray.findIndex((column) => column.id === columnId); + const column = index === -1 ? undefined : columnsArray[index]; - if (index === -1 || !column) { + if (!column) { throw new Error(`Column with id ${columnId} not found in group ${groupId}`); } @@ -456,34 +476,137 @@ export function useToggleHiddenGroupColumnDispatch(groupId: string, fieldId: str ); } -export function useToggleCollapsedHiddenGroupColumnDispatch() { +type WritableGroupColumn = { + id: string; + visible: boolean; + group_color?: string; +}; + +function normalizeWritableGroupColumn(column: unknown): WritableGroupColumn | null { + const parseVisible = (value: unknown) => value !== false && value !== 'false'; + + if (!column || typeof column !== 'object') return null; + + if ('get' in column && typeof column.get === 'function') { + const mapColumn = column as { get: (key: YjsDatabaseKey) => unknown }; + const id = mapColumn.get(YjsDatabaseKey.id); + + if (typeof id !== 'string' || !id) return null; + + return { + id, + visible: parseVisible(mapColumn.get(YjsDatabaseKey.visible)), + group_color: mapColumn.get(YjsDatabaseKey.group_color) as string | undefined, + }; + } + + const plainColumn = column as Partial; + + if (typeof plainColumn.id !== 'string' || !plainColumn.id) return null; + + return { + id: plainColumn.id, + visible: parseVisible(plainColumn.visible), + group_color: plainColumn.group_color, + }; +} + +function getFallbackVisibleGroupColumns(field?: YDatabaseField): WritableGroupColumn[] { + if (!field) return []; + + return (getGroupColumns(field) ?? []).map((column) => ({ + id: column.id, + visible: true, + })); +} + +export function useUpdateGroupColumnColorDispatch(groupId: string) { + const database = useDatabase(); const view = useDatabaseView(); const sharedRoot = useSharedRoot(); return useCallback( - (collapsed: boolean) => { + (columnId: string, groupColor: string) => { executeOperations( sharedRoot, [ () => { - if (!view) { - throw new Error(`Unable to toggle collapsed hidden group column`); + const groups = view?.get(YjsDatabaseKey.groups); + + if (!groups) { + throw new Error('Groups not found'); } - // Get or create the layout settings for the view - let layoutSettings = view.get(YjsDatabaseKey.layout_settings); + const group = groups.toArray().find((group) => group.get(YjsDatabaseKey.id) === groupId); - if (!layoutSettings) { - layoutSettings = new Y.Map() as YDatabaseLayoutSettings; + if (!group) { + throw new Error(`Group with id ${groupId} not found`); } - let layoutSetting = layoutSettings.get('1'); + const columns = group.get(YjsDatabaseKey.groups); - if (!layoutSetting) { - layoutSetting = new Y.Map() as YDatabaseBoardLayoutSetting; - layoutSettings.set('1', layoutSetting); + if (!columns) { + throw new Error('Group columns not found'); + } + + const columnsArray = columns.toArray(); + let index = columnsArray.findIndex((column) => normalizeWritableGroupColumn(column)?.id === columnId); + let column = index === -1 ? undefined : normalizeWritableGroupColumn(columnsArray[index]); + + if (!column && columnsArray.map(normalizeWritableGroupColumn).every((column) => !column)) { + const fieldId = group.get(YjsDatabaseKey.field_id); + const field = database?.get(YjsDatabaseKey.fields)?.get(fieldId); + const fallbackColumns = getFallbackVisibleGroupColumns(field); + + if (fallbackColumns.length) { + if (columnsArray.length > 0) { + columns.delete(0, columnsArray.length); + } + + columns.push(fallbackColumns); + const nextColumnsArray = columns.toArray(); + + index = nextColumnsArray.findIndex((column) => normalizeWritableGroupColumn(column)?.id === columnId); + column = index === -1 ? undefined : normalizeWritableGroupColumn(nextColumnsArray[index]); + } + } + + if (!column) { + throw new Error(`Column with id ${columnId} not found in group ${groupId}`); + } + + columns.delete(index); + columns.insert(index, [ + { + ...column, + group_color: groupColor, + }, + ]); + }, + ], + 'updateGroupColumnColor' + ); + }, + [database, groupId, sharedRoot, view] + ); +} + +export function useToggleCollapsedHiddenGroupColumnDispatch() { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (collapsed: boolean) => { + executeOperations( + sharedRoot, + [ + () => { + if (!view) { + throw new Error(`Unable to toggle collapsed hidden group column`); } + const layoutSetting = getOrCreateBoardLayoutSetting(view); + layoutSetting.set(YjsDatabaseKey.collapse_hidden_groups, collapsed); }, ], @@ -508,24 +631,38 @@ export function useToggleHideUnGrouped() { throw new Error(`Unable to toggle hide ungrouped column`); } - // Get or create the layout settings for the view - let layoutSettings = view.get(YjsDatabaseKey.layout_settings); + const layoutSetting = getOrCreateBoardLayoutSetting(view); - if (!layoutSettings) { - layoutSettings = new Y.Map() as YDatabaseLayoutSettings; - } + layoutSetting.set(YjsDatabaseKey.hide_ungrouped_column, hide); + }, + ], + 'toggleHideUnGrouped' + ); + }, + [sharedRoot, view] + ); +} - let layoutSetting = layoutSettings.get('1'); +export function useToggleShowColorColumns() { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); - if (!layoutSetting) { - layoutSetting = new Y.Map() as YDatabaseBoardLayoutSetting; - layoutSettings.set('1', layoutSetting); + return useCallback( + (show: boolean) => { + executeOperations( + sharedRoot, + [ + () => { + if (!view) { + throw new Error(`Unable to toggle color columns`); } - layoutSetting.set(YjsDatabaseKey.hide_ungrouped_column, hide); + const layoutSetting = getOrCreateBoardLayoutSetting(view); + + layoutSetting.set(YjsDatabaseKey.show_color_columns, show); }, ], - 'toggleHideUnGrouped' + 'toggleShowColorColumns' ); }, [sharedRoot, view] @@ -1756,6 +1893,7 @@ function generateBoardLayoutSettings() { layoutSetting.set(YjsDatabaseKey.hide_ungrouped_column, false); layoutSetting.set(YjsDatabaseKey.collapse_hidden_groups, true); + layoutSetting.set(YjsDatabaseKey.show_color_columns, false); layoutSettings.set('1', layoutSetting); return layoutSettings; } diff --git a/src/application/database-yjs/group-color.ts b/src/application/database-yjs/group-color.ts new file mode 100644 index 000000000..c25884920 --- /dev/null +++ b/src/application/database-yjs/group-color.ts @@ -0,0 +1,34 @@ +export enum GroupColorOption { + DefaultOption = 'defaultOption', + Mauve = 'mauve', + Lilac = 'lilac', + Camellia = 'camellia', + Papaya = 'papaya', + Mango = 'mango', + Olive = 'olive', + Grass = 'grass', + Jade = 'jade', + Azure = 'azure', + Iron = 'iron', +} + +export const GROUP_COLOR_OPTIONS = Object.values(GroupColorOption); + +export function groupColorOptionFromName(name: string | undefined): GroupColorOption | undefined { + if (!name) return undefined; + return GROUP_COLOR_OPTIONS.find((option) => option === name); +} + +export function groupColorOptionByName(name: string): GroupColorOption { + if (!name) return GroupColorOption.DefaultOption; + + let hash = 0; + + for (let i = 0; i < name.length; i += 1) { + hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0; + } + + const index = Math.abs(hash) % (GROUP_COLOR_OPTIONS.length - 1); + + return GROUP_COLOR_OPTIONS[index + 1]; +} diff --git a/src/application/database-yjs/index.ts b/src/application/database-yjs/index.ts index c426ffdb8..d72dca04d 100644 --- a/src/application/database-yjs/index.ts +++ b/src/application/database-yjs/index.ts @@ -3,6 +3,7 @@ export * from './context'; export * from './database.type'; export * from './dispatch'; export * from './fields'; +export * from './group-color'; export * from './selector'; export * from './comment_dispatch'; export * from './comment_selector'; diff --git a/src/application/database-yjs/selector.ts b/src/application/database-yjs/selector.ts index 7088acb93..d08c8e8e5 100644 --- a/src/application/database-yjs/selector.ts +++ b/src/application/database-yjs/selector.ts @@ -947,6 +947,7 @@ export function useGroupsSelector() { export interface GroupColumn { id: string; visible: boolean; + group_color?: string; } function normalizeGroupColumn(column: unknown): GroupColumn | null { @@ -963,6 +964,7 @@ function normalizeGroupColumn(column: unknown): GroupColumn | null { return { id, visible: parseVisible(mapColumn.get(YjsDatabaseKey.visible)), + group_color: mapColumn.get(YjsDatabaseKey.group_color) as string | undefined, }; } @@ -973,6 +975,7 @@ function normalizeGroupColumn(column: unknown): GroupColumn | null { return { id: plainColumn.id, visible: parseVisible(plainColumn.visible), + group_color: plainColumn.group_color, }; } @@ -1035,6 +1038,7 @@ export function useBoardLayoutSettings() { const layoutSetting = view?.get(YjsDatabaseKey.layout_settings)?.get('1'); const [isCollapsed, setIsCollapsed] = useState(true); const [hideUnGroup, setHideUnGroup] = useState(false); + const [showColorColumns, setShowColorColumns] = useState(false); const groups = view?.get(YjsDatabaseKey.groups); const [fieldId, setFieldId] = useState(null); @@ -1044,6 +1048,7 @@ export function useBoardLayoutSettings() { const observerEvent = () => { setIsCollapsed(Boolean(layoutSetting?.get(YjsDatabaseKey.collapse_hidden_groups))); setHideUnGroup(Boolean(layoutSetting?.get(YjsDatabaseKey.hide_ungrouped_column))); + setShowColorColumns(Boolean(layoutSetting?.get(YjsDatabaseKey.show_color_columns))); }; observerEvent(); @@ -1076,6 +1081,7 @@ export function useBoardLayoutSettings() { return { isCollapsed, hideUnGroup, + showColorColumns, fieldId, }; } diff --git a/src/application/types.ts b/src/application/types.ts index c18918551..8c3429eaa 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -468,9 +468,11 @@ export enum YjsDatabaseKey { format = 'format', filter_type = 'filter_type', visible = 'visible', + group_color = 'group_color', collapsed_group_ids = 'collapsed_group_ids', hide_ungrouped_column = 'hide_ungrouped_column', collapse_hidden_groups = 'collapse_hidden_groups', + show_color_columns = 'show_color_columns', first_day_of_week = 'first_day_of_week', show_week_numbers = 'show_week_numbers', show_weekends = 'show_weekends', @@ -764,7 +766,9 @@ export interface YDatabaseLayoutSettings extends Y.Map { } export interface YDatabaseBoardLayoutSetting extends Y.Map { - get(key: YjsDatabaseKey.hide_ungrouped_column | YjsDatabaseKey.collapse_hidden_groups): boolean; + get( + key: YjsDatabaseKey.hide_ungrouped_column | YjsDatabaseKey.collapse_hidden_groups | YjsDatabaseKey.show_color_columns + ): boolean; } export interface YDatabaseCalendarLayoutSetting extends Y.Map { @@ -795,12 +799,14 @@ export interface YDatabaseGroup extends Y.Map { get(key: YjsDatabaseKey.collapsed_group_ids): Y.Array | string[] | undefined; } -export type YDatabaseGroupColumns = Y.Array<{ id: string; visible: boolean }>; +export type YDatabaseGroupColumns = Y.Array<{ id: string; visible: boolean; group_color?: string }>; export interface YDatabaseGroupColumn extends Y.Map { get(key: YjsDatabaseKey.id): string; get(key: YjsDatabaseKey.visible): boolean; + + get(key: YjsDatabaseKey.group_color): string | undefined; } export interface YDatabaseSort extends Y.Map { diff --git a/src/components/database/components/board/card/CardPrimitive.tsx b/src/components/database/components/board/card/CardPrimitive.tsx index a8e66275e..83257cc30 100644 --- a/src/components/database/components/board/card/CardPrimitive.tsx +++ b/src/components/database/components/board/card/CardPrimitive.tsx @@ -162,9 +162,9 @@ export const CardPrimitive = forwardRef( ref={ref} data-card-id={dataCardId} className={cn( - 'board-card relative flex flex-col overflow-hidden rounded-[6px] text-xs shadow-card', + 'board-card relative flex flex-col overflow-hidden rounded-[6px] text-xs', navigateToRow && 'cursor-pointer hover:bg-fill-content-hover', - selected && 'ring-1 ring-border-theme-thick', + selected && 'board-card-selected', className )} > diff --git a/src/components/database/components/board/card/NewCard.tsx b/src/components/database/components/board/card/NewCard.tsx index 52b3405d6..5e875c543 100644 --- a/src/components/database/components/board/card/NewCard.tsx +++ b/src/components/database/components/board/card/NewCard.tsx @@ -6,11 +6,12 @@ import { useNewRowDispatch } from '@/application/database-yjs/dispatch'; import { ReactComponent as PlusIcon } from '@/assets/icons/plus.svg'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; const BOUNDARY_GAP = 100; +const NEW_CARD_HEIGHT = 44; +const NEW_CARD_CONTROL_HEIGHT = 36; function NewCard({ beforeId, @@ -31,24 +32,24 @@ function NewCard({ const [value, setValue] = useState(''); const onNewCard = useNewRowDispatch(); const inputRef = useRef(null); - const ref = useRef(null); - const [container, setContainer] = useState(null); + const containerRef = useRef(null); const scrollToBottom = useCallback(() => { - if (!container) return; + if (!containerRef.current) return; - const scrollElement = container.closest('.appflowy-scroll-container') as HTMLDivElement; - const rect = container.getBoundingClientRect(); + const scrollElement = containerRef.current.closest('.appflowy-scroll-container') as HTMLDivElement; + const rect = containerRef.current.getBoundingClientRect(); const scrollY = rect.bottom + BOUNDARY_GAP - window.innerHeight; if (scrollY <= 0) return; + if (!scrollElement) return; scrollElement.scrollBy({ top: scrollY, behavior: 'smooth', }); - }, [container]); + }, []); const handleSubmit = useCallback( (inputValue: string) => { @@ -76,68 +77,73 @@ function NewCard({ ); useLayoutEffect(() => { - if (!ref.current) return; - - const el = ref.current.parentElement as HTMLDivElement | null; + if (!isCreating || !inputRef.current) return; - if (!el) return; + inputRef.current.focus(); + inputRef.current.setSelectionRange(0, inputRef.current.value.length); + }, [isCreating]); - setContainer(el); - }, []); + const handleClose = useCallback(() => { + setIsCreating(false); + }, [setIsCreating]); return ( - - - - - - e.preventDefault()} - onKeyDown={(e) => { - if (createHotkey(HOT_KEY_NAME.ENTER)(e.nativeEvent)) { - handleSubmit(value); - } - }} - > +
+ {isCreating ? ( { - if (!input) return; - if (!inputRef.current) { - setTimeout(() => { - input.setSelectionRange(0, input.value.length); - }, 100); - - inputRef.current = input; - } - }} + ref={inputRef} value={value} onChange={(e) => { setValue(e.target.value); }} - className={'!h-9 w-full !rounded-300 text-text-primary'} + onBlur={handleClose} + onKeyDown={(e) => { + if (createHotkey(HOT_KEY_NAME.ENTER)(e.nativeEvent)) { + e.preventDefault(); + e.stopPropagation(); + handleSubmit(value); + return; + } + + if (createHotkey(HOT_KEY_NAME.ESCAPE)(e.nativeEvent)) { + e.preventDefault(); + e.stopPropagation(); + handleClose(); + } + }} + className={'w-full !rounded-[6px] !bg-transparent px-3 text-text-primary shadow-none'} + style={{ + height: NEW_CARD_CONTROL_HEIGHT, + }} /> - - + ) : ( + + + + + {t('board.column.addToColumnBottomTooltip')} + + )} +
); } diff --git a/src/components/database/components/board/column/CardList.tsx b/src/components/database/components/board/column/CardList.tsx index 9cb0a3bd2..ed765cd85 100644 --- a/src/components/database/components/board/column/CardList.tsx +++ b/src/components/database/components/board/column/CardList.tsx @@ -1,7 +1,6 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { memo, useCallback, useLayoutEffect, useMemo, useRef } from 'react'; -import { PADDING_END } from '@/application/database-yjs'; import { useBoardActions, useBoardSelection } from '@/components/database/board/BoardProvider'; import { Card } from '@/components/database/components/board/card'; import { cn } from '@/lib/utils'; @@ -17,9 +16,10 @@ export interface RenderCard { } const CARD_LIST_MAX_HEIGHT = 2000; +const CARD_LIST_ESTIMATED_ITEM_HEIGHT = 50; const CARD_LIST_STYLE = { - maxHeight: CARD_LIST_MAX_HEIGHT, + maxHeight: `min(100%, ${CARD_LIST_MAX_HEIGHT}px)`, overflowY: 'auto', } as const; @@ -72,21 +72,22 @@ function CardList({ scrollMargin: 0, // Always 0 for Board - items are positioned relative to column top overscan: 5, getScrollElement, - estimateSize: () => 36, + estimateSize: () => CARD_LIST_ESTIMATED_ITEM_HEIGHT, paddingStart: 0, - paddingEnd: PADDING_END, + paddingEnd: 0, getItemKey: (index) => data[index].id || String(index), }); const virtualItems = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); const viewportHeight = Math.min( parentRef.current?.clientHeight || CARD_LIST_MAX_HEIGHT, CARD_LIST_MAX_HEIGHT ); const scrollTop = parentRef.current?.scrollTop ?? 0; - const renderStart = Math.max(0, scrollTop - 5 * 36); - const renderEnd = scrollTop + viewportHeight + 5 * 36; - const maxRenderedItems = Math.ceil(viewportHeight / 36) + 12; + const renderStart = Math.max(0, scrollTop - 5 * CARD_LIST_ESTIMATED_ITEM_HEIGHT); + const renderEnd = scrollTop + viewportHeight + 5 * CARD_LIST_ESTIMATED_ITEM_HEIGHT; + const maxRenderedItems = Math.ceil(viewportHeight / CARD_LIST_ESTIMATED_ITEM_HEIGHT) + 12; const items = virtualItems.length > maxRenderedItems ? virtualItems .filter((item) => item.end >= renderStart && item.start <= renderEnd) @@ -96,12 +97,15 @@ function CardList({ return (
void; groupId: string; + groupColor?: string; + showColorColumns: boolean; } function areRowsEqual(prevRows: Row[], nextRows: Row[]) { @@ -38,14 +41,22 @@ function areColumnPropsEqual(prev: ColumnProps, next: ColumnProps) { prev.id === next.id && prev.fieldId === next.fieldId && prev.groupId === next.groupId && + prev.groupColor === next.groupColor && + prev.showColorColumns === next.showColorColumns && prev.addCardBefore === next.addCardBefore && areRowsEqual(prev.rows, next.rows) ); } export const Column = memo( - ({ id, rows, fieldId, addCardBefore, groupId }: ColumnProps) => { + ({ id, rows, fieldId, addCardBefore, groupId, groupColor, showColorColumns }: ColumnProps) => { const readOnly = useReadOnly(); + const { style: colorStyle, option: colorOption } = useBoardColumnColor({ + id, + fieldId, + groupColor, + showColorColumns, + }); const data: RenderCard[] = useMemo(() => { const cards = rows.map((row) => ({ @@ -75,14 +86,15 @@ export const Column = memo( return ( -
+
- +
{state.type === StateType.IS_COLUMN_OVER && state.closestEdge && ( diff --git a/src/components/database/components/board/column/ColumnHeader.tsx b/src/components/database/components/board/column/ColumnHeader.tsx index cd883d217..af677e827 100644 --- a/src/components/database/components/board/column/ColumnHeader.tsx +++ b/src/components/database/components/board/column/ColumnHeader.tsx @@ -1,16 +1,18 @@ - import { Row, useReadOnly } from '@/application/database-yjs'; +import { useBoardColumnColor } from '@/components/database/components/board/column/boardColumnColor'; import ColumnHeaderPrimitive from '@/components/database/components/board/column/ColumnHeaderPrimitive'; import { useColumnHeaderDrag, StateType } from '@/components/database/components/board/column/useColumnHeaderDrag'; import { DropColumnIndicator } from '@/components/database/components/board/drag-and-drop/DropColumnIndicator'; -function ColumnHeader ({ +function ColumnHeader({ id, fieldId, rowCount, addCardBefore, getCards, groupId, + groupColor, + showColorColumns, }: { id: string; fieldId: string; @@ -18,14 +20,17 @@ function ColumnHeader ({ addCardBefore: (id: string) => void; getCards: (id: string) => Row[]; groupId: string; + groupColor?: string; + showColorColumns: boolean; }) { - const { - columnRef, - headerRef, - state, - isDragging, - } = useColumnHeaderDrag(id); + const { columnRef, headerRef, state, isDragging } = useColumnHeaderDrag(id); const readOnly = useReadOnly(); + const { style: colorStyle, option: colorOption } = useBoardColumnColor({ + id, + fieldId, + groupColor, + showColorColumns, + }); return (
- {state.type === StateType.IS_COLUMN_OVER && state.closestEdge && ( - - )} + {state.type === StateType.IS_COLUMN_OVER && state.closestEdge && }
); } -export default ColumnHeader; \ No newline at end of file +export default ColumnHeader; diff --git a/src/components/database/components/board/column/ColumnHeaderPrimitive.tsx b/src/components/database/components/board/column/ColumnHeaderPrimitive.tsx index fe543829c..160904918 100644 --- a/src/components/database/components/board/column/ColumnHeaderPrimitive.tsx +++ b/src/components/database/components/board/column/ColumnHeaderPrimitive.tsx @@ -1,9 +1,10 @@ import { forwardRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Row, useReadOnly } from '@/application/database-yjs'; +import { GroupColorOption, Row, useReadOnly } from '@/application/database-yjs'; import { ReactComponent as MoreIcon } from '@/assets/icons/more.svg'; import { ReactComponent as AddIcon } from '@/assets/icons/plus.svg'; +import { BoardColumnColorStyle } from '@/components/database/components/board/column/boardColumnColor'; import { ColumnMenu } from '@/components/database/components/board/column/ColumnMenu'; import { useRenderColumn } from '@/components/database/components/board/column/useRenderColumn'; import { Button } from '@/components/ui/button'; @@ -19,6 +20,9 @@ function ColumnHeaderPrimitive( addCardBefore, getCards, groupId, + colorStyle, + colorOption, + showColorColumns, ...props }: { id: string; @@ -27,10 +31,13 @@ function ColumnHeaderPrimitive( getCards: (id: string) => Row[]; addCardBefore: (id: string) => void; groupId: string; + colorStyle?: BoardColumnColorStyle; + colorOption?: GroupColorOption; + showColorColumns?: boolean; } & React.HTMLAttributes, ref: React.Ref ) { - const { header, renameEnabled, deleteEnabled, hideEnabled } = useRenderColumn(id, fieldId); + const { header, renameEnabled, deleteEnabled, hideEnabled } = useRenderColumn(id, fieldId, colorStyle); const { t } = useTranslation(); const readOnly = useReadOnly(); @@ -57,6 +64,8 @@ function ColumnHeaderPrimitive( deleteEnabled={deleteEnabled} hideEnabled={hideEnabled} getCards={getCards} + showColorColumns={Boolean(showColorColumns)} + currentColorOption={colorOption} > + ))} + + )}
diff --git a/src/components/database/components/board/column/boardColumnColor.ts b/src/components/database/components/board/column/boardColumnColor.ts new file mode 100644 index 000000000..a11965d9f --- /dev/null +++ b/src/components/database/components/board/column/boardColumnColor.ts @@ -0,0 +1,111 @@ +import { useMemo } from 'react'; + +import { + GROUP_COLOR_OPTIONS, + GroupColorOption, + groupColorOptionByName, + groupColorOptionFromName, +} from '@/application/database-yjs'; +import { ColorEnum, renderColor, toBlockColor } from '@/utils/color'; + +import { useBoardColumnName } from './columnName'; + +export interface BoardColumnColorStyle { + backgroundColor: string; + labelBackgroundColor: string; + paletteColor: string; + textColor: string; +} + +export const BOARD_COLUMN_COLOR_OPTIONS = GROUP_COLOR_OPTIONS; + +export type BoardColumnColorLabelKey = + | 'colors.default' + | 'colors.mauve' + | 'colors.lilac' + | 'colors.camellia' + | 'colors.papaya' + | 'colors.mango' + | 'colors.olive' + | 'colors.grass' + | 'colors.jade' + | 'colors.azure' + | 'colors.gray'; + +const GROUP_COLOR_TO_TINT: Partial> = { + [GroupColorOption.Mauve]: ColorEnum.Tint1, + [GroupColorOption.Lilac]: ColorEnum.Tint2, + [GroupColorOption.Camellia]: ColorEnum.Tint3, + [GroupColorOption.Papaya]: ColorEnum.Tint4, + [GroupColorOption.Mango]: ColorEnum.Tint5, + [GroupColorOption.Olive]: ColorEnum.Tint6, + [GroupColorOption.Grass]: ColorEnum.Tint7, + [GroupColorOption.Jade]: ColorEnum.Tint8, + [GroupColorOption.Azure]: ColorEnum.Tint9, + [GroupColorOption.Iron]: ColorEnum.Tint10, +}; + +const GROUP_COLOR_LABEL_KEYS: Record = { + [GroupColorOption.DefaultOption]: 'colors.default', + [GroupColorOption.Mauve]: 'colors.mauve', + [GroupColorOption.Lilac]: 'colors.lilac', + [GroupColorOption.Camellia]: 'colors.camellia', + [GroupColorOption.Papaya]: 'colors.papaya', + [GroupColorOption.Mango]: 'colors.mango', + [GroupColorOption.Olive]: 'colors.olive', + [GroupColorOption.Grass]: 'colors.grass', + [GroupColorOption.Jade]: 'colors.jade', + [GroupColorOption.Azure]: 'colors.azure', + [GroupColorOption.Iron]: 'colors.gray', +}; + +export function getBoardColumnColorStyle(option: GroupColorOption | undefined): BoardColumnColorStyle | undefined { + if (!option || option === GroupColorOption.DefaultOption) return undefined; + + const tint = GROUP_COLOR_TO_TINT[option]; + + if (!tint) return undefined; + + const blockColor = toBlockColor(tint); + + return { + backgroundColor: renderColor(blockColor.bg), + labelBackgroundColor: renderColor(blockColor.border), + paletteColor: renderColor(tint), + textColor: renderColor(blockColor.text), + }; +} + +export function getBoardColumnColorLabelKey(option: GroupColorOption) { + return GROUP_COLOR_LABEL_KEYS[option]; +} + +export function useBoardColumnColor({ + id, + fieldId, + groupColor, + showColorColumns, +}: { + id: string; + fieldId: string; + groupColor?: string; + showColorColumns: boolean; +}) { + const columnName = useBoardColumnName(id, fieldId); + + return useMemo(() => { + if (!showColorColumns || id === fieldId) { + return { + option: undefined, + style: undefined, + }; + } + + const option = groupColorOptionFromName(groupColor) ?? groupColorOptionByName(columnName || id); + + return { + option, + style: getBoardColumnColorStyle(option), + }; + }, [columnName, fieldId, groupColor, id, showColorColumns]); +} diff --git a/src/components/database/components/board/column/columnName.ts b/src/components/database/components/board/column/columnName.ts new file mode 100644 index 000000000..3a860c0a1 --- /dev/null +++ b/src/components/database/components/board/column/columnName.ts @@ -0,0 +1,43 @@ +import { TFunction } from 'i18next'; +import { useTranslation } from 'react-i18next'; + +import { FieldType, parseSelectOptionTypeOptions, useFieldSelector } from '@/application/database-yjs'; +import { getChecked } from '@/application/database-yjs/fields/checkbox/utils'; +import { YDatabaseField, YjsDatabaseKey } from '@/application/types'; + +export function getBoardColumnName({ + id, + fieldId, + field, + fieldType, + t, +}: { + id: string; + fieldId: string; + field?: YDatabaseField; + fieldType: FieldType; + t: TFunction; +}) { + if (!field) return id; + + if (fieldType === FieldType.Checkbox) { + return getChecked(id) ? t('button.yes') : t('button.no'); + } + + if ([FieldType.SingleSelect, FieldType.MultiSelect].includes(fieldType)) { + const fieldName = field.get(YjsDatabaseKey.name) || ''; + const option = parseSelectOptionTypeOptions(field)?.options.find((option) => option?.id === id); + + return fieldId === id ? `${t('button.no')} ${fieldName}` : option?.name || id; + } + + return id; +} + +export function useBoardColumnName(id: string, fieldId: string) { + const { field } = useFieldSelector(fieldId); + const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType; + const { t } = useTranslation(); + + return getBoardColumnName({ id, fieldId, field, fieldType, t }); +} diff --git a/src/components/database/components/board/column/useRenderColumn.tsx b/src/components/database/components/board/column/useRenderColumn.tsx index 76bd219a1..1d3929ae3 100644 --- a/src/components/database/components/board/column/useRenderColumn.tsx +++ b/src/components/database/components/board/column/useRenderColumn.tsx @@ -8,45 +8,63 @@ import { YjsDatabaseKey } from '@/application/types'; import { ReactComponent as CheckboxCheckSvg } from '@/assets/icons/check_filled.svg'; import { ReactComponent as CheckboxUncheckSvg } from '@/assets/icons/uncheck.svg'; import { Tag } from '@/components/_shared/tag'; +import { BoardColumnColorStyle } from '@/components/database/components/board/column/boardColumnColor'; +import { getBoardColumnName } from '@/components/database/components/board/column/columnName'; import { SelectOptionColorMap, SelectOptionFgColorMap } from '@/components/database/components/cell/cell.const'; -export function useRenderColumn(id: string, fieldId: string) { +function BoardColumnLabel({ label, colorStyle }: { label: string; colorStyle: BoardColumnColorStyle }) { + return ( +
+
{label}
+
+ ); +} + +export function useRenderColumn(id: string, fieldId: string, colorStyle?: BoardColumnColorStyle) { const { field, clock } = useFieldSelector(fieldId); const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType; - const fieldName = field?.get(YjsDatabaseKey.name) || ''; const { t } = useTranslation(); + const label = getBoardColumnName({ id, fieldId, field, fieldType, t }); const header = useMemo(() => { if (!field) return null; if (fieldType === FieldType.Checkbox) return ( -
+
{getChecked(id) ? ( <> - {t('button.checked')} + {label} ) : ( <> {' '} - {t('button.unchecked')} + {label} )}
); if ([FieldType.SingleSelect, FieldType.MultiSelect].includes(fieldType)) { const option = parseSelectOptionTypeOptions(field)?.options.find((option) => option?.id === id); - const isFieldId = fieldId === id; - const label = isFieldId ? `${t('button.no')} ${fieldName}` : option?.name || ''; return ( - + {colorStyle ? ( + + ) : ( + + )} ); @@ -54,7 +72,7 @@ export function useRenderColumn(id: string, fieldId: string) { return null; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [field, clock, fieldType, id, fieldName, t]); + }, [field, clock, fieldType, id, label, colorStyle]); const renameEnabled = useMemo(() => { return [FieldType.SingleSelect, FieldType.MultiSelect].includes(fieldType); diff --git a/src/components/database/components/board/group/Columns.tsx b/src/components/database/components/board/group/Columns.tsx index 4072f61ef..1d12c797d 100644 --- a/src/components/database/components/board/group/Columns.tsx +++ b/src/components/database/components/board/group/Columns.tsx @@ -1,6 +1,13 @@ import { forwardRef, useCallback, useMemo } from 'react'; -import { FieldType, GroupColumn, Row, useFieldType, useReadOnly } from '@/application/database-yjs'; +import { + FieldType, + GroupColumn, + Row, + useBoardLayoutSettings, + useFieldType, + useReadOnly, +} from '@/application/database-yjs'; import { Column } from '@/components/database/components/board/column'; import HiddenGroupColumn from '@/components/database/components/board/column/HiddenGroupColumn'; import AddGroupColumn from '@/components/database/components/board/group/AddGroupColumn'; @@ -45,13 +52,22 @@ const Columns = forwardRef< }, [columns, groupResult]); const readOnly = useReadOnly(); + const { showColorColumns } = useBoardLayoutSettings(); return (
{!readOnly && } {columnsWithRows.map((data) => ( - + ))} {isSelectField && !readOnly && }
diff --git a/src/components/database/components/board/group/GroupHeader.tsx b/src/components/database/components/board/group/GroupHeader.tsx index e351717bf..f72621785 100644 --- a/src/components/database/components/board/group/GroupHeader.tsx +++ b/src/components/database/components/board/group/GroupHeader.tsx @@ -1,41 +1,39 @@ import { forwardRef, useCallback, useMemo } from 'react'; -import { FieldType, GroupColumn, Row, useFieldType, useReadOnly } from '@/application/database-yjs'; +import { + FieldType, + GroupColumn, + Row, + useBoardLayoutSettings, + useFieldType, + useReadOnly, +} from '@/application/database-yjs'; import ColumnHeader from '@/components/database/components/board/column/ColumnHeader'; import HiddenGroupColumnHeader from '@/components/database/components/board/column/HiddenGroupColumnHeader'; import AddGroupColumn from '@/components/database/components/board/group/AddGroupColumn'; -const GroupHeader = forwardRef; - addCardBefore: (id: string) => void; - groupId: string; -}>(({ - columns, - fieldId, - groupResult, - addCardBefore, - groupId, -}, ref) => { +const GroupHeader = forwardRef< + HTMLDivElement, + { + columns: GroupColumn[]; + fieldId: string; + + groupResult: Map; + addCardBefore: (id: string) => void; + groupId: string; + } +>(({ columns, fieldId, groupResult, addCardBefore, groupId }, ref) => { const readOnly = useReadOnly(); + const { showColorColumns } = useBoardLayoutSettings(); const getCards = useCallback((id: string) => groupResult.get(id) || [], [groupResult]); const fieldType = useFieldType(fieldId); const isSelectField = useMemo(() => { - return [ - FieldType.SingleSelect, - FieldType.MultiSelect, - ].includes(fieldType); + return [FieldType.SingleSelect, FieldType.MultiSelect].includes(fieldType); }, [fieldType]); return ( -
- +
{!readOnly && } {columns.map((data) => ( @@ -47,14 +45,13 @@ const GroupHeader = forwardRef ))} - {isSelectField && !readOnly && } + {isSelectField && !readOnly && }
); }); -export default GroupHeader; \ No newline at end of file +export default GroupHeader; diff --git a/src/components/database/components/settings/BoardSettingGroup.tsx b/src/components/database/components/settings/BoardSettingGroup.tsx index 82aeddfd5..9cb1eadb2 100644 --- a/src/components/database/components/settings/BoardSettingGroup.tsx +++ b/src/components/database/components/settings/BoardSettingGroup.tsx @@ -10,23 +10,23 @@ import { DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, - DropdownMenuItem, DropdownMenuLabel, DropdownMenuItemTick, DropdownMenuSeparator, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuItemTick, + DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; import { Switch } from '@/components/ui/switch'; -function BoardSettingGroup () { +function BoardSettingGroup() { const { t } = useTranslation(); - const { - hideUnGroup, - fieldId, - } = useBoardLayoutSettings(); + const { hideUnGroup, fieldId } = useBoardLayoutSettings(); const fieldType = useFieldType(fieldId || ''); const toggle = useToggleHideUnGrouped(); const groupBy = useGroupByFieldDispatch(); const { properties: allProperties } = usePropertiesSelector(true); const properties = useMemo(() => { - return allProperties.filter(property => { + return allProperties.filter((property) => { const type = property.type; return [ @@ -47,9 +47,7 @@ function BoardSettingGroup () { {t('grid.settings.group')} - + {fieldType !== FieldType.Checkbox && ( <> {t('board.showUngrouped')} - - + )} {t('board.groupBy')} - {properties.map(property => ( + {properties.map((property) => ( {fieldId === property.id && } - ))} - ); } -export default BoardSettingGroup; \ No newline at end of file +export default BoardSettingGroup; diff --git a/src/components/database/components/settings/Layout.tsx b/src/components/database/components/settings/Layout.tsx index 069fc7feb..3af8b8254 100644 --- a/src/components/database/components/settings/Layout.tsx +++ b/src/components/database/components/settings/Layout.tsx @@ -1,24 +1,30 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useDatabaseViewId } from '@/application/database-yjs'; -import { useUpdateDatabaseLayout } from '@/application/database-yjs/dispatch'; +import { useBoardLayoutSettings, useDatabaseViewId } from '@/application/database-yjs'; +import { useToggleShowColorColumns, useUpdateDatabaseLayout } from '@/application/database-yjs/dispatch'; import { DatabaseViewLayout } from '@/application/types'; import { ReactComponent as LayoutIcon } from '@/assets/icons/layout.svg'; +import { ReactComponent as PaletteIcon } from '@/assets/icons/palette.svg'; import { DropdownMenuItem, DropdownMenuItemTick, DropdownMenuPortal, + DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, } from '@/components/ui/dropdown-menu'; +import { Switch } from '@/components/ui/switch'; function Layout({ currentLayout }: { currentLayout: DatabaseViewLayout }) { const { t } = useTranslation(); const viewId = useDatabaseViewId(); const updateLayout = useUpdateDatabaseLayout(viewId); + const toggleColorColumns = useToggleShowColorColumns(); + const { showColorColumns } = useBoardLayoutSettings(); + const isBoardLayout = currentLayout === DatabaseViewLayout.Board; const options = useMemo( () => [ { @@ -61,6 +67,22 @@ function Layout({ currentLayout }: { currentLayout: DatabaseViewLayout }) { {currentLayout === option.value && } ))} + {isBoardLayout && ( + <> + + { + e.preventDefault(); + toggleColorColumns(!showColorColumns); + }} + > + + {t('board.column.colorColumns')} + + + + )} diff --git a/src/styles/app.scss b/src/styles/app.scss index d4a978b2a..61c0240b9 100644 --- a/src/styles/app.scss +++ b/src/styles/app.scss @@ -177,9 +177,20 @@ body { } .board-card { - @apply border-border-primary border-[1px]; border-radius: 6px; background: var(--background-primary); + border: 1px solid rgb(31 35 41 / 12%); + box-shadow: + 0 0 4px rgb(31 35 41 / 2%), + 0 0 4px -2px rgb(31 35 41 / 2%); +} + +:root[data-dark-mode='true'] .board-card { + border-color: #59647a; +} + +.board-card.board-card-selected { + border-color: var(--border-theme-thick); } .no-scrollbar::-webkit-scrollbar { From 2531291755052a7eb703764e5823960b895facc4 Mon Sep 17 00:00:00 2001 From: nathan Date: Sun, 7 Jun 2026 14:12:25 +0800 Subject: [PATCH 2/3] refactor: use select option colors for board columns --- .../e2e/database/board-column-color.spec.ts | 322 ++++++++++++++++++ .../database-yjs/__tests__/useGroup.test.tsx | 90 +---- src/application/database-yjs/dispatch.ts | 116 ------- src/application/database-yjs/group-color.ts | 34 -- src/application/database-yjs/index.ts | 1 - src/application/database-yjs/selector.ts | 3 - src/application/types.ts | 5 +- .../components/board/column/Column.tsx | 24 +- .../components/board/column/ColumnHeader.tsx | 7 +- .../board/column/ColumnHeaderPrimitive.tsx | 10 +- .../components/board/column/ColumnMenu.tsx | 62 ++-- .../board/column/boardColumnColor.ts | 132 ++++--- .../board/column/useRenderColumn.tsx | 35 +- .../components/board/group/Columns.tsx | 1 - .../components/board/group/GroupHeader.tsx | 1 - src/styles/app.scss | 5 +- 16 files changed, 470 insertions(+), 378 deletions(-) create mode 100644 playwright/e2e/database/board-column-color.spec.ts delete mode 100644 src/application/database-yjs/group-color.ts diff --git a/playwright/e2e/database/board-column-color.spec.ts b/playwright/e2e/database/board-column-color.spec.ts new file mode 100644 index 000000000..e3917f4ec --- /dev/null +++ b/playwright/e2e/database/board-column-color.spec.ts @@ -0,0 +1,322 @@ +import { expect, test, type APIRequestContext, type Locator, type Page } from '@playwright/test'; + +import { + BoardSelectors, + DatabaseGridSelectors, + DatabaseViewSelectors, + GridFieldSelectors, + PropertyMenuSelectors, + SingleSelectSelectors, +} from '../../support/selectors'; +import { signInAndCreateDatabaseView } from '../../support/database-ui-helpers'; +import { generateRandomEmail, setupPageErrorHandling } from '../../support/test-config'; +import { waitForDatabaseDocReady } from '../../support/yjs-inject-helpers'; + +type SelectOptionColor = 'Blue' | 'Lime'; + +interface SelectOptionInfo { + fieldId: string; + optionId: string; + color: string; +} + +const colorTargets: Record = { + Blue: { + fillVar: '--tag-fill-09-light', + }, + Lime: { + fillVar: '--tag-fill-06-light', + }, +}; + +test.describe('Board column color', () => { + test.beforeEach(async ({ page }) => { + setupPageErrorHandling(page); + await page.setViewportSize({ width: 1280, height: 720 }); + }); + + test('updates the board group color when the backing select option color changes', async ({ page, request }) => { + const testEmail = generateRandomEmail(); + + await createBoardAndWait(page, request, testEmail); + await waitForDatabaseDocReady(page); + + await enableBoardColorColumnsViaYjs(page); + + const todoOption = await findSelectOptionByNameViaYjs(page, 'To Do'); + const targetColor: SelectOptionColor = todoOption.color === 'Blue' ? 'Lime' : 'Blue'; + + await updateSelectOptionColorViaYjs(page, todoOption.optionId, targetColor); + + await expect + .poll(() => findSelectOptionByIdViaYjs(page, todoOption.optionId).then((option) => option.color)) + .toBe(targetColor); + + const column = BoardSelectors.boardContainer(page).locator(`[data-column-id="${todoOption.optionId}"]`); + const columnSurface = getColumnSurface(column); + const expectedBackgroundColor = await resolveCssColor(page, colorTargets[targetColor].fillVar); + + await expect(column).toBeVisible({ timeout: 15000 }); + await expect.poll(() => getComputedBackgroundColor(columnSurface)).toBe(expectedBackgroundColor); + }); + + test('updates the board group color after editing the shared option color from a grid view', async ({ + page, + request, + }) => { + const testEmail = generateRandomEmail(); + + await createBoardAndWait(page, request, testEmail); + await waitForDatabaseDocReady(page); + await enableBoardColorColumnsViaYjs(page); + + const todoOption = await findSelectOptionByNameViaYjs(page, 'To Do'); + const targetColor: SelectOptionColor = todoOption.color === 'Blue' ? 'Lime' : 'Blue'; + const target = colorTargets[targetColor]; + + await addDatabaseViewFromTabBar(page, 'Grid'); + await expect(DatabaseGridSelectors.grid(page)).toBeVisible({ timeout: 15000 }); + + await openGridSelectOptionEditor(page, todoOption); + await selectOptionMenuColor(page, target.fillVar); + + await expect + .poll(() => findSelectOptionByIdViaYjs(page, todoOption.optionId).then((option) => option.color)) + .toBe(targetColor); + + await closeOpenMenus(page); + await switchDatabaseViewByName(page, 'Board'); + + const column = BoardSelectors.boardContainer(page).locator(`[data-column-id="${todoOption.optionId}"]`); + const columnSurface = getColumnSurface(column); + const expectedBackgroundColor = await resolveCssColor(page, target.fillVar); + + await expect(column).toBeVisible({ timeout: 15000 }); + await expect.poll(() => getComputedBackgroundColor(columnSurface)).toBe(expectedBackgroundColor); + }); +}); + +async function createBoardAndWait(page: Page, request: APIRequestContext, testEmail: string) { + await signInAndCreateDatabaseView(page, request, testEmail, 'Board', { + verify: async (p) => { + await expect(BoardSelectors.boardContainer(p)).toBeVisible({ timeout: 15000 }); + await expect(BoardSelectors.boardContainer(p).locator('[data-column-id]').first()).toBeVisible({ + timeout: 15000, + }); + await expect(BoardSelectors.boardContainer(p).getByText('To Do')).toBeVisible({ timeout: 15000 }); + }, + }); +} + +async function enableBoardColorColumnsViaYjs(page: Page): Promise { + await page.evaluate(() => { + const win = window as any; + const doc = win.__TEST_DATABASE_DOC__; + const Y = win.Y; + const sharedRoot = doc.getMap('data'); + const database = sharedRoot.get('database'); + const view = database.get('views').get(win.__TEST_DATABASE_VIEW_ID__); + + doc.transact(() => { + let layoutSettings = view.get('layout_settings'); + + if (!layoutSettings) { + layoutSettings = new Y.Map(); + view.set('layout_settings', layoutSettings); + } + + let boardLayoutSettings = layoutSettings.get('1'); + + if (!boardLayoutSettings) { + boardLayoutSettings = new Y.Map(); + layoutSettings.set('1', boardLayoutSettings); + } + + boardLayoutSettings.set('show_color_columns', true); + }, 'remote'); + }); +} + +async function findSelectOptionByNameViaYjs(page: Page, optionName: string): Promise { + const option = await page.evaluate((name) => { + const win = window as any; + const doc = win.__TEST_DATABASE_DOC__; + const sharedRoot = doc.getMap('data'); + const database = sharedRoot.get('database'); + const fields = database.get('fields'); + let result: SelectOptionInfo | undefined; + + fields.forEach((field: any, fieldId: string) => { + if (result) return; + + const fieldType = String(field.get('ty')); + const typeOption = field.get('type_option')?.get(fieldType); + const content = typeOption?.get('content'); + + if (!content) return; + + const parsed = JSON.parse(content) as { options?: Array<{ id: string; name: string; color: string }> }; + const selectOption = parsed.options?.find((option) => option.name === name); + + if (!selectOption) return; + + result = { + fieldId, + optionId: selectOption.id, + color: selectOption.color, + }; + }); + + return result; + }, optionName); + + if (!option) { + throw new Error(`Select option "${optionName}" was not found`); + } + + return option; +} + +async function findSelectOptionByIdViaYjs(page: Page, optionId: string): Promise { + const option = await page.evaluate((id) => { + const win = window as any; + const doc = win.__TEST_DATABASE_DOC__; + const sharedRoot = doc.getMap('data'); + const database = sharedRoot.get('database'); + const fields = database.get('fields'); + let result: SelectOptionInfo | undefined; + + fields.forEach((field: any, fieldId: string) => { + if (result) return; + + const fieldType = String(field.get('ty')); + const typeOption = field.get('type_option')?.get(fieldType); + const content = typeOption?.get('content'); + + if (!content) return; + + const parsed = JSON.parse(content) as { options?: Array<{ id: string; color: string }> }; + const selectOption = parsed.options?.find((option) => option.id === id); + + if (!selectOption) return; + + result = { + fieldId, + optionId: selectOption.id, + color: selectOption.color, + }; + }); + + return result; + }, optionId); + + if (!option) { + throw new Error(`Select option "${optionId}" was not found`); + } + + return option; +} + +async function updateSelectOptionColorViaYjs(page: Page, optionId: string, color: SelectOptionColor): Promise { + await page.evaluate( + ({ optionId, color }) => { + const win = window as any; + const doc = win.__TEST_DATABASE_DOC__; + const sharedRoot = doc.getMap('data'); + const database = sharedRoot.get('database'); + const fields = database.get('fields'); + let updated = false; + + fields.forEach((field: any) => { + if (updated) return; + + const fieldType = String(field.get('ty')); + const typeOption = field.get('type_option')?.get(fieldType); + const content = typeOption?.get('content'); + + if (!content) return; + + const parsed = JSON.parse(content) as { options?: Array<{ id: string; color: string }> }; + const options = parsed.options ?? []; + + if (!options.some((option) => option.id === optionId)) return; + + doc.transact(() => { + typeOption.set( + 'content', + JSON.stringify({ + ...parsed, + options: options.map((option) => (option.id === optionId ? { ...option, color } : option)), + }) + ); + }, 'remote'); + + updated = true; + }); + + if (!updated) { + throw new Error(`Select option "${optionId}" was not found`); + } + }, + { optionId, color } + ); +} + +async function addDatabaseViewFromTabBar(page: Page, viewType: 'Grid' | 'Board' | 'Calendar' | 'Chart'): Promise { + await DatabaseViewSelectors.addViewButton(page).click({ force: true }); + + const menu = page.locator('[data-slot="dropdown-menu-content"]').last(); + + await expect(menu).toBeVisible({ timeout: 5000 }); + await menu.getByRole('menuitem', { name: new RegExp(`^${viewType}$`, 'i') }).click({ force: true }); +} + +async function openGridSelectOptionEditor(page: Page, option: SelectOptionInfo): Promise { + await GridFieldSelectors.fieldHeader(page, option.fieldId).last().click({ force: true }); + await expect(PropertyMenuSelectors.editPropertyMenuItem(page).first()).toBeVisible({ timeout: 10000 }); + await PropertyMenuSelectors.editPropertyMenuItem(page).first().click({ force: true }); + await expect(SingleSelectSelectors.selectOption(page, option.optionId)).toBeVisible({ timeout: 10000 }); + await SingleSelectSelectors.selectOption(page, option.optionId).click({ force: true }); +} + +async function selectOptionMenuColor(page: Page, fillVar: string): Promise { + const menu = page.locator('[data-slot="dropdown-menu-content"]').last(); + const colorTile = menu.locator(`div[style*="${fillVar}"]`).first(); + + await expect(colorTile).toBeVisible({ timeout: 5000 }); + await colorTile.click({ force: true }); +} + +async function closeOpenMenus(page: Page): Promise { + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); +} + +async function switchDatabaseViewByName(page: Page, viewName: string): Promise { + await DatabaseViewSelectors.viewTab(page).filter({ hasText: viewName }).first().click({ force: true }); + await expect(BoardSelectors.boardContainer(page)).toBeVisible({ timeout: 15000 }); +} + +function getColumnSurface(column: Locator): Locator { + return column.locator(':scope > div').first(); +} + +async function getComputedBackgroundColor(locator: Locator): Promise { + return locator.evaluate((element) => getComputedStyle(element).backgroundColor); +} + +async function resolveCssColor(page: Page, cssVariable: string): Promise { + return page.evaluate((variable) => { + const value = getComputedStyle(document.documentElement).getPropertyValue(variable).trim(); + const probe = document.createElement('div'); + + probe.style.backgroundColor = value; + document.body.appendChild(probe); + + const computedColor = getComputedStyle(probe).backgroundColor; + + probe.remove(); + + return computedColor; + }, cssVariable); +} diff --git a/src/application/database-yjs/__tests__/useGroup.test.tsx b/src/application/database-yjs/__tests__/useGroup.test.tsx index 3c9461f66..9ab7c71ba 100644 --- a/src/application/database-yjs/__tests__/useGroup.test.tsx +++ b/src/application/database-yjs/__tests__/useGroup.test.tsx @@ -1,9 +1,8 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; +import { renderHook, waitFor } from '@testing-library/react'; import type React from 'react'; import * as Y from 'yjs'; -import { DatabaseContext, DatabaseContextState, FieldType, GroupColorOption, useGroup } from '@/application/database-yjs'; -import { useUpdateGroupColumnColorDispatch } from '@/application/database-yjs/dispatch'; +import { DatabaseContext, DatabaseContextState, FieldType, useGroup } from '@/application/database-yjs'; import { YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; jest.mock('@/utils/runtime-config', () => ({ @@ -56,31 +55,6 @@ function createDatabaseDoc({ return doc; } -function getPersistedColumns(databaseDoc: YDoc, viewId: string, groupId: string) { - const database = databaseDoc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database); - const view = database?.get(YjsDatabaseKey.views)?.get(viewId); - const group = view - ?.get(YjsDatabaseKey.groups) - ?.toArray() - .find((group) => group.get(YjsDatabaseKey.id) === groupId); - - return group?.get(YjsDatabaseKey.groups)?.toArray() ?? []; -} - -function toColumnData(column: unknown) { - if (column && typeof column === 'object' && 'get' in column && typeof column.get === 'function') { - const mapColumn = column as { get: (key: YjsDatabaseKey) => unknown }; - - return { - id: mapColumn.get(YjsDatabaseKey.id), - visible: mapColumn.get(YjsDatabaseKey.visible), - group_color: mapColumn.get(YjsDatabaseKey.group_color), - }; - } - - return column; -} - function createWrapper(databaseDoc: YDoc, activeViewId: string) { const contextValue: DatabaseContextState = { readOnly: false, @@ -146,64 +120,4 @@ describe('useGroup', () => { expect(result.current.columns).toEqual([{ id: optionId, visible: false }]); }); - - it('persists fallback columns before updating a column color', async () => { - const fieldId = 'checkbox-field-id'; - const groupId = 'group-id'; - const viewId = 'board-view-id'; - const databaseDoc = createDatabaseDoc({ - fieldId, - groupId, - groupColumns: [], - viewId, - fieldType: FieldType.Checkbox, - }); - - const { result } = renderHook(() => useUpdateGroupColumnColorDispatch(groupId), { - wrapper: createWrapper(databaseDoc, viewId), - }); - - act(() => { - result.current('Yes', GroupColorOption.Camellia); - }); - - expect(getPersistedColumns(databaseDoc, viewId, groupId)).toEqual([ - { id: 'Yes', visible: true, group_color: GroupColorOption.Camellia }, - { id: 'No', visible: true }, - ]); - }); - - it('updates color on persisted Y.Map group columns', async () => { - const fieldId = 'checkbox-field-id'; - const groupId = 'group-id'; - const viewId = 'board-view-id'; - const yesColumn = new Y.Map(); - const noColumn = new Y.Map(); - - yesColumn.set(YjsDatabaseKey.id, 'Yes'); - yesColumn.set(YjsDatabaseKey.visible, true); - noColumn.set(YjsDatabaseKey.id, 'No'); - noColumn.set(YjsDatabaseKey.visible, true); - - const databaseDoc = createDatabaseDoc({ - fieldId, - groupId, - groupColumns: [yesColumn, noColumn], - viewId, - fieldType: FieldType.Checkbox, - }); - - const { result } = renderHook(() => useUpdateGroupColumnColorDispatch(groupId), { - wrapper: createWrapper(databaseDoc, viewId), - }); - - act(() => { - result.current('Yes', GroupColorOption.Olive); - }); - - expect(getPersistedColumns(databaseDoc, viewId, groupId).map(toColumnData)).toEqual([ - { id: 'Yes', visible: true, group_color: GroupColorOption.Olive }, - { id: 'No', visible: true, group_color: undefined }, - ]); - }); }); diff --git a/src/application/database-yjs/dispatch.ts b/src/application/database-yjs/dispatch.ts index 60a151a8e..5866bdb01 100644 --- a/src/application/database-yjs/dispatch.ts +++ b/src/application/database-yjs/dispatch.ts @@ -38,7 +38,6 @@ import { createSelectOptionCell } from '@/application/database-yjs/fields/select import { createDateTimeField } from '@/application/database-yjs/fields/text/utils'; import { getDefaultFilterCondition } from '@/application/database-yjs/filter'; import { DEFAULT_FIELD_WRAP } from '@/application/database-yjs/const'; -import { getGroupColumns } from '@/application/database-yjs/group'; import { getOptionsFromRow } from '@/application/database-yjs/row'; import { getMetaIdMap } from '@/application/database-yjs/row_meta'; import { useBoardLayoutSettings, useCalendarLayoutSetting, useFieldType } from '@/application/database-yjs/selector'; @@ -476,121 +475,6 @@ export function useToggleHiddenGroupColumnDispatch(groupId: string, fieldId: str ); } -type WritableGroupColumn = { - id: string; - visible: boolean; - group_color?: string; -}; - -function normalizeWritableGroupColumn(column: unknown): WritableGroupColumn | null { - const parseVisible = (value: unknown) => value !== false && value !== 'false'; - - if (!column || typeof column !== 'object') return null; - - if ('get' in column && typeof column.get === 'function') { - const mapColumn = column as { get: (key: YjsDatabaseKey) => unknown }; - const id = mapColumn.get(YjsDatabaseKey.id); - - if (typeof id !== 'string' || !id) return null; - - return { - id, - visible: parseVisible(mapColumn.get(YjsDatabaseKey.visible)), - group_color: mapColumn.get(YjsDatabaseKey.group_color) as string | undefined, - }; - } - - const plainColumn = column as Partial; - - if (typeof plainColumn.id !== 'string' || !plainColumn.id) return null; - - return { - id: plainColumn.id, - visible: parseVisible(plainColumn.visible), - group_color: plainColumn.group_color, - }; -} - -function getFallbackVisibleGroupColumns(field?: YDatabaseField): WritableGroupColumn[] { - if (!field) return []; - - return (getGroupColumns(field) ?? []).map((column) => ({ - id: column.id, - visible: true, - })); -} - -export function useUpdateGroupColumnColorDispatch(groupId: string) { - const database = useDatabase(); - const view = useDatabaseView(); - const sharedRoot = useSharedRoot(); - - return useCallback( - (columnId: string, groupColor: string) => { - executeOperations( - sharedRoot, - [ - () => { - const groups = view?.get(YjsDatabaseKey.groups); - - if (!groups) { - throw new Error('Groups not found'); - } - - const group = groups.toArray().find((group) => group.get(YjsDatabaseKey.id) === groupId); - - if (!group) { - throw new Error(`Group with id ${groupId} not found`); - } - - const columns = group.get(YjsDatabaseKey.groups); - - if (!columns) { - throw new Error('Group columns not found'); - } - - const columnsArray = columns.toArray(); - let index = columnsArray.findIndex((column) => normalizeWritableGroupColumn(column)?.id === columnId); - let column = index === -1 ? undefined : normalizeWritableGroupColumn(columnsArray[index]); - - if (!column && columnsArray.map(normalizeWritableGroupColumn).every((column) => !column)) { - const fieldId = group.get(YjsDatabaseKey.field_id); - const field = database?.get(YjsDatabaseKey.fields)?.get(fieldId); - const fallbackColumns = getFallbackVisibleGroupColumns(field); - - if (fallbackColumns.length) { - if (columnsArray.length > 0) { - columns.delete(0, columnsArray.length); - } - - columns.push(fallbackColumns); - const nextColumnsArray = columns.toArray(); - - index = nextColumnsArray.findIndex((column) => normalizeWritableGroupColumn(column)?.id === columnId); - column = index === -1 ? undefined : normalizeWritableGroupColumn(nextColumnsArray[index]); - } - } - - if (!column) { - throw new Error(`Column with id ${columnId} not found in group ${groupId}`); - } - - columns.delete(index); - columns.insert(index, [ - { - ...column, - group_color: groupColor, - }, - ]); - }, - ], - 'updateGroupColumnColor' - ); - }, - [database, groupId, sharedRoot, view] - ); -} - export function useToggleCollapsedHiddenGroupColumnDispatch() { const view = useDatabaseView(); const sharedRoot = useSharedRoot(); diff --git a/src/application/database-yjs/group-color.ts b/src/application/database-yjs/group-color.ts deleted file mode 100644 index c25884920..000000000 --- a/src/application/database-yjs/group-color.ts +++ /dev/null @@ -1,34 +0,0 @@ -export enum GroupColorOption { - DefaultOption = 'defaultOption', - Mauve = 'mauve', - Lilac = 'lilac', - Camellia = 'camellia', - Papaya = 'papaya', - Mango = 'mango', - Olive = 'olive', - Grass = 'grass', - Jade = 'jade', - Azure = 'azure', - Iron = 'iron', -} - -export const GROUP_COLOR_OPTIONS = Object.values(GroupColorOption); - -export function groupColorOptionFromName(name: string | undefined): GroupColorOption | undefined { - if (!name) return undefined; - return GROUP_COLOR_OPTIONS.find((option) => option === name); -} - -export function groupColorOptionByName(name: string): GroupColorOption { - if (!name) return GroupColorOption.DefaultOption; - - let hash = 0; - - for (let i = 0; i < name.length; i += 1) { - hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0; - } - - const index = Math.abs(hash) % (GROUP_COLOR_OPTIONS.length - 1); - - return GROUP_COLOR_OPTIONS[index + 1]; -} diff --git a/src/application/database-yjs/index.ts b/src/application/database-yjs/index.ts index d72dca04d..c426ffdb8 100644 --- a/src/application/database-yjs/index.ts +++ b/src/application/database-yjs/index.ts @@ -3,7 +3,6 @@ export * from './context'; export * from './database.type'; export * from './dispatch'; export * from './fields'; -export * from './group-color'; export * from './selector'; export * from './comment_dispatch'; export * from './comment_selector'; diff --git a/src/application/database-yjs/selector.ts b/src/application/database-yjs/selector.ts index d08c8e8e5..0430b44fc 100644 --- a/src/application/database-yjs/selector.ts +++ b/src/application/database-yjs/selector.ts @@ -947,7 +947,6 @@ export function useGroupsSelector() { export interface GroupColumn { id: string; visible: boolean; - group_color?: string; } function normalizeGroupColumn(column: unknown): GroupColumn | null { @@ -964,7 +963,6 @@ function normalizeGroupColumn(column: unknown): GroupColumn | null { return { id, visible: parseVisible(mapColumn.get(YjsDatabaseKey.visible)), - group_color: mapColumn.get(YjsDatabaseKey.group_color) as string | undefined, }; } @@ -975,7 +973,6 @@ function normalizeGroupColumn(column: unknown): GroupColumn | null { return { id: plainColumn.id, visible: parseVisible(plainColumn.visible), - group_color: plainColumn.group_color, }; } diff --git a/src/application/types.ts b/src/application/types.ts index 8c3429eaa..cf95381b2 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -468,7 +468,6 @@ export enum YjsDatabaseKey { format = 'format', filter_type = 'filter_type', visible = 'visible', - group_color = 'group_color', collapsed_group_ids = 'collapsed_group_ids', hide_ungrouped_column = 'hide_ungrouped_column', collapse_hidden_groups = 'collapse_hidden_groups', @@ -799,14 +798,12 @@ export interface YDatabaseGroup extends Y.Map { get(key: YjsDatabaseKey.collapsed_group_ids): Y.Array | string[] | undefined; } -export type YDatabaseGroupColumns = Y.Array<{ id: string; visible: boolean; group_color?: string }>; +export type YDatabaseGroupColumns = Y.Array<{ id: string; visible: boolean }>; export interface YDatabaseGroupColumn extends Y.Map { get(key: YjsDatabaseKey.id): string; get(key: YjsDatabaseKey.visible): boolean; - - get(key: YjsDatabaseKey.group_color): string | undefined; } export interface YDatabaseSort extends Y.Map { diff --git a/src/components/database/components/board/column/Column.tsx b/src/components/database/components/board/column/Column.tsx index 0db0c4c1b..2bda17ea4 100644 --- a/src/components/database/components/board/column/Column.tsx +++ b/src/components/database/components/board/column/Column.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo } from 'react'; +import { type CSSProperties, memo, useCallback, useMemo } from 'react'; import { Row, useReadOnly } from '@/application/database-yjs'; import { useBoardColumnColor } from '@/components/database/components/board/column/boardColumnColor'; @@ -16,7 +16,6 @@ export interface ColumnProps { fieldId: string; addCardBefore: (id: string) => void; groupId: string; - groupColor?: string; showColorColumns: boolean; } @@ -41,7 +40,6 @@ function areColumnPropsEqual(prev: ColumnProps, next: ColumnProps) { prev.id === next.id && prev.fieldId === next.fieldId && prev.groupId === next.groupId && - prev.groupColor === next.groupColor && prev.showColorColumns === next.showColorColumns && prev.addCardBefore === next.addCardBefore && areRowsEqual(prev.rows, next.rows) @@ -49,12 +47,11 @@ function areColumnPropsEqual(prev: ColumnProps, next: ColumnProps) { } export const Column = memo( - ({ id, rows, fieldId, addCardBefore, groupId, groupColor, showColorColumns }: ColumnProps) => { + ({ id, rows, fieldId, addCardBefore, groupId, showColorColumns }: ColumnProps) => { const readOnly = useReadOnly(); - const { style: colorStyle, option: colorOption } = useBoardColumnColor({ + const { style: colorStyle } = useBoardColumnColor({ id, fieldId, - groupColor, showColorColumns, }); @@ -88,11 +85,14 @@ export const Column = memo(
@@ -107,8 +107,6 @@ export const Column = memo( addCardBefore={addCardBefore} getCards={getCards} groupId={groupId} - colorStyle={colorStyle} - colorOption={colorOption} showColorColumns={showColorColumns} /> diff --git a/src/components/database/components/board/column/ColumnHeader.tsx b/src/components/database/components/board/column/ColumnHeader.tsx index af677e827..12ba4434e 100644 --- a/src/components/database/components/board/column/ColumnHeader.tsx +++ b/src/components/database/components/board/column/ColumnHeader.tsx @@ -11,7 +11,6 @@ function ColumnHeader({ addCardBefore, getCards, groupId, - groupColor, showColorColumns, }: { id: string; @@ -20,15 +19,13 @@ function ColumnHeader({ addCardBefore: (id: string) => void; getCards: (id: string) => Row[]; groupId: string; - groupColor?: string; showColorColumns: boolean; }) { const { columnRef, headerRef, state, isDragging } = useColumnHeaderDrag(id); const readOnly = useReadOnly(); - const { style: colorStyle, option: colorOption } = useBoardColumnColor({ + const { style: colorStyle } = useBoardColumnColor({ id, fieldId, - groupColor, showColorColumns, }); @@ -54,8 +51,6 @@ function ColumnHeader({ }} getCards={getCards} groupId={groupId} - colorStyle={colorStyle} - colorOption={colorOption} showColorColumns={showColorColumns} /> {state.type === StateType.IS_COLUMN_OVER && state.closestEdge && } diff --git a/src/components/database/components/board/column/ColumnHeaderPrimitive.tsx b/src/components/database/components/board/column/ColumnHeaderPrimitive.tsx index 160904918..47aa5667c 100644 --- a/src/components/database/components/board/column/ColumnHeaderPrimitive.tsx +++ b/src/components/database/components/board/column/ColumnHeaderPrimitive.tsx @@ -1,10 +1,9 @@ import { forwardRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { GroupColorOption, Row, useReadOnly } from '@/application/database-yjs'; +import { Row, useReadOnly } from '@/application/database-yjs'; import { ReactComponent as MoreIcon } from '@/assets/icons/more.svg'; import { ReactComponent as AddIcon } from '@/assets/icons/plus.svg'; -import { BoardColumnColorStyle } from '@/components/database/components/board/column/boardColumnColor'; import { ColumnMenu } from '@/components/database/components/board/column/ColumnMenu'; import { useRenderColumn } from '@/components/database/components/board/column/useRenderColumn'; import { Button } from '@/components/ui/button'; @@ -20,8 +19,6 @@ function ColumnHeaderPrimitive( addCardBefore, getCards, groupId, - colorStyle, - colorOption, showColorColumns, ...props }: { @@ -31,13 +28,11 @@ function ColumnHeaderPrimitive( getCards: (id: string) => Row[]; addCardBefore: (id: string) => void; groupId: string; - colorStyle?: BoardColumnColorStyle; - colorOption?: GroupColorOption; showColorColumns?: boolean; } & React.HTMLAttributes, ref: React.Ref ) { - const { header, renameEnabled, deleteEnabled, hideEnabled } = useRenderColumn(id, fieldId, colorStyle); + const { header, renameEnabled, deleteEnabled, hideEnabled } = useRenderColumn(id, fieldId); const { t } = useTranslation(); const readOnly = useReadOnly(); @@ -65,7 +60,6 @@ function ColumnHeaderPrimitive( hideEnabled={hideEnabled} getCards={getCards} showColorColumns={Boolean(showColorColumns)} - currentColorOption={colorOption} >
{colorOptions.map((color) => ( ))} diff --git a/src/components/database/components/board/column/boardColumnColor.ts b/src/components/database/components/board/column/boardColumnColor.ts index a11965d9f..59d2484ad 100644 --- a/src/components/database/components/board/column/boardColumnColor.ts +++ b/src/components/database/components/board/column/boardColumnColor.ts @@ -1,26 +1,25 @@ import { useMemo } from 'react'; import { - GROUP_COLOR_OPTIONS, - GroupColorOption, - groupColorOptionByName, - groupColorOptionFromName, + FieldType, + parseSelectOptionTypeOptions, + SelectOption, + SelectOptionColor, + useFieldSelector, } from '@/application/database-yjs'; -import { ColorEnum, renderColor, toBlockColor } from '@/utils/color'; - -import { useBoardColumnName } from './columnName'; +import { YjsDatabaseKey } from '@/application/types'; +import { SelectOptionColorMap, SelectOptionFgColorMap } from '@/components/database/components/cell/cell.const'; export interface BoardColumnColorStyle { backgroundColor: string; - labelBackgroundColor: string; + highlightColor: string; paletteColor: string; textColor: string; } -export const BOARD_COLUMN_COLOR_OPTIONS = GROUP_COLOR_OPTIONS; +export const BOARD_COLUMN_COLOR_OPTIONS = Object.values(SelectOptionColor); export type BoardColumnColorLabelKey = - | 'colors.default' | 'colors.mauve' | 'colors.lilac' | 'colors.camellia' @@ -30,82 +29,105 @@ export type BoardColumnColorLabelKey = | 'colors.grass' | 'colors.jade' | 'colors.azure' - | 'colors.gray'; - -const GROUP_COLOR_TO_TINT: Partial> = { - [GroupColorOption.Mauve]: ColorEnum.Tint1, - [GroupColorOption.Lilac]: ColorEnum.Tint2, - [GroupColorOption.Camellia]: ColorEnum.Tint3, - [GroupColorOption.Papaya]: ColorEnum.Tint4, - [GroupColorOption.Mango]: ColorEnum.Tint5, - [GroupColorOption.Olive]: ColorEnum.Tint6, - [GroupColorOption.Grass]: ColorEnum.Tint7, - [GroupColorOption.Jade]: ColorEnum.Tint8, - [GroupColorOption.Azure]: ColorEnum.Tint9, - [GroupColorOption.Iron]: ColorEnum.Tint10, + | 'colors.iron' + | 'colors.mauveEmphasized' + | 'colors.lavenderEmphasized' + | 'colors.camelliaEmphasized' + | 'colors.papayaEmphasized' + | 'colors.mangoEmphasized' + | 'colors.oliveEmphasized' + | 'colors.grassEmphasized' + | 'colors.jadeEmphasized' + | 'colors.azureEmphasized' + | 'colors.ironEmphasized'; + +const BOARD_COLUMN_COLOR_LABEL_KEYS: Record = { + [SelectOptionColor.OptionColor1]: 'colors.mauve', + [SelectOptionColor.OptionColor2]: 'colors.lilac', + [SelectOptionColor.OptionColor3]: 'colors.camellia', + [SelectOptionColor.OptionColor4]: 'colors.papaya', + [SelectOptionColor.OptionColor5]: 'colors.mango', + [SelectOptionColor.OptionColor6]: 'colors.olive', + [SelectOptionColor.OptionColor7]: 'colors.grass', + [SelectOptionColor.OptionColor8]: 'colors.jade', + [SelectOptionColor.OptionColor9]: 'colors.azure', + [SelectOptionColor.OptionColor10]: 'colors.iron', + [SelectOptionColor.OptionColor11]: 'colors.mauveEmphasized', + [SelectOptionColor.OptionColor12]: 'colors.lavenderEmphasized', + [SelectOptionColor.OptionColor13]: 'colors.camelliaEmphasized', + [SelectOptionColor.OptionColor14]: 'colors.papayaEmphasized', + [SelectOptionColor.OptionColor15]: 'colors.mangoEmphasized', + [SelectOptionColor.OptionColor16]: 'colors.oliveEmphasized', + [SelectOptionColor.OptionColor17]: 'colors.grassEmphasized', + [SelectOptionColor.OptionColor18]: 'colors.jadeEmphasized', + [SelectOptionColor.OptionColor19]: 'colors.azureEmphasized', + [SelectOptionColor.OptionColor20]: 'colors.ironEmphasized', }; -const GROUP_COLOR_LABEL_KEYS: Record = { - [GroupColorOption.DefaultOption]: 'colors.default', - [GroupColorOption.Mauve]: 'colors.mauve', - [GroupColorOption.Lilac]: 'colors.lilac', - [GroupColorOption.Camellia]: 'colors.camellia', - [GroupColorOption.Papaya]: 'colors.papaya', - [GroupColorOption.Mango]: 'colors.mango', - [GroupColorOption.Olive]: 'colors.olive', - [GroupColorOption.Grass]: 'colors.grass', - [GroupColorOption.Jade]: 'colors.jade', - [GroupColorOption.Azure]: 'colors.azure', - [GroupColorOption.Iron]: 'colors.gray', -}; +function cssVar(token?: string) { + return token ? `var(${token})` : undefined; +} -export function getBoardColumnColorStyle(option: GroupColorOption | undefined): BoardColumnColorStyle | undefined { - if (!option || option === GroupColorOption.DefaultOption) return undefined; +function withColorOpacity(color: string, opacity: number) { + return `color-mix(in srgb, ${color} ${opacity * 100}%, transparent)`; +} + +export function getBoardColumnColorStyle(color: SelectOptionColor | undefined): BoardColumnColorStyle | undefined { + if (!color) return undefined; - const tint = GROUP_COLOR_TO_TINT[option]; + const optionColor = cssVar(SelectOptionColorMap[color]); + const textColor = cssVar(SelectOptionFgColorMap[color]); - if (!tint) return undefined; + if (!optionColor || !textColor) return undefined; - const blockColor = toBlockColor(tint); + const translucentOptionColor = withColorOpacity(optionColor, 0.4); return { - backgroundColor: renderColor(blockColor.bg), - labelBackgroundColor: renderColor(blockColor.border), - paletteColor: renderColor(tint), - textColor: renderColor(blockColor.text), + backgroundColor: translucentOptionColor, + highlightColor: translucentOptionColor, + paletteColor: optionColor, + textColor, }; } -export function getBoardColumnColorLabelKey(option: GroupColorOption) { - return GROUP_COLOR_LABEL_KEYS[option]; +export function getBoardColumnColorLabelKey(color: SelectOptionColor) { + return BOARD_COLUMN_COLOR_LABEL_KEYS[color]; } export function useBoardColumnColor({ id, fieldId, - groupColor, showColorColumns, }: { id: string; fieldId: string; - groupColor?: string; showColorColumns: boolean; -}) { - const columnName = useBoardColumnName(id, fieldId); +}): { option: SelectOption | undefined; style: BoardColumnColorStyle | undefined } { + const { field, clock } = useFieldSelector(fieldId); return useMemo(() => { - if (!showColorColumns || id === fieldId) { + if (!showColorColumns || id === fieldId || !field) { + return { + option: undefined, + style: undefined, + }; + } + + const fieldType = Number(field.get(YjsDatabaseKey.type)) as FieldType; + + if (![FieldType.SingleSelect, FieldType.MultiSelect].includes(fieldType)) { return { option: undefined, style: undefined, }; } - const option = groupColorOptionFromName(groupColor) ?? groupColorOptionByName(columnName || id); + const option = parseSelectOptionTypeOptions(field)?.options.find((option) => option?.id === id); return { option, - style: getBoardColumnColorStyle(option), + style: getBoardColumnColorStyle(option?.color), }; - }, [columnName, fieldId, groupColor, id, showColorColumns]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clock, field, fieldId, id, showColorColumns]); } diff --git a/src/components/database/components/board/column/useRenderColumn.tsx b/src/components/database/components/board/column/useRenderColumn.tsx index 1d3929ae3..e4bbf50b0 100644 --- a/src/components/database/components/board/column/useRenderColumn.tsx +++ b/src/components/database/components/board/column/useRenderColumn.tsx @@ -8,25 +8,10 @@ import { YjsDatabaseKey } from '@/application/types'; import { ReactComponent as CheckboxCheckSvg } from '@/assets/icons/check_filled.svg'; import { ReactComponent as CheckboxUncheckSvg } from '@/assets/icons/uncheck.svg'; import { Tag } from '@/components/_shared/tag'; -import { BoardColumnColorStyle } from '@/components/database/components/board/column/boardColumnColor'; import { getBoardColumnName } from '@/components/database/components/board/column/columnName'; import { SelectOptionColorMap, SelectOptionFgColorMap } from '@/components/database/components/cell/cell.const'; -function BoardColumnLabel({ label, colorStyle }: { label: string; colorStyle: BoardColumnColorStyle }) { - return ( -
-
{label}
-
- ); -} - -export function useRenderColumn(id: string, fieldId: string, colorStyle?: BoardColumnColorStyle) { +export function useRenderColumn(id: string, fieldId: string) { const { field, clock } = useFieldSelector(fieldId); const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType; const { t } = useTranslation(); @@ -35,7 +20,7 @@ export function useRenderColumn(id: string, fieldId: string, colorStyle?: BoardC if (!field) return null; if (fieldType === FieldType.Checkbox) return ( -
+
{getChecked(id) ? ( <> @@ -56,15 +41,11 @@ export function useRenderColumn(id: string, fieldId: string, colorStyle?: BoardC return ( - {colorStyle ? ( - - ) : ( - - )} + ); @@ -72,7 +53,7 @@ export function useRenderColumn(id: string, fieldId: string, colorStyle?: BoardC return null; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [field, clock, fieldType, id, label, colorStyle]); + }, [field, clock, fieldType, id, label]); const renameEnabled = useMemo(() => { return [FieldType.SingleSelect, FieldType.MultiSelect].includes(fieldType); diff --git a/src/components/database/components/board/group/Columns.tsx b/src/components/database/components/board/group/Columns.tsx index 1d12c797d..a3fc45c1d 100644 --- a/src/components/database/components/board/group/Columns.tsx +++ b/src/components/database/components/board/group/Columns.tsx @@ -64,7 +64,6 @@ const Columns = forwardRef< id={data.id} fieldId={fieldId} rows={data.rows} - groupColor={data.group_color} showColorColumns={showColorColumns} {...props} /> diff --git a/src/components/database/components/board/group/GroupHeader.tsx b/src/components/database/components/board/group/GroupHeader.tsx index f72621785..4f909e29a 100644 --- a/src/components/database/components/board/group/GroupHeader.tsx +++ b/src/components/database/components/board/group/GroupHeader.tsx @@ -45,7 +45,6 @@ const GroupHeader = forwardRef< rowCount={groupResult.get(data.id)?.length || 0} addCardBefore={addCardBefore} groupId={groupId} - groupColor={data.group_color} showColorColumns={showColorColumns} /> ))} diff --git a/src/styles/app.scss b/src/styles/app.scss index 61c0240b9..29c96db69 100644 --- a/src/styles/app.scss +++ b/src/styles/app.scss @@ -189,8 +189,9 @@ body { border-color: #59647a; } -.board-card.board-card-selected { - border-color: var(--border-theme-thick); +.board-card.board-card-selected, +:root[data-dark-mode='true'] .board-card.board-card-selected { + border-color: var(--board-card-highlight-color, var(--border-theme-thick)); } .no-scrollbar::-webkit-scrollbar { From f375720879fa1b050c48127c62405a7d338c9e9b Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 16 Jun 2026 23:13:15 +0800 Subject: [PATCH 3/3] fix: align board column colors with desktop --- .../e2e/database/board-column-color.spec.ts | 14 +- src/application/database-yjs/dispatch.ts | 14 +- src/application/database-yjs/selector.ts | 23 ++-- src/components/_shared/tag/Tag.tsx | 11 +- .../components/board/column/CardList.tsx | 2 +- .../board/column/ColumnHeaderPrimitive.tsx | 4 +- .../components/board/column/ColumnMenu.tsx | 8 +- .../board/column/boardColumnColor.ts | 128 ++++++++++++++++-- .../board/column/useRenderColumn.tsx | 12 +- 9 files changed, 169 insertions(+), 47 deletions(-) diff --git a/playwright/e2e/database/board-column-color.spec.ts b/playwright/e2e/database/board-column-color.spec.ts index e3917f4ec..e9678331f 100644 --- a/playwright/e2e/database/board-column-color.spec.ts +++ b/playwright/e2e/database/board-column-color.spec.ts @@ -20,12 +20,14 @@ interface SelectOptionInfo { color: string; } -const colorTargets: Record = { +const colorTargets: Record = { Blue: { - fillVar: '--tag-fill-09-light', + boardBackgroundVar: '--block-bg-color-12', + selectOptionFillVar: '--tag-fill-09-light', }, Lime: { - fillVar: '--tag-fill-06-light', + boardBackgroundVar: '--block-bg-color-6', + selectOptionFillVar: '--tag-fill-06-light', }, }; @@ -54,7 +56,7 @@ test.describe('Board column color', () => { const column = BoardSelectors.boardContainer(page).locator(`[data-column-id="${todoOption.optionId}"]`); const columnSurface = getColumnSurface(column); - const expectedBackgroundColor = await resolveCssColor(page, colorTargets[targetColor].fillVar); + const expectedBackgroundColor = await resolveCssColor(page, colorTargets[targetColor].boardBackgroundVar); await expect(column).toBeVisible({ timeout: 15000 }); await expect.poll(() => getComputedBackgroundColor(columnSurface)).toBe(expectedBackgroundColor); @@ -78,7 +80,7 @@ test.describe('Board column color', () => { await expect(DatabaseGridSelectors.grid(page)).toBeVisible({ timeout: 15000 }); await openGridSelectOptionEditor(page, todoOption); - await selectOptionMenuColor(page, target.fillVar); + await selectOptionMenuColor(page, target.selectOptionFillVar); await expect .poll(() => findSelectOptionByIdViaYjs(page, todoOption.optionId).then((option) => option.color)) @@ -89,7 +91,7 @@ test.describe('Board column color', () => { const column = BoardSelectors.boardContainer(page).locator(`[data-column-id="${todoOption.optionId}"]`); const columnSurface = getColumnSurface(column); - const expectedBackgroundColor = await resolveCssColor(page, target.fillVar); + const expectedBackgroundColor = await resolveCssColor(page, target.boardBackgroundVar); await expect(column).toBeVisible({ timeout: 15000 }); await expect.poll(() => getComputedBackgroundColor(columnSurface)).toBe(expectedBackgroundColor); diff --git a/src/application/database-yjs/dispatch.ts b/src/application/database-yjs/dispatch.ts index 5866bdb01..e2195e8ba 100644 --- a/src/application/database-yjs/dispatch.ts +++ b/src/application/database-yjs/dispatch.ts @@ -243,6 +243,18 @@ function getOrCreateBoardLayoutSetting(view: YDatabaseView) { layoutSettings.set('1', layoutSetting); } + if (layoutSetting.get(YjsDatabaseKey.hide_ungrouped_column) === undefined) { + layoutSetting.set(YjsDatabaseKey.hide_ungrouped_column, false); + } + + if (layoutSetting.get(YjsDatabaseKey.collapse_hidden_groups) === undefined) { + layoutSetting.set(YjsDatabaseKey.collapse_hidden_groups, true); + } + + if (layoutSetting.get(YjsDatabaseKey.show_color_columns) === undefined) { + layoutSetting.set(YjsDatabaseKey.show_color_columns, true); + } + return layoutSetting; } @@ -1777,7 +1789,7 @@ function generateBoardLayoutSettings() { layoutSetting.set(YjsDatabaseKey.hide_ungrouped_column, false); layoutSetting.set(YjsDatabaseKey.collapse_hidden_groups, true); - layoutSetting.set(YjsDatabaseKey.show_color_columns, false); + layoutSetting.set(YjsDatabaseKey.show_color_columns, true); layoutSettings.set('1', layoutSetting); return layoutSettings; } diff --git a/src/application/database-yjs/selector.ts b/src/application/database-yjs/selector.ts index 0430b44fc..d033b2a95 100644 --- a/src/application/database-yjs/selector.ts +++ b/src/application/database-yjs/selector.ts @@ -1032,29 +1032,34 @@ export function useGroup(groupId: string) { export function useBoardLayoutSettings() { const view = useDatabaseView(); - const layoutSetting = view?.get(YjsDatabaseKey.layout_settings)?.get('1'); + const layoutSettings = view?.get(YjsDatabaseKey.layout_settings); + const layoutSetting = layoutSettings?.get('1'); const [isCollapsed, setIsCollapsed] = useState(true); const [hideUnGroup, setHideUnGroup] = useState(false); - const [showColorColumns, setShowColorColumns] = useState(false); + const [showColorColumns, setShowColorColumns] = useState(true); const groups = view?.get(YjsDatabaseKey.groups); const [fieldId, setFieldId] = useState(null); useEffect(() => { - if (!layoutSetting) return; + if (!view) return; const observerEvent = () => { - setIsCollapsed(Boolean(layoutSetting?.get(YjsDatabaseKey.collapse_hidden_groups))); - setHideUnGroup(Boolean(layoutSetting?.get(YjsDatabaseKey.hide_ungrouped_column))); - setShowColorColumns(Boolean(layoutSetting?.get(YjsDatabaseKey.show_color_columns))); + const currentLayoutSetting = view.get(YjsDatabaseKey.layout_settings)?.get('1'); + + setIsCollapsed(currentLayoutSetting?.get(YjsDatabaseKey.collapse_hidden_groups) ?? true); + setHideUnGroup(currentLayoutSetting?.get(YjsDatabaseKey.hide_ungrouped_column) ?? false); + setShowColorColumns(currentLayoutSetting?.get(YjsDatabaseKey.show_color_columns) ?? true); }; observerEvent(); - layoutSetting.observe(observerEvent); + layoutSettings?.observeDeep(observerEvent); + view.observe(observerEvent); return () => { - layoutSetting.unobserve(observerEvent); + layoutSettings?.unobserveDeep(observerEvent); + view.unobserve(observerEvent); }; - }, [view, layoutSetting]); + }, [view, layoutSettings, layoutSetting]); useEffect(() => { const observerEvent = () => { diff --git a/src/components/_shared/tag/Tag.tsx b/src/components/_shared/tag/Tag.tsx index 85f46284a..ef8738418 100644 --- a/src/components/_shared/tag/Tag.tsx +++ b/src/components/_shared/tag/Tag.tsx @@ -10,6 +10,11 @@ export interface TagProps { badge?: string; } +function toCssColor(color: string | undefined, fallback?: string) { + if (!color) return fallback; + return color.startsWith('--') ? `var(${color})` : color; +} + export const Tag: FC = ({ bgColor, textColor, label, badge }) => { const className = useMemo(() => { return cn( @@ -22,15 +27,15 @@ export const Tag: FC = ({ bgColor, textColor, label, badge }) => { return (
{badge && ( diff --git a/src/components/database/components/board/column/CardList.tsx b/src/components/database/components/board/column/CardList.tsx index ed765cd85..acfc8c881 100644 --- a/src/components/database/components/board/column/CardList.tsx +++ b/src/components/database/components/board/column/CardList.tsx @@ -125,7 +125,7 @@ function CardList({ top: 0, left: 0, transform: `translateY(${virtualRow.start - virtualizer.options.scrollMargin}px)`, - paddingTop: virtualRow.index === 0 ? 10 : undefined, + paddingTop: virtualRow.index === 0 ? 4 : undefined, }} > , ref: React.Ref ) { - const { header, renameEnabled, deleteEnabled, hideEnabled } = useRenderColumn(id, fieldId); + const { header, renameEnabled, deleteEnabled, hideEnabled } = useRenderColumn(id, fieldId, Boolean(showColorColumns)); const { t } = useTranslation(); const readOnly = useReadOnly(); @@ -47,7 +46,6 @@ function ColumnHeaderPrimitive( >
{header}
- {rowCount}
{!readOnly && (
diff --git a/src/components/database/components/board/column/ColumnMenu.tsx b/src/components/database/components/board/column/ColumnMenu.tsx index 89e4aa9aa..b24cf7df9 100644 --- a/src/components/database/components/board/column/ColumnMenu.tsx +++ b/src/components/database/components/board/column/ColumnMenu.tsx @@ -6,7 +6,6 @@ import { parseSelectOptionTypeOptions, Row, SelectOptionColor, - useDatabaseContext, useFieldSelector, } from '@/application/database-yjs'; import { useToggleHiddenGroupColumnDispatch, useUpdateSelectOption } from '@/application/database-yjs/dispatch'; @@ -14,7 +13,6 @@ import { YjsDatabaseKey } from '@/application/types'; import { ReactComponent as DeleteIcon } from '@/assets/icons/delete.svg'; import { ReactComponent as EditIcon } from '@/assets/icons/edit.svg'; import { ReactComponent as HideIcon } from '@/assets/icons/hide.svg'; -import { useSubscriptionPlan } from '@/components/app/hooks/useSubscriptionPlan'; import { BOARD_COLUMN_COLOR_OPTIONS, getBoardColumnColorLabelKey, @@ -69,8 +67,6 @@ export function ColumnMenu({ const [deleteOpen, setDeleteOpen] = useState(false); const toggleHidden = useToggleHiddenGroupColumnDispatch(groupId, fieldId); const updateSelectOption = useUpdateSelectOption(fieldId); - const { getSubscriptions } = useDatabaseContext(); - const { isPro } = useSubscriptionPlan(getSubscriptions); const { field, clock } = useFieldSelector(fieldId); const { t } = useTranslation(); @@ -99,12 +95,12 @@ export function ColumnMenu({ }; const colorOptions = useMemo(() => { - return BOARD_COLUMN_COLOR_OPTIONS.filter((_, index) => isPro || index < 10).map((color) => ({ + return BOARD_COLUMN_COLOR_OPTIONS.map((color) => ({ color, label: t(getBoardColumnColorLabelKey(color)), swatchColor: getBoardColumnColorStyle(color)?.paletteColor || 'transparent', })); - }, [isPro, t]); + }, [t]); const options = useMemo(() => { return [ diff --git a/src/components/database/components/board/column/boardColumnColor.ts b/src/components/database/components/board/column/boardColumnColor.ts index 59d2484ad..afd291cf3 100644 --- a/src/components/database/components/board/column/boardColumnColor.ts +++ b/src/components/database/components/board/column/boardColumnColor.ts @@ -8,16 +8,27 @@ import { useFieldSelector, } from '@/application/database-yjs'; import { YjsDatabaseKey } from '@/application/types'; -import { SelectOptionColorMap, SelectOptionFgColorMap } from '@/components/database/components/cell/cell.const'; export interface BoardColumnColorStyle { backgroundColor: string; highlightColor: string; + labelColor: string; paletteColor: string; textColor: string; } -export const BOARD_COLUMN_COLOR_OPTIONS = Object.values(SelectOptionColor); +export const BOARD_COLUMN_COLOR_OPTIONS = [ + SelectOptionColor.OptionColor1, + SelectOptionColor.OptionColor2, + SelectOptionColor.OptionColor3, + SelectOptionColor.OptionColor4, + SelectOptionColor.OptionColor5, + SelectOptionColor.OptionColor6, + SelectOptionColor.OptionColor7, + SelectOptionColor.OptionColor8, + SelectOptionColor.OptionColor9, + SelectOptionColor.OptionColor10, +]; export type BoardColumnColorLabelKey = | 'colors.mauve' @@ -64,28 +75,117 @@ const BOARD_COLUMN_COLOR_LABEL_KEYS: Record = { + [SelectOptionColor.OptionColor1]: '--palette-bg-color-14', + [SelectOptionColor.OptionColor2]: '--palette-bg-color-16', + [SelectOptionColor.OptionColor3]: '--palette-bg-color-18', + [SelectOptionColor.OptionColor4]: '--palette-bg-color-2', + [SelectOptionColor.OptionColor5]: '--palette-bg-color-4', + [SelectOptionColor.OptionColor6]: '--palette-bg-color-6', + [SelectOptionColor.OptionColor7]: '--palette-bg-color-8', + [SelectOptionColor.OptionColor8]: '--palette-bg-color-10', + [SelectOptionColor.OptionColor9]: '--palette-bg-color-12', + [SelectOptionColor.OptionColor10]: '--palette-bg-color-20', + [SelectOptionColor.OptionColor11]: '--palette-bg-color-14', + [SelectOptionColor.OptionColor12]: '--palette-bg-color-16', + [SelectOptionColor.OptionColor13]: '--palette-bg-color-18', + [SelectOptionColor.OptionColor14]: '--palette-bg-color-2', + [SelectOptionColor.OptionColor15]: '--palette-bg-color-4', + [SelectOptionColor.OptionColor16]: '--palette-bg-color-6', + [SelectOptionColor.OptionColor17]: '--palette-bg-color-8', + [SelectOptionColor.OptionColor18]: '--palette-bg-color-10', + [SelectOptionColor.OptionColor19]: '--palette-bg-color-12', + [SelectOptionColor.OptionColor20]: '--palette-bg-color-20', +}; + +const SELECT_OPTION_TO_BACKGROUND_COLOR: Record = { + [SelectOptionColor.OptionColor1]: '--block-bg-color-14', + [SelectOptionColor.OptionColor2]: '--block-bg-color-16', + [SelectOptionColor.OptionColor3]: '--block-bg-color-18', + [SelectOptionColor.OptionColor4]: '--block-bg-color-2', + [SelectOptionColor.OptionColor5]: '--block-bg-color-4', + [SelectOptionColor.OptionColor6]: '--block-bg-color-6', + [SelectOptionColor.OptionColor7]: '--block-bg-color-8', + [SelectOptionColor.OptionColor8]: '--block-bg-color-10', + [SelectOptionColor.OptionColor9]: '--block-bg-color-12', + [SelectOptionColor.OptionColor10]: '--block-bg-color-20', + [SelectOptionColor.OptionColor11]: '--block-bg-color-14', + [SelectOptionColor.OptionColor12]: '--block-bg-color-16', + [SelectOptionColor.OptionColor13]: '--block-bg-color-18', + [SelectOptionColor.OptionColor14]: '--block-bg-color-2', + [SelectOptionColor.OptionColor15]: '--block-bg-color-4', + [SelectOptionColor.OptionColor16]: '--block-bg-color-6', + [SelectOptionColor.OptionColor17]: '--block-bg-color-8', + [SelectOptionColor.OptionColor18]: '--block-bg-color-10', + [SelectOptionColor.OptionColor19]: '--block-bg-color-12', + [SelectOptionColor.OptionColor20]: '--block-bg-color-20', +}; + +const SELECT_OPTION_TO_LABEL_COLOR: Record = { + [SelectOptionColor.OptionColor1]: '--block-border-color-14', + [SelectOptionColor.OptionColor2]: '--block-border-color-16', + [SelectOptionColor.OptionColor3]: '--block-border-color-18', + [SelectOptionColor.OptionColor4]: '--block-border-color-2', + [SelectOptionColor.OptionColor5]: '--block-border-color-4', + [SelectOptionColor.OptionColor6]: '--block-border-color-6', + [SelectOptionColor.OptionColor7]: '--block-border-color-8', + [SelectOptionColor.OptionColor8]: '--block-border-color-10', + [SelectOptionColor.OptionColor9]: '--block-border-color-12', + [SelectOptionColor.OptionColor10]: '--block-border-color-20', + [SelectOptionColor.OptionColor11]: '--block-border-color-14', + [SelectOptionColor.OptionColor12]: '--block-border-color-16', + [SelectOptionColor.OptionColor13]: '--block-border-color-18', + [SelectOptionColor.OptionColor14]: '--block-border-color-2', + [SelectOptionColor.OptionColor15]: '--block-border-color-4', + [SelectOptionColor.OptionColor16]: '--block-border-color-6', + [SelectOptionColor.OptionColor17]: '--block-border-color-8', + [SelectOptionColor.OptionColor18]: '--block-border-color-10', + [SelectOptionColor.OptionColor19]: '--block-border-color-12', + [SelectOptionColor.OptionColor20]: '--block-border-color-20', +}; + +const SELECT_OPTION_TO_TEXT_COLOR: Record = { + [SelectOptionColor.OptionColor1]: '--block-text-color-14', + [SelectOptionColor.OptionColor2]: '--block-text-color-16', + [SelectOptionColor.OptionColor3]: '--block-text-color-18', + [SelectOptionColor.OptionColor4]: '--block-text-color-2', + [SelectOptionColor.OptionColor5]: '--block-text-color-4', + [SelectOptionColor.OptionColor6]: '--block-text-color-6', + [SelectOptionColor.OptionColor7]: '--block-text-color-8', + [SelectOptionColor.OptionColor8]: '--block-text-color-10', + [SelectOptionColor.OptionColor9]: '--block-text-color-12', + [SelectOptionColor.OptionColor10]: '--block-text-color-20', + [SelectOptionColor.OptionColor11]: '--block-text-color-14', + [SelectOptionColor.OptionColor12]: '--block-text-color-16', + [SelectOptionColor.OptionColor13]: '--block-text-color-18', + [SelectOptionColor.OptionColor14]: '--block-text-color-2', + [SelectOptionColor.OptionColor15]: '--block-text-color-4', + [SelectOptionColor.OptionColor16]: '--block-text-color-6', + [SelectOptionColor.OptionColor17]: '--block-text-color-8', + [SelectOptionColor.OptionColor18]: '--block-text-color-10', + [SelectOptionColor.OptionColor19]: '--block-text-color-12', + [SelectOptionColor.OptionColor20]: '--block-text-color-20', +}; + function cssVar(token?: string) { return token ? `var(${token})` : undefined; } -function withColorOpacity(color: string, opacity: number) { - return `color-mix(in srgb, ${color} ${opacity * 100}%, transparent)`; -} - export function getBoardColumnColorStyle(color: SelectOptionColor | undefined): BoardColumnColorStyle | undefined { if (!color) return undefined; - const optionColor = cssVar(SelectOptionColorMap[color]); - const textColor = cssVar(SelectOptionFgColorMap[color]); - - if (!optionColor || !textColor) return undefined; + const backgroundColor = cssVar(SELECT_OPTION_TO_BACKGROUND_COLOR[color]); + const labelColor = cssVar(SELECT_OPTION_TO_LABEL_COLOR[color]); + const paletteColor = cssVar(SELECT_OPTION_TO_PALETTE_COLOR[color]); + const textColor = cssVar(SELECT_OPTION_TO_TEXT_COLOR[color]); - const translucentOptionColor = withColorOpacity(optionColor, 0.4); + if (!backgroundColor || !labelColor || !paletteColor || !textColor) return undefined; return { - backgroundColor: translucentOptionColor, - highlightColor: translucentOptionColor, - paletteColor: optionColor, + backgroundColor, + highlightColor: backgroundColor, + labelColor, + paletteColor, textColor, }; } diff --git a/src/components/database/components/board/column/useRenderColumn.tsx b/src/components/database/components/board/column/useRenderColumn.tsx index e4bbf50b0..b09b357d5 100644 --- a/src/components/database/components/board/column/useRenderColumn.tsx +++ b/src/components/database/components/board/column/useRenderColumn.tsx @@ -8,10 +8,11 @@ import { YjsDatabaseKey } from '@/application/types'; import { ReactComponent as CheckboxCheckSvg } from '@/assets/icons/check_filled.svg'; import { ReactComponent as CheckboxUncheckSvg } from '@/assets/icons/uncheck.svg'; import { Tag } from '@/components/_shared/tag'; +import { getBoardColumnColorStyle } from '@/components/database/components/board/column/boardColumnColor'; import { getBoardColumnName } from '@/components/database/components/board/column/columnName'; import { SelectOptionColorMap, SelectOptionFgColorMap } from '@/components/database/components/cell/cell.const'; -export function useRenderColumn(id: string, fieldId: string) { +export function useRenderColumn(id: string, fieldId: string, showColorColumns = false) { const { field, clock } = useFieldSelector(fieldId); const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType; const { t } = useTranslation(); @@ -37,14 +38,17 @@ export function useRenderColumn(id: string, fieldId: string) { ); if ([FieldType.SingleSelect, FieldType.MultiSelect].includes(fieldType)) { const option = parseSelectOptionTypeOptions(field)?.options.find((option) => option?.id === id); + const colorStyle = showColorColumns ? getBoardColumnColorStyle(option?.color) : undefined; return ( @@ -53,7 +57,7 @@ export function useRenderColumn(id: string, fieldId: string) { return null; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [field, clock, fieldType, id, label]); + }, [field, clock, fieldType, id, label, showColorColumns]); const renameEnabled = useMemo(() => { return [FieldType.SingleSelect, FieldType.MultiSelect].includes(fieldType);