@@ -1667,6 +1667,192 @@ function ActionMultiWidget({ id, value, onChange, readOnly, context }: WidgetPro
16671667}
16681668
16691669/* -------------------------------------------------------------------------- */
1670+ /* Operator lists match FilterBuilder exactly — same operator sets per field type.
1671+ Values are snake_case (spec protocol), labels are human-readable. */
1672+ const OP_LABELS : Record < string , string > = {
1673+ equals : 'Equals' , not_equals : 'Does not equal' ,
1674+ contains : 'Contains' , not_contains : 'Does not contain' ,
1675+ starts_with : 'Starts with' , ends_with : 'Ends with' ,
1676+ is_empty : 'Is empty' , is_not_empty : 'Is not empty' ,
1677+ is_null : 'Is null' , is_not_null : 'Is not null' ,
1678+ greater_than : 'Greater than' , less_than : 'Less than' ,
1679+ greater_than_or_equal : 'Greater than or equal' , less_than_or_equal : 'Less than or equal' ,
1680+ before : 'Before' , after : 'After' , between : 'Between' ,
1681+ in : 'In' , not_in : 'Not in' ,
1682+ } ;
1683+
1684+ /** Per-type operator lists — match FilterBuilder textOperators / numberOperators / etc.
1685+ * in snake_case (spec ViewFilterRuleSchema enum). starts_with / ends_with / is_null /
1686+ * is_not_null are valid spec values but only shown when the current value uses them. */
1687+ const TEXT_OPS = [ 'equals' , 'not_equals' , 'contains' , 'not_contains' , 'is_empty' , 'is_not_empty' ] ;
1688+ const NUMBER_OPS = [ 'equals' , 'not_equals' , 'greater_than' , 'less_than' , 'greater_than_or_equal' , 'less_than_or_equal' , 'is_empty' , 'is_not_empty' ] ;
1689+ const BOOLEAN_OPS = [ 'equals' , 'not_equals' ] ;
1690+ const DATE_OPS = [ 'equals' , 'not_equals' , 'before' , 'after' , 'between' , 'is_empty' , 'is_not_empty' ] ;
1691+ const SELECT_OPS = [ 'equals' , 'not_equals' , 'in' , 'not_in' , 'is_empty' , 'is_not_empty' ] ;
1692+
1693+ const OPS_BY_TYPE : Record < string , string [ ] > = {
1694+ text : TEXT_OPS , email : TEXT_OPS , url : TEXT_OPS , phone : TEXT_OPS , password : TEXT_OPS , textarea : TEXT_OPS , markdown : TEXT_OPS , html : TEXT_OPS , richtext : TEXT_OPS ,
1695+ number : NUMBER_OPS , currency : NUMBER_OPS , percent : NUMBER_OPS , rating : NUMBER_OPS , integer : NUMBER_OPS ,
1696+ boolean : BOOLEAN_OPS , toggle : BOOLEAN_OPS ,
1697+ date : DATE_OPS , datetime : DATE_OPS , time : DATE_OPS ,
1698+ select : SELECT_OPS , status : SELECT_OPS , multiselect : SELECT_OPS , radio : SELECT_OPS ,
1699+ lookup : SELECT_OPS , master_detail : SELECT_OPS , user : SELECT_OPS , owner : SELECT_OPS ,
1700+ } ;
1701+
1702+ function lbl ( op : string ) { return OP_LABELS [ op ] || op ; }
1703+
1704+ function OperatorWidget ( { value, onChange, readOnly, context, formData, schema } : WidgetProps ) {
1705+ const locale = useMetadataLocale ( ) ;
1706+ const fieldName = formData ?. field as string | undefined ;
1707+ const objectFields = context ?. objectFields ?? [ ] ;
1708+ const allOps : string [ ] = schema ?. enum ?? [ ] ;
1709+
1710+ // If we can determine the field type, filter operators accordingly
1711+ let ops = allOps ;
1712+ if ( fieldName && objectFields . length ) {
1713+ const fieldInfo = objectFields . find ( ( f : any ) => f . name === fieldName || f . field === fieldName ) ;
1714+ if ( fieldInfo ?. type ) {
1715+ ops = OPS_BY_TYPE [ fieldInfo . type ] ?? allOps ;
1716+ }
1717+ }
1718+ // Always include the current value so it doesn't disappear
1719+ const current = value == null ? '' : String ( value ) ;
1720+ if ( current && ! ops . includes ( current ) ) ops = [ current , ...ops ] ;
1721+
1722+ return (
1723+ < Select value = { current } onValueChange = { ( v : string ) => onChange ( v ) } disabled = { readOnly } >
1724+ < SelectTrigger className = "h-8 text-sm" >
1725+ < SelectValue placeholder = { t ( 'engine.form.selectEllipsis' , locale ) } />
1726+ </ SelectTrigger >
1727+ < SelectContent >
1728+ { ops . map ( ( op : string ) => (
1729+ < SelectItem key = { op } value = { op } > { lbl ( op ) } </ SelectItem >
1730+ ) ) }
1731+ </ SelectContent >
1732+ </ Select >
1733+ ) ;
1734+ }
1735+
1736+ function ValueWidget ( { value, onChange, readOnly, context, formData } : WidgetProps ) {
1737+ const locale = useMetadataLocale ( ) ;
1738+ const fieldName = formData ?. field as string | undefined ;
1739+ const operator = ( formData ?. operator as string | undefined ) ?? '' ;
1740+ const objectFields = context ?. objectFields ?? [ ] ;
1741+ const current = value == null ? '' : String ( value ) ;
1742+
1743+ // Unary operators that don't need a value
1744+ const UNARY = new Set ( [ 'is_empty' , 'is_not_empty' , 'is_null' , 'is_not_null' ] ) ;
1745+ if ( UNARY . has ( operator ) ) {
1746+ return (
1747+ < Input value = "\u2014" disabled className = "h-8 text-sm text-muted-foreground" />
1748+ ) ;
1749+ }
1750+
1751+ // Determine the field type from the object field catalog
1752+ let fieldType = 'text' ;
1753+ if ( fieldName && objectFields . length ) {
1754+ const fi = objectFields . find ( ( f : any ) => f . name === fieldName || f . field === fieldName ) ;
1755+ if ( fi ?. type ) fieldType = fi . type ;
1756+ }
1757+
1758+ // Boolean -> toggle
1759+ if ( fieldType === 'boolean' || fieldType === 'toggle' ) {
1760+ return (
1761+ < div className = "flex items-center gap-2 h-8" >
1762+ < Switch
1763+ checked = { current === 'true' || current === '1' }
1764+ disabled = { readOnly }
1765+ onCheckedChange = { ( c ) => onChange ( String ( c ) ) }
1766+ />
1767+ < span className = "text-xs text-muted-foreground" > { current === 'true' || current === '1' ? 'true' : 'false' } </ span >
1768+ </ div >
1769+ ) ;
1770+ }
1771+
1772+ // Number / Currency / Percent -> number input
1773+ const isNum = fieldType === 'number' || fieldType === 'currency' || fieldType === 'percent' || fieldType === 'integer' || fieldType === 'rating' ;
1774+ if ( isNum ) {
1775+ return (
1776+ < Input
1777+ type = "number"
1778+ value = { current }
1779+ disabled = { readOnly }
1780+ placeholder = { t ( 'engine.form.valuePlaceholder' , locale ) }
1781+ className = "h-8 text-sm"
1782+ onChange = { ( e ) => {
1783+ const v = e . target . value ;
1784+ onChange ( v === '' ? undefined : Number ( v ) ) ;
1785+ } }
1786+ />
1787+ ) ;
1788+ }
1789+
1790+ // Date / DateTime -> date input
1791+ if ( fieldType === 'date' || fieldType === 'datetime' ) {
1792+ return (
1793+ < Input
1794+ type = { fieldType === 'datetime' ? 'datetime-local' : 'date' }
1795+ value = { current }
1796+ disabled = { readOnly }
1797+ className = "h-8 text-sm"
1798+ onChange = { ( e ) => onChange ( e . target . value || undefined ) }
1799+ />
1800+ ) ;
1801+ }
1802+
1803+ // Between operator -> two inputs (start + end)
1804+ if ( operator === 'between' ) {
1805+ const arr = Array . isArray ( value ) ? value . map ( String ) : [ '' , '' ] ;
1806+ return (
1807+ < div className = "flex items-center gap-1" >
1808+ < Input
1809+ type = "text"
1810+ value = { arr [ 0 ] ?? '' }
1811+ disabled = { readOnly }
1812+ placeholder = "from"
1813+ className = "h-8 text-sm flex-1"
1814+ onChange = { ( e ) => onChange ( [ e . target . value || undefined , arr [ 1 ] || undefined ] ) }
1815+ />
1816+ < span className = "text-xs text-muted-foreground shrink-0" > –</ span >
1817+ < Input
1818+ type = "text"
1819+ value = { arr [ 1 ] ?? '' }
1820+ disabled = { readOnly }
1821+ placeholder = "to"
1822+ className = "h-8 text-sm flex-1"
1823+ onChange = { ( e ) => onChange ( [ arr [ 0 ] || undefined , e . target . value || undefined ] ) }
1824+ />
1825+ </ div >
1826+ ) ;
1827+ }
1828+
1829+ // In / NotIn -> comma-separated values
1830+ if ( operator === 'in' || operator === 'not_in' ) {
1831+ return (
1832+ < Input
1833+ value = { current }
1834+ disabled = { readOnly }
1835+ placeholder = "value1, value2, ..."
1836+ className = "h-8 text-sm"
1837+ onChange = { ( e ) => onChange ( e . target . value || undefined ) }
1838+ />
1839+ ) ;
1840+ }
1841+
1842+ // Default: text input
1843+ return (
1844+ < Input
1845+ value = { current }
1846+ disabled = { readOnly }
1847+ placeholder = { t ( 'engine.form.valuePlaceholder' , locale ) }
1848+ className = "h-8 text-sm"
1849+ onChange = { ( e ) => onChange ( e . target . value || undefined ) }
1850+ />
1851+ ) ;
1852+ }
1853+
1854+
1855+
16701856/* filter-builder — the SAME runtime FilterBuilder used by the list toolbar, */
16711857/* reused in Studio for tab presets and the page base filter (unified UX). */
16721858/* Stored format stays spec ViewFilterRule[] ({field,operator,value}); the */
@@ -1952,6 +2138,8 @@ export const WIDGETS: Record<string, WidgetRenderer> = {
19522138 'field-multi' : FieldRefMultiWidget ,
19532139 'action-multi' : ActionMultiWidget ,
19542140 'filter-builder' : FilterBuilderWidget ,
2141+ 'operator' : OperatorWidget ,
2142+ 'filter-value' : ValueWidget ,
19552143 'view-ref' : ViewRefWidget ,
19562144 'icon' : IconPickerWidget ,
19572145 'color-picker' : ColorPickerWidget ,
0 commit comments