From 86974549a77fbb5529627fdac0afd0ee96293497 Mon Sep 17 00:00:00 2001 From: Tristan Mouchet Date: Tue, 24 Mar 2026 11:07:53 +0100 Subject: [PATCH 1/4] fix: improve CMS item status tracking and UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix "Published · Edited" status not showing after editing published items - Backfill null content_hash on published items during status enrichment - Set content_hash on published items in bulk and single publish flows - Publish collection fields before item values to prevent FK violations - Optimistically update status pill when saving published items - Add Edit option and isModified-aware disabled states to context menu - Allow clicking CMS items in manual order mode to open the sheet - Disable browser autocomplete on field and item form inputs - Remove duplicate richTextImage tiptap extension --- app/ycode/components/CMS.tsx | 34 +++++++------ .../components/CollectionItemContextMenu.tsx | 12 ++++- app/ycode/components/CollectionItemSheet.tsx | 17 ++++--- app/ycode/components/FieldFormDialog.tsx | 6 +++ app/ycode/components/RichTextEditor.tsx | 1 - lib/repositories/collectionItemRepository.ts | 49 ++++++++++++++++++- lib/services/collectionService.ts | 9 +++- stores/useCollectionsStore.ts | 15 +++++- 8 files changed, 112 insertions(+), 31 deletions(-) diff --git a/app/ycode/components/CMS.tsx b/app/ycode/components/CMS.tsx index d52d3640..8420f687 100644 --- a/app/ycode/components/CMS.tsx +++ b/app/ycode/components/CMS.tsx @@ -112,6 +112,7 @@ interface SortableRowProps { isCollectionPublished: boolean; children: React.ReactNode; statusValue: import('./CollectionStatusPill').ItemStatusValue | null; + onEdit: () => void; onSetAsDraft: () => void; onStageForPublish: () => void; onSetAsPublished: () => void; @@ -120,7 +121,7 @@ interface SortableRowProps { lockInfo?: ItemLockInfo; } -function SortableRow({ item, isSaving, isManualMode, isCollectionPublished, children, statusValue, onSetAsDraft, onStageForPublish, onSetAsPublished, onDuplicate, onDelete, lockInfo }: SortableRowProps) { +function SortableRow({ item, isSaving, isManualMode, isCollectionPublished, children, statusValue, onEdit, onSetAsDraft, onStageForPublish, onSetAsPublished, onDuplicate, onDelete, lockInfo }: SortableRowProps) { const { attributes, listeners, @@ -147,7 +148,9 @@ function SortableRow({ item, isSaving, isManualMode, isCollectionPublished, chil handleEditItem(item)} onSetAsDraft={() => handleSetItemStatus(item.id, 'draft')} onStageForPublish={() => handleSetItemStatus(item.id, 'stage')} onSetAsPublished={() => handleSetItemStatus(item.id, 'publish')} @@ -1576,9 +1580,7 @@ const CMS = React.memo(function CMS() { className="pl-5 pr-3 py-3 w-12" onClick={(e) => { e.stopPropagation(); - if (!isManualMode) { - handleEditItem(item); - } + handleEditItem(item); }} >
@@ -1599,7 +1601,7 @@ const CMS = React.memo(function CMS() { !isManualMode && handleEditItem(item)} + onClick={() => handleEditItem(item)} > !isManualMode && handleEditItem(item)} + onClick={() => handleEditItem(item)} > {formatDateInTimezone(value, timezone, 'display')} @@ -1637,7 +1639,7 @@ const CMS = React.memo(function CMS() { !isManualMode && handleEditItem(item)} + onClick={() => handleEditItem(item)} > - @@ -1648,7 +1650,7 @@ const CMS = React.memo(function CMS() { !isManualMode && handleEditItem(item)} + onClick={() => handleEditItem(item)} >
{assetIds.slice(0, 3).map((assetId, idx) => { @@ -1716,7 +1718,7 @@ const CMS = React.memo(function CMS() { !isManualMode && handleEditItem(item)} + onClick={() => handleEditItem(item)} > - @@ -1727,7 +1729,7 @@ const CMS = React.memo(function CMS() { !isManualMode && handleEditItem(item)} + onClick={() => handleEditItem(item)} >
{assetIds.slice(0, 3).map((assetId, idx) => { @@ -1763,7 +1765,7 @@ const CMS = React.memo(function CMS() { !isManualMode && handleEditItem(item)} + onClick={() => handleEditItem(item)} > !isManualMode && handleEditItem(item)} + onClick={() => handleEditItem(item)} > {plainText || '-'} @@ -1828,7 +1830,7 @@ const CMS = React.memo(function CMS() { !isManualMode && handleEditItem(item)} + onClick={() => handleEditItem(item)} > {displayValue} @@ -1843,7 +1845,7 @@ const CMS = React.memo(function CMS() { !isManualMode && handleEditItem(item)} + onClick={() => handleEditItem(item)} >
!isManualMode && handleEditItem(item)} + onClick={() => handleEditItem(item)} >
!isManualMode && handleEditItem(item)} + onClick={() => handleEditItem(item)} > {value || '-'} diff --git a/app/ycode/components/CollectionItemContextMenu.tsx b/app/ycode/components/CollectionItemContextMenu.tsx index 812cbe16..9e5fea59 100644 --- a/app/ycode/components/CollectionItemContextMenu.tsx +++ b/app/ycode/components/CollectionItemContextMenu.tsx @@ -7,7 +7,9 @@ interface CollectionItemContextMenuProps { children: React.ReactNode; isPublishable: boolean; hasPublishedVersion: boolean; + isModified: boolean; isCollectionPublished: boolean; + onEdit: () => void; onSetAsDraft: () => void; onStageForPublish: () => void; onSetAsPublished: () => void; @@ -20,7 +22,9 @@ export default function CollectionItemContextMenu({ children, isPublishable, hasPublishedVersion, + isModified, isCollectionPublished, + onEdit, onSetAsDraft, onStageForPublish, onSetAsPublished, @@ -38,6 +42,10 @@ export default function CollectionItemContextMenu({ {children} + + Edit CMS item + + Stage for publish Set as published diff --git a/app/ycode/components/CollectionItemSheet.tsx b/app/ycode/components/CollectionItemSheet.tsx index 37ef6b49..860bb4ea 100644 --- a/app/ycode/components/CollectionItemSheet.tsx +++ b/app/ycode/components/CollectionItemSheet.tsx @@ -211,12 +211,14 @@ export default function CollectionItemSheet({ // Reset form when editing item changes useEffect(() => { + // Only include fillable/visible fields in form state to avoid + // sending computed fields (status, ID, timestamps) to the API + const editableFields = collectionFields.filter(f => f.fillable && !f.hidden); + if (editingItem) { - // Ensure all values are defined (not undefined) const values: Record = {}; - collectionFields.forEach(field => { + editableFields.forEach(field => { let value = editingItem.values[field.id] ?? ''; - // Normalize boolean values to strings if (field.type === 'boolean') { value = normalizeBooleanValue(value); } @@ -224,11 +226,9 @@ export default function CollectionItemSheet({ }); form.reset(values); } else { - // Reset with default values for new items const defaultValues: Record = {}; - collectionFields.forEach(field => { + editableFields.forEach(field => { let value = field.default || ''; - // Normalize boolean values to strings if (field.type === 'boolean') { value = normalizeBooleanValue(value); } @@ -607,17 +607,20 @@ export default function CollectionItemSheet({ ) : field.type === 'phone' ? ( ) : field.type === 'date' ? ( { const utcValue = localDatetimeToUTC(e.target.value, timezone); @@ -786,6 +789,7 @@ export default function CollectionItemSheet({ )} diff --git a/app/ycode/components/FieldFormDialog.tsx b/app/ycode/components/FieldFormDialog.tsx index 7f26c3f5..368103fd 100644 --- a/app/ycode/components/FieldFormDialog.tsx +++ b/app/ycode/components/FieldFormDialog.tsx @@ -190,6 +190,7 @@ export default function FieldFormDialog({ value={fieldName} onChange={(e) => setFieldName(e.target.value)} placeholder="Field name" + autoComplete="off" />
@@ -351,6 +352,7 @@ export default function FieldFormDialog({ value={fieldDefault} onChange={(e) => setFieldDefault(e.target.value)} placeholder="0" + autoComplete="off" /> ) : fieldType === 'date' ? ( setFieldDefault(e.target.value)} + autoComplete="off" /> ) : fieldType === 'email' ? ( setFieldDefault(e.target.value)} placeholder="email@example.com" + autoComplete="off" /> ) : fieldType === 'phone' ? ( setFieldDefault(e.target.value)} placeholder="+1 (555) 000-0000" + autoComplete="off" /> ) : ( setFieldDefault(e.target.value)} placeholder="Default value" + autoComplete="off" /> )}
diff --git a/app/ycode/components/RichTextEditor.tsx b/app/ycode/components/RichTextEditor.tsx index bae516d9..1f9c3142 100644 --- a/app/ycode/components/RichTextEditor.tsx +++ b/app/ycode/components/RichTextEditor.tsx @@ -445,7 +445,6 @@ const RichTextEditor = forwardRef(({ Code, RichTextImageWithNodeView, HorizontalRule, - RichTextImage, ]; // Always include heading extension so content with headings is preserved diff --git a/lib/repositories/collectionItemRepository.ts b/lib/repositories/collectionItemRepository.ts index e8230898..816e2c36 100644 --- a/lib/repositories/collectionItemRepository.ts +++ b/lib/repositories/collectionItemRepository.ts @@ -3,7 +3,8 @@ import { SUPABASE_QUERY_LIMIT } from '@/lib/supabase-constants'; import type { CollectionItem, CollectionItemWithValues } from '@/types'; import { randomUUID } from 'crypto'; import { getFieldsByCollectionId } from '@/lib/repositories/collectionFieldRepository'; -import { getValuesByFieldId, getValuesByItemIds } from '@/lib/repositories/collectionItemValueRepository'; +import { getValuesByFieldId, getValuesByItemIds, getValuesByItemId } from '@/lib/repositories/collectionItemValueRepository'; +import { generateCollectionItemContentHash } from '@/lib/hash-utils'; import { castValue } from '../collection-utils'; import { findStatusFieldId, buildStatusValue } from '@/lib/collection-field-utils'; @@ -286,10 +287,29 @@ export async function enrichItemsWithStatus( if (error) throw new Error(`Failed to fetch published items for status: ${error.message}`); - const publishedHashMap = new Map( + const publishedHashMap = new Map( (publishedRows || []).map(row => [row.id, row.content_hash]) ); + // Backfill published items that have null content_hash + const itemsMissingHash = (publishedRows || []).filter(row => row.content_hash == null); + if (itemsMissingHash.length > 0) { + const backfillPromises = itemsMissingHash.map(async (row) => { + const pubValues = await getValuesByItemId(row.id, true); + if (pubValues.length === 0) return; + const hash = generateCollectionItemContentHash( + pubValues.map(v => ({ field_id: v.field_id, value: v.value })) + ); + publishedHashMap.set(row.id, hash); + await client + .from('collection_items') + .update({ content_hash: hash }) + .eq('id', row.id) + .eq('is_published', true); + }); + await Promise.all(backfillPromises); + } + for (const item of items) { const publishedHash = publishedHashMap.get(item.id); const hasPublishedVersion = publishedHash !== undefined; @@ -1324,6 +1344,31 @@ export async function publishSingleItem(itemId: string): Promise { .eq('is_published', false); } + // Ensure published fields exist (values FK requires them) + const draftFields = await getFieldsByCollectionId(draftItem.collection_id, false); + if (draftFields.length > 0) { + const fieldsToUpsert = draftFields.map(f => ({ + id: f.id, + name: f.name, + key: f.key, + type: f.type, + default: f.default, + fillable: f.fillable, + order: f.order, + collection_id: f.collection_id, + reference_collection_id: f.reference_collection_id, + hidden: f.hidden, + is_computed: f.is_computed, + data: f.data, + is_published: true, + created_at: f.created_at, + updated_at: now, + })); + await client + .from('collection_fields') + .upsert(fieldsToUpsert, { onConflict: 'id,is_published' }); + } + // Upsert published item row await client .from('collection_items') diff --git a/lib/services/collectionService.ts b/lib/services/collectionService.ts index 889dd1d7..a1a361d2 100644 --- a/lib/services/collectionService.ts +++ b/lib/services/collectionService.ts @@ -460,8 +460,12 @@ async function publishSelectedItems( for (const item of publishableItems) { const existing = publishedItemsById.get(item.id); - if (existing && existing.manual_order === item.manual_order && existing.is_publishable === item.is_publishable) { - // Item metadata unchanged - still need to check values + const metadataChanged = !existing + || existing.manual_order !== item.manual_order + || existing.is_publishable !== item.is_publishable + || existing.content_hash !== item.content_hash; + + if (!metadataChanged) { itemIdsToPublishValues.push(item.id); continue; } @@ -472,6 +476,7 @@ async function publishSelectedItems( manual_order: item.manual_order, is_publishable: item.is_publishable, is_published: true, + content_hash: item.content_hash, created_at: item.created_at, updated_at: now, }); diff --git a/stores/useCollectionsStore.ts b/stores/useCollectionsStore.ts index 9dc28d6c..3f461988 100644 --- a/stores/useCollectionsStore.ts +++ b/stores/useCollectionsStore.ts @@ -698,13 +698,24 @@ export const useCollectionsStore = create((set, get) => ({ const previousItems = get().items[collectionId] || []; const previousItem = previousItems.find(item => item.id === itemId); - // Optimistically update item in store (no loading state) + // Optimistically set status to "Published · Edited" if the item is currently published + const statusFieldId = findStatusFieldId(get().fields[collectionId] || []); + const optimisticStatus: Record = {}; + if (statusFieldId && previousItem) { + try { + const current = JSON.parse(previousItem.values[statusFieldId] || '{}'); + if (current.is_published) { + optimisticStatus[statusFieldId] = buildStatusValue(current.is_publishable, true, true); + } + } catch { /* non-JSON status value, skip */ } + } + set(state => ({ items: { ...state.items, [collectionId]: (state.items[collectionId] || []).map(item => item.id === itemId - ? { ...item, values, updated_at: new Date().toISOString() } + ? { ...item, values: { ...item.values, ...values, ...optimisticStatus }, updated_at: new Date().toISOString() } : item ), }, From 137394e4d431868390bdb4ef135d20aaa49700c9 Mon Sep 17 00:00:00 2001 From: Tristan Mouchet Date: Tue, 24 Mar 2026 11:38:01 +0100 Subject: [PATCH 2/4] fix: adding a collection should add its status field --- app/ycode/api/collections/[id]/fields/route.ts | 3 ++- lib/collection-field-utils.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/ycode/api/collections/[id]/fields/route.ts b/app/ycode/api/collections/[id]/fields/route.ts index 4d3010e5..e7798cdb 100644 --- a/app/ycode/api/collections/[id]/fields/route.ts +++ b/app/ycode/api/collections/[id]/fields/route.ts @@ -78,8 +78,9 @@ export async function POST( order: body.order ?? 0, reference_collection_id: body.reference_collection_id || null, hidden: body.hidden ?? false, + is_computed: body.is_computed ?? false, data: body.data || {}, - is_published: false, // Always create as draft + is_published: false, }); return noCache( diff --git a/lib/collection-field-utils.ts b/lib/collection-field-utils.ts index 389bf9ab..b7239593 100644 --- a/lib/collection-field-utils.ts +++ b/lib/collection-field-utils.ts @@ -59,8 +59,8 @@ export const FIELD_TYPES_BY_CATEGORY = FIELD_TYPE_CATEGORIES.map(cat => ({ types: FIELD_TYPES.filter(t => t.category === cat.id), })); -/** Valid field type values for API validation */ -export const VALID_FIELD_TYPES: readonly string[] = FIELD_TYPES.map((t) => t.value); +/** Valid field type values for API validation (includes system types like 'status') */ +export const VALID_FIELD_TYPES: readonly string[] = [...FIELD_TYPES.map((t) => t.value), 'status']; /** Check if a field type supports setting a default value */ export function supportsDefaultValue(fieldType: CollectionFieldType | undefined): boolean { From edb3872be852463f17be4ed86aa9205b8564738f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Lun=C4=97nas?= <34475426+lunenas@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:54:22 +0200 Subject: [PATCH 3/4] fix: remove w-full from SelectTrigger base styles Made-with: Cursor --- components/ui/select.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ui/select.tsx b/components/ui/select.tsx index 02f2f7fd..0b583d9c 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -28,7 +28,7 @@ function SelectValue({ } const selectVariants = cva( - "w-full border-transparent data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-[0px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex items-center justify-between gap-1 rounded-lg border bg-transparent px-2 py-1 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "border-transparent data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-[0px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex items-center justify-between gap-1 rounded-lg border bg-transparent px-2 py-1 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { From e468204c8af25311a47609e7e3bce3d1a42c4191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Lun=C4=97nas?= <34475426+lunenas@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:55:45 +0200 Subject: [PATCH 4/4] fix: RichTextLinkSettings width --- app/ycode/components/RichTextLinkSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ycode/components/RichTextLinkSettings.tsx b/app/ycode/components/RichTextLinkSettings.tsx index d9e4a059..4e0aa44b 100644 --- a/app/ycode/components/RichTextLinkSettings.tsx +++ b/app/ycode/components/RichTextLinkSettings.tsx @@ -517,7 +517,7 @@ export default function RichTextLinkSettings({ {/* Link Type */}
-
+