diff --git a/apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field-visitor.postgres.ts b/apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field-visitor.postgres.ts index 1f012022cd..3ec80f92d9 100644 --- a/apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field-visitor.postgres.ts +++ b/apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field-visitor.postgres.ts @@ -1,11 +1,17 @@ import type { AttachmentFieldCore, AutoNumberFieldCore, + ButtonFieldCore, CheckboxFieldCore, + ColorFieldCore, + ConditionalRollupFieldCore, CreatedByFieldCore, CreatedTimeFieldCore, DateFieldCore, + FieldCore, FormulaFieldCore, + IFieldVisitor, + ILinkFieldOptions, LastModifiedByFieldCore, LastModifiedTimeFieldCore, LinkFieldCore, @@ -14,14 +20,9 @@ import type { NumberFieldCore, RatingFieldCore, RollupFieldCore, - ConditionalRollupFieldCore, SingleLineTextFieldCore, SingleSelectFieldCore, UserFieldCore, - IFieldVisitor, - FieldCore, - ILinkFieldOptions, - ButtonFieldCore, } from '@teable/core'; import { DbFieldType, Relationship } from '@teable/core'; import type { Knex } from 'knex'; @@ -377,6 +378,10 @@ export class CreatePostgresDatabaseColumnFieldVisitor implements IFieldVisitor { visitButtonField(field: ButtonFieldCore): IFieldSelectName { return this.visitLookupField(field); } + visitColorField(field: ColorFieldCore): IFieldSelectName { + return this.visitLookupField(field); + } } export class FieldCteVisitor implements IFieldVisitor { @@ -2821,6 +2825,7 @@ export class FieldCteVisitor implements IFieldVisitor { visitCreatedByField(_field: CreatedByFieldCore): void {} visitLastModifiedByField(_field: LastModifiedByFieldCore): void {} visitButtonField(_field: ButtonFieldCore): void {} + visitColorField(_field: ColorFieldCore): void {} private ensureLinkCteJoined(cteName: string): void { if (this.state.isCteJoined(cteName)) { diff --git a/apps/nestjs-backend/src/features/record/query-builder/field-formatting-visitor.ts b/apps/nestjs-backend/src/features/record/query-builder/field-formatting-visitor.ts index 063edd2452..9bc6dbfd36 100644 --- a/apps/nestjs-backend/src/features/record/query-builder/field-formatting-visitor.ts +++ b/apps/nestjs-backend/src/features/record/query-builder/field-formatting-visitor.ts @@ -21,6 +21,7 @@ import { type CreatedByFieldCore, type LastModifiedByFieldCore, type ButtonFieldCore, + type ColorFieldCore, type INumberFormatting, type IDatetimeFormatting, } from '@teable/core'; @@ -243,4 +244,8 @@ export class FieldFormattingVisitor implements IFieldVisitor { // Button fields don't have values, return as-is return this.fieldExpression; } + + visitColorField(_field: ColorFieldCore): string { + return this.fieldExpression; + } } diff --git a/apps/nestjs-backend/src/features/record/query-builder/field-select-visitor.ts b/apps/nestjs-backend/src/features/record/query-builder/field-select-visitor.ts index cd222cd5a8..3abe5a8653 100644 --- a/apps/nestjs-backend/src/features/record/query-builder/field-select-visitor.ts +++ b/apps/nestjs-backend/src/features/record/query-builder/field-select-visitor.ts @@ -22,6 +22,7 @@ import type { UserFieldCore, IFieldVisitor, ButtonFieldCore, + ColorFieldCore, TableDomain, } from '@teable/core'; import { DbFieldType, FieldType, isLinkLookupOptions, DriverClient } from '@teable/core'; @@ -578,6 +579,10 @@ export class FieldSelectVisitor implements IFieldVisitor { return this.checkAndSelectLookupField(field); } + visitColorField(field: ColorFieldCore): IFieldSelectName { + return this.checkAndSelectLookupField(field); + } + // Formula field types - these may use generated columns visitFormulaField(field: FormulaFieldCore): IFieldSelectName { // If the formula field has an error (e.g., referenced field deleted), return NULL diff --git a/apps/nestjs-backend/src/types/i18n.generated.ts b/apps/nestjs-backend/src/types/i18n.generated.ts index d990617ec4..3516da8843 100644 --- a/apps/nestjs-backend/src/types/i18n.generated.ts +++ b/apps/nestjs-backend/src/types/i18n.generated.ts @@ -2071,6 +2071,9 @@ export type I18nTranslations = { "refineOptionsError": string; "optionsRequired": string; }; + "color": { + "button": string; + }; }; "filter": { "label": string; @@ -2259,6 +2262,7 @@ export type I18nTranslations = { "button": string; "createdBy": string; "lastModifiedBy": string; + "color": string; }; "description": { "singleLineText": string; @@ -2283,6 +2287,7 @@ export type I18nTranslations = { "button": string; "createdBy": string; "lastModifiedBy": string; + "color": string; }; "link": { "oneWay": string; @@ -3876,6 +3881,9 @@ export type I18nTranslations = { "title": string; "description": string; }; + "color": { + "title": string; + }; }; "editor": { "addField": string; @@ -4019,6 +4027,7 @@ export type I18nTranslations = { "button": string; "lookup": string; "conditionalRollup": string; + "color": string; }; "fieldName": string; "fieldNameOptional": string; diff --git a/apps/nextjs-app/src/features/app/components/field-setting/FieldOptions.tsx b/apps/nextjs-app/src/features/app/components/field-setting/FieldOptions.tsx index 16acb15676..1fc08226be 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/FieldOptions.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/FieldOptions.tsx @@ -29,6 +29,7 @@ import { useFields } from '@teable/sdk/hooks'; import { useMemo } from 'react'; import { ButtonOptions } from './options/ButtonOptions'; import { CheckboxOptions } from './options/CheckboxOptions'; +import { ColorOptions } from './options/ColorOptions'; import { ConditionalRollupOptions } from './options/ConditionalRollupOptions'; import { CreatedTimeOptions } from './options/CreatedTimeOptions'; import { DateOptions } from './options/DateOptions'; @@ -202,6 +203,8 @@ export const FieldOptions: React.FC = ({ field, onChange, on onSave={onSave} /> ); + case FieldType.Color: + return ; default: return <>; } diff --git a/apps/nextjs-app/src/features/app/components/field-setting/SelectFieldType.tsx b/apps/nextjs-app/src/features/app/components/field-setting/SelectFieldType.tsx index d25f44c9c8..d63c6e9629 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/SelectFieldType.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/SelectFieldType.tsx @@ -47,6 +47,7 @@ export const FIELD_TYPE_ORDER1 = [ FieldType.Date, FieldType.Rating, FieldType.Checkbox, + FieldType.Color, FieldType.Attachment, FieldType.Formula, FieldType.Link, @@ -70,6 +71,7 @@ const BASE_FIELD_TYPE = [ FieldType.Date, FieldType.Rating, FieldType.Checkbox, + FieldType.Color, FieldType.Attachment, ]; diff --git a/apps/nextjs-app/src/features/app/components/field-setting/options/ColorOptions.tsx b/apps/nextjs-app/src/features/app/components/field-setting/options/ColorOptions.tsx new file mode 100644 index 0000000000..33ab96850c --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/field-setting/options/ColorOptions.tsx @@ -0,0 +1 @@ +export const ColorOptions = () => null; diff --git a/apps/nextjs-app/src/features/app/components/field-setting/useFieldTypeSubtitle.ts b/apps/nextjs-app/src/features/app/components/field-setting/useFieldTypeSubtitle.ts index ed07831e0c..162f9adc12 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/useFieldTypeSubtitle.ts +++ b/apps/nextjs-app/src/features/app/components/field-setting/useFieldTypeSubtitle.ts @@ -49,6 +49,8 @@ export const useFieldTypeSubtitle = () => { return t('table:field.subTitle.autoNumber'); case FieldType.Button: return t('table:field.subTitle.button'); + case FieldType.Color: + return t('table:field.subTitle.color'); default: { assertNever(fieldType); } diff --git a/packages/common-i18n/src/locales/en/sdk.json b/packages/common-i18n/src/locales/en/sdk.json index d9d4cca364..d101ea1a09 100644 --- a/packages/common-i18n/src/locales/en/sdk.json +++ b/packages/common-i18n/src/locales/en/sdk.json @@ -81,6 +81,9 @@ "from": "From", "to": "To" }, + "color": { + "button": "Pick a color" + }, "formula": { "title": "Formula editor", "guideSyntax": "Syntax", @@ -344,6 +347,7 @@ "lookup": "Lookup", "conditionalLookup": "Conditional lookup", "button": "Button", + "color": "Color", "createdBy": "Created by", "lastModifiedBy": "Last modified by" }, @@ -368,6 +372,7 @@ "lookup": "Display values from linked records.", "conditionalLookup": "Show linked values that match filters you define.", "button": "Run actions with a clickable button.", + "color": "Store a color value as a hex code.", "createdBy": "Show who created the record.", "lastModifiedBy": "Show who last modified the record." }, diff --git a/packages/common-i18n/src/locales/en/table.json b/packages/common-i18n/src/locales/en/table.json index d7fef7adf3..bc11746489 100644 --- a/packages/common-i18n/src/locales/en/table.json +++ b/packages/common-i18n/src/locales/en/table.json @@ -181,6 +181,9 @@ "checkbox": { "title": "Done" }, + "color": { + "title": "Color" + }, "button": { "title": "Button", "label": "Button label", @@ -386,6 +389,7 @@ "lastModifiedBy": "See which user made the most recent edit to some or all fields in a record.", "autoNumber": "Automatically generate unique incremental numbers for each record.", "button": "Trigger a customized action.", + "color": "Store a color value as a hex code.", "lookup": "See values from a field in a linked record." }, "fieldName": "Field name", diff --git a/packages/core/src/models/field/cell-value-validation.ts b/packages/core/src/models/field/cell-value-validation.ts index 88c1df8d25..2beeba7cca 100644 --- a/packages/core/src/models/field/cell-value-validation.ts +++ b/packages/core/src/models/field/cell-value-validation.ts @@ -57,6 +57,8 @@ export const validateCellValue = (field: IFieldVo, cellValue: unknown) => { } case FieldType.Button: return validateWithSchema(buttonFieldCelValueSchema, cellValue); + case FieldType.Color: + return validateWithSchema(singleLineTextCelValueSchema, cellValue); default: assertNever(type); } diff --git a/packages/core/src/models/field/constant.ts b/packages/core/src/models/field/constant.ts index c0400a9331..fd786c1d47 100644 --- a/packages/core/src/models/field/constant.ts +++ b/packages/core/src/models/field/constant.ts @@ -20,6 +20,7 @@ export enum FieldType { LastModifiedBy = 'lastModifiedBy', AutoNumber = 'autoNumber', Button = 'button', + Color = 'color', } export enum DbFieldType { @@ -72,6 +73,7 @@ export const PRIMARY_SUPPORTED_TYPES = new Set([ FieldType.CreatedBy, FieldType.LastModifiedBy, FieldType.AutoNumber, + FieldType.Color, ]); export const IMPORT_SUPPORTED_TYPES = [ diff --git a/packages/core/src/models/field/derivate/color-option.schema.ts b/packages/core/src/models/field/derivate/color-option.schema.ts new file mode 100644 index 0000000000..da516bd707 --- /dev/null +++ b/packages/core/src/models/field/derivate/color-option.schema.ts @@ -0,0 +1,5 @@ +import { z } from '../../../zod'; + +export const colorFieldOptionsSchema = z.object({}); + +export type IColorFieldOptions = z.infer; diff --git a/packages/core/src/models/field/derivate/color.field.ts b/packages/core/src/models/field/derivate/color.field.ts new file mode 100644 index 0000000000..9ab1d93ed0 --- /dev/null +++ b/packages/core/src/models/field/derivate/color.field.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; +import type { CellValueType, FieldType } from '../constant'; +import { FieldCore } from '../field'; +import type { IFieldVisitor } from '../field-visitor.interface'; +import type { IColorFieldOptions } from './color-option.schema'; +import { colorFieldOptionsSchema } from './color-option.schema'; + +const colorRegex = /^#[0-9a-f]{6}$/i; + +const colorCellValueSchema = z.string().regex(colorRegex); + +export class ColorFieldCore extends FieldCore { + type!: FieldType.Color; + + options!: IColorFieldOptions; + + meta?: undefined; + + cellValueType!: CellValueType.String; + + static defaultOptions(): IColorFieldOptions { + return {}; + } + + cellValue2String(cellValue?: unknown): string { + if (this.isMultipleCellValue && Array.isArray(cellValue)) { + return cellValue.join(', '); + } + return (cellValue as string) ?? ''; + } + + item2String(value?: unknown): string { + return (value as string) ?? ''; + } + + convertStringToCellValue(value: string): string | null { + if (this.isLookup) return null; + const v = value?.trim().toUpperCase() ?? null; + if (!v || !colorRegex.test(v)) return null; + return v; + } + + repair(value: unknown): string | null { + if (typeof value === 'string') return this.convertStringToCellValue(value); + return null; + } + + validateOptions() { + return colorFieldOptionsSchema.safeParse(this.options); + } + + validateCellValue(value: unknown) { + if (this.isMultipleCellValue) { + return z.array(colorCellValueSchema).nonempty().nullable().safeParse(value); + } + return colorCellValueSchema.nullable().safeParse(value); + } + + accept(visitor: IFieldVisitor): T { + return visitor.visitColorField(this); + } +} diff --git a/packages/core/src/models/field/derivate/index.ts b/packages/core/src/models/field/derivate/index.ts index aaf51a3851..6c061c2d52 100644 --- a/packages/core/src/models/field/derivate/index.ts +++ b/packages/core/src/models/field/derivate/index.ts @@ -39,3 +39,5 @@ export * from './last-modified-by.field'; export * from './last-modified-by-option.schema'; export * from './button.field'; export * from './button-option.schema'; +export * from './color.field'; +export * from './color-option.schema'; diff --git a/packages/core/src/models/field/field-unions.schema.ts b/packages/core/src/models/field/field-unions.schema.ts index a7648156b3..a7fd0a5c88 100644 --- a/packages/core/src/models/field/field-unions.schema.ts +++ b/packages/core/src/models/field/field-unions.schema.ts @@ -10,6 +10,7 @@ import { } from './derivate/auto-number-option.schema'; import { buttonFieldOptionsSchema } from './derivate/button-option.schema'; import { checkboxFieldOptionsSchema } from './derivate/checkbox-option.schema'; +import { colorFieldOptionsSchema } from './derivate/color-option.schema'; import { conditionalRollupFieldOptionsSchema } from './derivate/conditional-rollup-option.schema'; import { createdByFieldOptionsSchema } from './derivate/created-by-option.schema'; import { @@ -57,6 +58,7 @@ export const unionFieldOptions = z.union([ createdByFieldOptionsSchema.strict(), lastModifiedByFieldOptionsSchema.strict(), buttonFieldOptionsSchema.strict(), + colorFieldOptionsSchema.strict(), ]); // Common options schema for lookup fields diff --git a/packages/core/src/models/field/field-visitor.interface.ts b/packages/core/src/models/field/field-visitor.interface.ts index dd24f9b448..6dfbcc7a5f 100644 --- a/packages/core/src/models/field/field-visitor.interface.ts +++ b/packages/core/src/models/field/field-visitor.interface.ts @@ -2,6 +2,7 @@ import type { AttachmentFieldCore } from './derivate/attachment.field'; import type { AutoNumberFieldCore } from './derivate/auto-number.field'; import type { ButtonFieldCore } from './derivate/button.field'; import type { CheckboxFieldCore } from './derivate/checkbox.field'; +import type { ColorFieldCore } from './derivate/color.field'; import type { ConditionalRollupFieldCore } from './derivate/conditional-rollup.field'; import type { CreatedByFieldCore } from './derivate/created-by.field'; import type { CreatedTimeFieldCore } from './derivate/created-time.field'; @@ -53,4 +54,6 @@ export interface IFieldVisitor { visitLastModifiedByField(field: LastModifiedByFieldCore): T; visitButtonField(field: ButtonFieldCore): T; + + visitColorField(field: ColorFieldCore): T; } diff --git a/packages/core/src/models/field/field.schema.ts b/packages/core/src/models/field/field.schema.ts index 7cadeb9279..718598177e 100644 --- a/packages/core/src/models/field/field.schema.ts +++ b/packages/core/src/models/field/field.schema.ts @@ -10,6 +10,7 @@ import { attachmentFieldOptionsSchema } from './derivate/attachment-option.schem import { autoNumberFieldOptionsRoSchema } from './derivate/auto-number-option.schema'; import { buttonFieldOptionsSchema } from './derivate/button-option.schema'; import { checkboxFieldOptionsSchema } from './derivate/checkbox-option.schema'; +import { colorFieldOptionsSchema } from './derivate/color-option.schema'; import { conditionalRollupFieldOptionsSchema } from './derivate/conditional-rollup-option.schema'; import { createdByFieldOptionsSchema } from './derivate/created-by-option.schema'; import { createdTimeFieldOptionsRoSchema } from './derivate/created-time-option.schema'; @@ -238,6 +239,8 @@ export const getOptionsSchema = (type: FieldType) => { return lastModifiedByFieldOptionsSchema; case FieldType.Button: return buttonFieldOptionsSchema; + case FieldType.Color: + return colorFieldOptionsSchema; default: assertNever(type); } diff --git a/packages/core/src/models/field/options.schema.ts b/packages/core/src/models/field/options.schema.ts index 5790589516..6bb659379b 100644 --- a/packages/core/src/models/field/options.schema.ts +++ b/packages/core/src/models/field/options.schema.ts @@ -5,6 +5,7 @@ import { attachmentFieldOptionsSchema } from './derivate/attachment-option.schem import { autoNumberFieldOptionsSchema } from './derivate/auto-number-option.schema'; import { buttonFieldOptionsSchema } from './derivate/button-option.schema'; import { checkboxFieldOptionsSchema } from './derivate/checkbox-option.schema'; +import { colorFieldOptionsSchema } from './derivate/color-option.schema'; import { conditionalRollupFieldOptionsSchema } from './derivate/conditional-rollup-option.schema'; import { createdByFieldOptionsSchema } from './derivate/created-by-option.schema'; import { createdTimeFieldOptionsSchema } from './derivate/created-time-option.schema'; @@ -62,6 +63,8 @@ export function safeParseOptions(fieldType: FieldType, value: unknown) { return conditionalRollupFieldOptionsSchema.safeParse(value); case FieldType.Button: return buttonFieldOptionsSchema.safeParse(value); + case FieldType.Color: + return colorFieldOptionsSchema.safeParse(value); default: assertNever(fieldType); } diff --git a/packages/sdk/src/components/cell-value-editor/CellEditorMain.tsx b/packages/sdk/src/components/cell-value-editor/CellEditorMain.tsx index 8a0c5b5b35..37cfff0a07 100644 --- a/packages/sdk/src/components/cell-value-editor/CellEditorMain.tsx +++ b/packages/sdk/src/components/cell-value-editor/CellEditorMain.tsx @@ -35,6 +35,7 @@ import { LinkEditor, UserEditor, ButtonEditor, + ColorEditor, } from '../editor'; import { isMarkdownShowAs } from '../editor/long-text/utils'; import type { IEditorRef } from '../editor/type'; @@ -244,6 +245,18 @@ export const CellEditorMain = (props: Omit ); } + case FieldType.Color: { + return ( + + ); + } default: throw new Error(`The field type (${type}) is not implemented editor`); } diff --git a/packages/sdk/src/components/cell-value/CellValue.tsx b/packages/sdk/src/components/cell-value/CellValue.tsx index 60cd626930..69fa739d3d 100644 --- a/packages/sdk/src/components/cell-value/CellValue.tsx +++ b/packages/sdk/src/components/cell-value/CellValue.tsx @@ -18,6 +18,7 @@ import { isMarkdownShowAs, normalizeMarkdownValue, stripMarkdown } from '../edit import { CellAttachment } from './cell-attachment'; import { CellButton } from './cell-button'; import { CellCheckbox } from './cell-checkbox'; +import { CellColor } from './cell-color'; import { CellDate } from './cell-date'; import { CellLink } from './cell-link'; import { CellMarkdown } from './cell-markdown'; @@ -223,6 +224,10 @@ const renderLink: RenderFn = ({ value, className, itemClassName, ellipsis }) => /> ); +const renderColor: RenderFn = ({ value, className }) => ( + +); + const typeRenderers: Partial> = { [FieldType.LongText]: renderLongText, [FieldType.SingleLineText]: renderSingleLineText, @@ -244,6 +249,7 @@ const typeRenderers: Partial> = { [FieldType.Rollup]: renderFormulaLike, [FieldType.ConditionalRollup]: renderFormulaLike, [FieldType.Link]: renderLink, + [FieldType.Color]: renderColor, }; export const CellValue = (props: ICellValueContainer) => { diff --git a/packages/sdk/src/components/cell-value/cell-color/CellColor.tsx b/packages/sdk/src/components/cell-value/cell-color/CellColor.tsx new file mode 100644 index 0000000000..f111bf9619 --- /dev/null +++ b/packages/sdk/src/components/cell-value/cell-color/CellColor.tsx @@ -0,0 +1,23 @@ +import { cn } from '@teable/ui-lib'; +import type { ICellValue } from '../type'; + +interface ICellColor extends ICellValue {} + +export const CellColor = ({ value, className, style }: ICellColor) => { + if (!value) return null; + return ( +
+
+ +
+ + {value} +
+ ); +}; diff --git a/packages/sdk/src/components/cell-value/cell-color/index.ts b/packages/sdk/src/components/cell-value/cell-color/index.ts new file mode 100644 index 0000000000..081f6b6e2f --- /dev/null +++ b/packages/sdk/src/components/cell-value/cell-color/index.ts @@ -0,0 +1 @@ +export * from './CellColor'; diff --git a/packages/sdk/src/components/editor/color/Editor.tsx b/packages/sdk/src/components/editor/color/Editor.tsx new file mode 100644 index 0000000000..cb121655db --- /dev/null +++ b/packages/sdk/src/components/editor/color/Editor.tsx @@ -0,0 +1,123 @@ +import { Button, cn } from '@teable/ui-lib'; +import { + forwardRef, + type ForwardRefRenderFunction, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import { useTranslation } from '../../../context/app/i18n'; +import type { ICellEditor, IEditorRef } from '../type'; + +// we need to have default color for the field, because input with empty value will be invisible +// default color should not be white or black, because that color would not be visible on light or dark theme +const DEFAULT_COLOR = '#00C96F'; + +interface IColorEditor extends ICellEditor {} + +const ColorEditorBase: ForwardRefRenderFunction< + IEditorRef, + IColorEditor & { + value: string; + saveOnChange?: boolean; + isCellEditor?: boolean; + } +> = ( + { value = '', onChange, saveOnBlur = true, saveOnChange = false, isCellEditor = false }, + ref +) => { + const inputRef = useRef(null); + const textRef = useRef(value || DEFAULT_COLOR); + const [text, setText] = useState(value); + const [showButton, setShowButton] = useState(isCellEditor && !value); + const { t } = useTranslation(); + + const updateText = (newVal: string) => { + textRef.current = newVal; + setText(newVal); + }; + + useImperativeHandle(ref, () => ({ + focus: () => { + inputRef.current?.click(); + }, + setValue: (newValue?: string) => updateText(newValue?.toUpperCase() ?? ''), + saveValue: () => onChange?.(textRef.current ? textRef.current.trim().toUpperCase() : null), + })); + + // Native 'change' fires once when the picker is committed (closed/confirmed), + // unlike React's onChange which fires on every 'input' event during interaction. + useEffect(() => { + const input = inputRef.current; + if (!input) return; + const handleCommit = (e: Event) => { + const newVal = (e.target as HTMLInputElement).value; + updateText(newVal); + onChange?.(newVal ? newVal.trim() : null); + }; + input.addEventListener('change', handleCommit); + return () => input.removeEventListener('change', handleCommit); + }, [onChange]); + + const onChangeInner = (e: React.ChangeEvent) => { + const newVal = e.target.value.toUpperCase(); + updateText(newVal); + if (saveOnChange) { + onChange?.(newVal ? newVal.trim() : null); + } + }; + + const button = showButton ? ( + + ) : null; + + return ( +
+ {button} + + + saveOnBlur && !saveOnChange && onChange?.(textRef.current ? textRef.current.trim() : null) + } + /> + + +
+ ); +}; + +export const ColorEditor = forwardRef(ColorEditorBase); diff --git a/packages/sdk/src/components/editor/color/index.ts b/packages/sdk/src/components/editor/color/index.ts new file mode 100644 index 0000000000..8b7c4c267a --- /dev/null +++ b/packages/sdk/src/components/editor/color/index.ts @@ -0,0 +1 @@ +export * from './Editor'; diff --git a/packages/sdk/src/components/editor/index.ts b/packages/sdk/src/components/editor/index.ts index 38a3be291b..8280f90766 100644 --- a/packages/sdk/src/components/editor/index.ts +++ b/packages/sdk/src/components/editor/index.ts @@ -10,3 +10,4 @@ export * from './rating'; export * from './link'; export * from './user'; export * from './button'; +export * from './color'; diff --git a/packages/sdk/src/components/grid-enhancements/editor/GridColorEditor.tsx b/packages/sdk/src/components/grid-enhancements/editor/GridColorEditor.tsx new file mode 100644 index 0000000000..4e429741e5 --- /dev/null +++ b/packages/sdk/src/components/grid-enhancements/editor/GridColorEditor.tsx @@ -0,0 +1,73 @@ +import { + forwardRef, + type ForwardRefRenderFunction, + useEffect, + useImperativeHandle, + useMemo, + useRef, +} from 'react'; +import { useTranslation } from '../../../context/app/i18n'; +import { ColorEditor } from '../../editor'; +import type { IEditorRef } from '../../editor/type'; +import type { IEditorProps } from '../../grid'; +import { GRID_DEFAULT } from '../../grid/configs'; +import type { IWrapperEditorProps } from './type'; + +const { rowHeight: defaultRowHeight } = GRID_DEFAULT; + +const GridColorEditorBase: ForwardRefRenderFunction< + IEditorRef, + IWrapperEditorProps & IEditorProps +> = (props, ref) => { + const { field, record, isEditing, style, theme, rect } = props; + const { width, height } = rect; + const { cellLineColorActived } = theme; + const { t } = useTranslation(); + const editorRef = useRef>(null); + + // Open the picker only when the user activates edit mode (double-click / Enter), + // not on every cell navigation (which also triggers focus() via EditorContainer). + useEffect(() => { + if (isEditing) { + editorRef.current?.focus?.(); + } + }, [isEditing]); + + useImperativeHandle(ref, () => ({ + focus: () => undefined, // picker is opened by the isEditing effect above + setValue: (value?: string) => editorRef.current?.setValue?.(value), + saveValue: () => editorRef.current?.saveValue?.(), + })); + + const onChangeInner = (value?: string | null) => { + record.updateCell(field.id, value?.toUpperCase() ?? null, { t }); + }; + + const cellValue = record.getCellValue(field.id) as string | undefined; + + const attachStyle = useMemo(() => { + const style: React.CSSProperties = { + width: width + 2, + height: height + 2, + marginLeft: -2, + marginTop: -2.5, + }; + if (height > defaultRowHeight) { + style.paddingBottom = height - defaultRowHeight; + } + return style; + }, [height, width]); + + return ( + + ); +}; + +export const GridColorEditor = forwardRef(GridColorEditorBase); diff --git a/packages/sdk/src/components/grid-enhancements/editor/type.ts b/packages/sdk/src/components/grid-enhancements/editor/type.ts index c9467c07f4..a7b04a744b 100644 --- a/packages/sdk/src/components/grid-enhancements/editor/type.ts +++ b/packages/sdk/src/components/grid-enhancements/editor/type.ts @@ -1,5 +1,6 @@ import type { AttachmentField, + ColorField, DateField, LongTextField, MultipleSelectField, @@ -23,7 +24,8 @@ export interface IWrapperEditorProps { | UserField | CreatedByField | LastModifiedByField - | NumberField; + | NumberField + | ColorField; record: Record; style?: React.CSSProperties; onCancel?: () => void; diff --git a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-columns.tsx b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-columns.tsx index c840bbc443..7f5ce675d8 100644 --- a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-columns.tsx +++ b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-columns.tsx @@ -22,7 +22,7 @@ import { CellType, hexToRGBA, getFileCover, onMixedTextClick } from '../..'; import { useTranslation } from '../../../context/app/i18n/useTranslation'; import type { IButtonClickStatusHook } from '../../../hooks'; import { useFields, useTablePermission, useView } from '../../../hooks'; -import type { IFieldInstance, NumberField, Record } from '../../../model'; +import type { ColorField, IFieldInstance, NumberField, Record } from '../../../model'; import type { GridView } from '../../../model/view'; import { isMarkdownShowAs, stripMarkdown } from '../../editor/long-text/utils'; import { getFilterFieldIds } from '../../filter/view-filter/utils'; @@ -38,6 +38,7 @@ import { GridSelectEditor, expandPreviewModal, } from '../editor'; +import { GridColorEditor } from '../editor/GridColorEditor'; import { GridUserEditor } from '../editor/GridUserEditor'; const cellValueStringCache: LRUCache = new LRUCache({ max: 1000 }); @@ -553,6 +554,22 @@ export const useCreateCellValue2GridDisplay = ( }, }; } + case FieldType.Color: { + return { + ...baseCellProps, + type: CellType.Color, + data: (cellValue as string) || '', + displayData: field.cellValue2String(cellValue) || '', + customEditor: (props, editorRef) => ( + + ), + }; + } default: { return { type: CellType.Loading }; } diff --git a/packages/sdk/src/components/grid/renderers/cell-renderer/colorCellRenderer.ts b/packages/sdk/src/components/grid/renderers/cell-renderer/colorCellRenderer.ts new file mode 100644 index 0000000000..244d5ddbe1 --- /dev/null +++ b/packages/sdk/src/components/grid/renderers/cell-renderer/colorCellRenderer.ts @@ -0,0 +1,67 @@ +import { GRID_DEFAULT } from '../../configs'; +import { drawRect, drawSingleLineText } from '../base-renderer'; +import type { ICellRenderProps, IColorCell, IInternalCellRenderer } from './interface'; +import { CellType } from './interface'; + +const { cellHorizontalPadding, rowHeight } = GRID_DEFAULT; + +const SWATCH_SIZE = 22; +const SWATCH_RADIUS = 11; +const SWATCH_GAP = 6; + +const INNER_CIRCLE_SIZE = 14; +const INNER_CIRCLE_RADIUS = 7; + +export const colorCellRenderer: IInternalCellRenderer = { + type: CellType.Color, + + draw(cell: IColorCell, props: ICellRenderProps) { + const { ctx, rect, theme } = props; + const { x, y, width, height } = rect; + const { data, displayData } = cell; + const { cellTextColor, fontSizeXS, fontFamily, cellBg } = theme; + + if (!data) return; + + const swatchX = x + cellHorizontalPadding - 1; + const swatchY = y + (height - SWATCH_SIZE + 1) / 2; + + // Draw color swatch + ctx.save(); + + drawRect(ctx, { + x: swatchX, + y: swatchY, + width: SWATCH_SIZE, + height: SWATCH_SIZE, + radius: SWATCH_RADIUS, + fill: data, + }); + + const circleX = swatchX + 4; + const circleY = swatchY + 4; + + ctx.lineWidth = 2; + drawRect(ctx, { + x: circleX, + y: circleY, + width: INNER_CIRCLE_SIZE, + height: INNER_CIRCLE_SIZE, + radius: INNER_CIRCLE_RADIUS, + stroke: cellBg, + }); + + // Draw hex text next to swatch + ctx.font = `${fontSizeXS}px ${fontFamily}`; + const textX = swatchX + SWATCH_SIZE + SWATCH_GAP + 2; + drawSingleLineText(ctx, { + x: textX, + y: y + (rowHeight - fontSizeXS) / 2, + text: displayData, + fill: cellTextColor, + maxWidth: width - (textX - x) - cellHorizontalPadding, + }); + + ctx.restore(); + }, +}; diff --git a/packages/sdk/src/components/grid/renderers/cell-renderer/index.ts b/packages/sdk/src/components/grid/renderers/cell-renderer/index.ts index f2036ac1c9..f09a4a9010 100644 --- a/packages/sdk/src/components/grid/renderers/cell-renderer/index.ts +++ b/packages/sdk/src/components/grid/renderers/cell-renderer/index.ts @@ -1,6 +1,7 @@ import { booleanCellRenderer } from './booleanCellRenderer'; import { buttonCellRenderer } from './buttonCellRenderer'; import { chartCellRenderer } from './chartCellRenderer'; +import { colorCellRenderer } from './colorCellRenderer'; import { imageCellRenderer } from './imageCellRenderer'; import { CellType } from './interface'; import { linkCellRenderer } from './linkCellRenderer'; @@ -36,6 +37,8 @@ export const getCellRenderer = (cellType: CellType) => { return userCellRenderer; case CellType.Button: return buttonCellRenderer; + case CellType.Color: + return colorCellRenderer; case CellType.Loading: default: return loadingCellRenderer; diff --git a/packages/sdk/src/components/grid/renderers/cell-renderer/interface.ts b/packages/sdk/src/components/grid/renderers/cell-renderer/interface.ts index 182ebabe45..cd9deba9bb 100644 --- a/packages/sdk/src/components/grid/renderers/cell-renderer/interface.ts +++ b/packages/sdk/src/components/grid/renderers/cell-renderer/interface.ts @@ -19,6 +19,7 @@ export enum CellType { Boolean = 'Boolean', Loading = 'Loading', Button = 'Button', + Color = 'Color', } export enum EditorType { @@ -175,6 +176,12 @@ export interface IButtonCell extends IEditableCell { }; } +export interface IColorCell extends IEditableCell { + type: CellType.Color; + data: string; + displayData: string; +} + export type IInnerCell = | ITextCell | ILinkCell @@ -185,7 +192,8 @@ export type IInnerCell = | IBooleanCell | IChartCell | IUserCell - | IButtonCell; + | IButtonCell + | IColorCell; export type ICell = IInnerCell | ILoadingCell; diff --git a/packages/sdk/src/hooks/use-field-static-getter.ts b/packages/sdk/src/hooks/use-field-static-getter.ts index d179bb733f..06a70f42e0 100644 --- a/packages/sdk/src/hooks/use-field-static-getter.ts +++ b/packages/sdk/src/hooks/use-field-static-getter.ts @@ -27,6 +27,7 @@ import { EyeOff, MagicAi, MousePointerClick as MousePointerClickIcon, + PaintBucket as PaintBucketIcon, } from '@teable/icons'; import { useCallback } from 'react'; @@ -35,6 +36,7 @@ import { AttachmentField, AutoNumberField, CheckboxField, + ColorField, CreatedTimeField, DateField, LastModifiedTimeField, @@ -227,6 +229,13 @@ export const useFieldStaticGetter = () => { }, Icon: getIcon(MousePointerClickIcon), }; + case FieldType.Color: + return { + title: t('field.title.color'), + description: t('field.description.color'), + defaultOptions: ColorField.defaultOptions(), + Icon: getIcon(PaintBucketIcon), + }; default: throw new Error(`field type: ${type} has not define statics`); } diff --git a/packages/sdk/src/model/field/color.field.ts b/packages/sdk/src/model/field/color.field.ts new file mode 100644 index 0000000000..501c01776f --- /dev/null +++ b/packages/sdk/src/model/field/color.field.ts @@ -0,0 +1,5 @@ +import { ColorFieldCore } from '@teable/core'; +import { Mixin } from 'ts-mixer'; +import { Field } from './field'; + +export class ColorField extends Mixin(ColorFieldCore, Field) {} diff --git a/packages/sdk/src/model/field/factory.ts b/packages/sdk/src/model/field/factory.ts index 30bb622280..a8dd3c66a5 100644 --- a/packages/sdk/src/model/field/factory.ts +++ b/packages/sdk/src/model/field/factory.ts @@ -6,6 +6,7 @@ import { AttachmentField } from './attachment.field'; import { AutoNumberField } from './auto-number.field'; import { ButtonField } from './button.field'; import { CheckboxField } from './checkbox.field'; +import { ColorField } from './color.field'; import { ConditionalRollupField } from './conditional-rollup.field'; import { CreatedByField } from './created-by.field'; import { CreatedTimeField } from './created-time.field'; @@ -231,6 +232,8 @@ export function createFieldInstance(field: IFieldVo, doc?: Doc) { return plainToInstance(LastModifiedByField, normalizedField); case FieldType.Button: return plainToInstance(ButtonField, normalizedField); + case FieldType.Color: + return plainToInstance(ColorField, normalizedField); default: assertNever(normalizedField.type); } diff --git a/packages/sdk/src/model/field/index.ts b/packages/sdk/src/model/field/index.ts index b90f1afc84..4aa8c940b4 100644 --- a/packages/sdk/src/model/field/index.ts +++ b/packages/sdk/src/model/field/index.ts @@ -19,3 +19,4 @@ export * from './auto-number.field'; export * from './user.field'; export * from './last-modified-by.field'; export * from './created-by.field'; +export * from './color.field'; diff --git a/packages/sdk/src/utils/fieldType.ts b/packages/sdk/src/utils/fieldType.ts index 7cbf9643e5..ffff1071af 100644 --- a/packages/sdk/src/utils/fieldType.ts +++ b/packages/sdk/src/utils/fieldType.ts @@ -10,6 +10,7 @@ export const FIELD_TYPE_ORDER = [ FieldType.Date, FieldType.Rating, FieldType.Checkbox, + FieldType.Color, FieldType.Attachment, FieldType.Formula, FieldType.Link,