@@ -100,6 +100,7 @@ const fields = {
100100 commitment : document . querySelector ( "#field-commitment" ) ,
101101 boardGroup : document . querySelector ( "#field-board-group" ) ,
102102 milestone : document . querySelector ( "#field-milestone" ) ,
103+ extraMetadataContainer : document . querySelector ( "#extra-metadata-fields" ) ,
103104} ;
104105
105106function loadStoredScopePreference ( ) {
@@ -891,28 +892,79 @@ function getBadgeTone(field, value) {
891892 return "neutral" ;
892893}
893894
894- function renderBadge ( value , field = "" ) {
895+ const CORE_METADATA_FIELDS = [ "status" , "priority" , "commitment" , "milestone" ] ;
896+ const RESERVED_METADATA_KEYS = new Set ( [ "id" , "title" , "kind" , "labels" , ...CORE_METADATA_FIELDS ] ) ;
897+
898+ function normalizeMetadataValue ( value ) {
899+ if ( value === null || value === undefined || Array . isArray ( value ) || typeof value === "object" ) {
900+ return "" ;
901+ }
902+
903+ return String ( value ) . trim ( ) ;
904+ }
905+
906+ function getCustomMetadataEntries ( metadata , options = { } ) {
907+ const excludeKey = normalizeBadgeToken ( options . excludeKey || "" ) ;
908+ const maxCount = Number . isFinite ( options . maxCount ) ? options . maxCount : Number . POSITIVE_INFINITY ;
909+ const entries = Object . entries ( metadata || { } )
910+ . map ( ( [ key , value ] ) => ( { key, value : normalizeMetadataValue ( value ) } ) )
911+ . filter ( ( entry ) => entry . value && ! RESERVED_METADATA_KEYS . has ( entry . key ) && normalizeBadgeToken ( entry . key ) !== excludeKey )
912+ . sort ( ( left , right ) => humanizeFilterKey ( left . key ) . localeCompare ( humanizeFilterKey ( right . key ) ) ) ;
913+
914+ return entries . slice ( 0 , maxCount ) ;
915+ }
916+
917+ function buildMetadataBadgeEntries ( metadata , options = { } ) {
918+ const excludeKey = normalizeBadgeToken ( options . excludeKey || "" ) ;
919+ const cardMode = options . cardMode === true ;
920+ const entries = CORE_METADATA_FIELDS
921+ . filter ( ( field ) => normalizeBadgeToken ( field ) !== excludeKey )
922+ . map ( ( field ) => ( { field, value : normalizeMetadataValue ( metadata ?. [ field ] ) , showFieldLabel : false } ) )
923+ . filter ( ( entry ) => entry . value ) ;
924+
925+ const customEntries = getCustomMetadataEntries ( metadata , { excludeKey, maxCount : cardMode ? 2 : Number . POSITIVE_INFINITY } )
926+ . map ( ( entry ) => ( { field : entry . key , value : entry . value , showFieldLabel : true } ) ) ;
927+
928+ return [ ...entries , ...customEntries ] ;
929+ }
930+
931+ function renderBadge ( value , field = "" , options = { } ) {
895932 const normalizedField = normalizeBadgeToken ( field ) ;
896933 const tone = getBadgeTone ( normalizedField , value ) ;
897934 const classes = [ "badge" , `badge-tone-${ tone } ` ] ;
898935 if ( normalizedField ) {
899936 classes . push ( `badge-field-${ normalizedField } ` ) ;
900937 }
901- return `<span class="${ classes . join ( " " ) } ">${ escapeHtml ( value ) } </span>` ;
938+ const label = options . showFieldLabel && normalizedField
939+ ? `${ humanizeFilterKey ( normalizedField ) } : ${ value } `
940+ : value ;
941+ return `<span class="${ classes . join ( " " ) } ">${ escapeHtml ( label ) } </span>` ;
942+ }
943+
944+ function getBadgeMetadata ( item ) {
945+ if ( ! item || typeof item !== "object" ) {
946+ return { } ;
947+ }
948+
949+ if ( item . metadata && typeof item . metadata === "object" ) {
950+ return item . metadata ;
951+ }
952+
953+ return Object . fromEntries ( CORE_METADATA_FIELDS
954+ . map ( ( field ) => [ field , normalizeMetadataValue ( item [ field ] ) ] )
955+ . filter ( ( [ , value ] ) => value ) ) ;
902956}
903957
904- function renderBadges ( item , excludeKey = "" ) {
905- return [
906- excludeKey === "status" ? null : { field : "status" , value : item . status } ,
907- excludeKey === "priority" ? null : { field : "priority" , value : item . priority } ,
908- excludeKey === "commitment" ? null : { field : "commitment" , value : item . commitment } ,
909- excludeKey === "milestone" ? null : { field : "milestone" , value : item . milestone } ,
910- ]
911- . filter ( ( entry ) => entry ?. value )
912- . map ( ( entry ) => renderBadge ( entry . value , entry . field ) )
958+ function renderMetadataBadges ( metadata , excludeKey = "" , options = { } ) {
959+ return buildMetadataBadgeEntries ( metadata || { } , { ...options , excludeKey } )
960+ . map ( ( entry ) => renderBadge ( entry . value , entry . field , { showFieldLabel : entry . showFieldLabel } ) )
913961 . join ( "" ) ;
914962}
915963
964+ function renderBadges ( item , excludeKey = "" , options = { } ) {
965+ return renderMetadataBadges ( getBadgeMetadata ( item ) , excludeKey , options ) ;
966+ }
967+
916968function updateDocumentTitle ( ) {
917969 const repoName = state . workspace ?. repoName || repoNameElement . textContent || "Roadmap" ;
918970 repoNameElement . textContent = repoName ;
@@ -1626,6 +1678,66 @@ function renderBoardGroupField(itemId = state.selectedItemId) {
16261678 fields . boardGroup . value = selectedIndex >= 0 ? String ( selectedIndex ) : "" ;
16271679}
16281680
1681+ function getEditableMetadataOptions ( key , currentValue = "" ) {
1682+ const values = [ ] ;
1683+ const lens = state . workspace ?. availableLenses ?. find ( ( entry ) => entry . key === key ) ;
1684+ const facet = state . workspace ?. availableFilters ?. find ( ( entry ) => entry . key === key ) ;
1685+
1686+ if ( Array . isArray ( lens ?. values ) ) {
1687+ values . push ( ...lens . values ) ;
1688+ }
1689+ if ( Array . isArray ( facet ?. values ) ) {
1690+ values . push ( ...facet . values ) ;
1691+ }
1692+ if ( currentValue ) {
1693+ values . push ( currentValue ) ;
1694+ }
1695+
1696+ return Array . from ( new Set ( values . map ( ( value ) => String ( value ) . trim ( ) ) . filter ( Boolean ) ) ) ;
1697+ }
1698+
1699+ function renderExtraMetadataFields ( item = state . currentItem ) {
1700+ if ( ! fields . extraMetadataContainer ) {
1701+ return ;
1702+ }
1703+
1704+ const entries = getCustomMetadataEntries ( item ?. metadata , { maxCount : Number . POSITIVE_INFINITY } ) ;
1705+ if ( entries . length === 0 ) {
1706+ fields . extraMetadataContainer . hidden = true ;
1707+ fields . extraMetadataContainer . innerHTML = "" ;
1708+ return ;
1709+ }
1710+
1711+ fields . extraMetadataContainer . hidden = false ;
1712+ fields . extraMetadataContainer . innerHTML = entries . map ( ( entry ) => {
1713+ const key = escapeHtml ( entry . key ) ;
1714+ const label = escapeHtml ( humanizeFilterKey ( entry . key ) ) ;
1715+ const value = escapeHtml ( entry . value ) ;
1716+ const options = getEditableMetadataOptions ( entry . key , entry . value ) ;
1717+
1718+ if ( options . length >= 2 ) {
1719+ const optionMarkup = [ '<option value=""></option>' , ...options . map ( ( option ) => {
1720+ const selected = option === entry . value ? " selected" : "" ;
1721+ return `<option value="${ escapeHtml ( option ) } "${ selected } >${ escapeHtml ( option ) } </option>` ;
1722+ } ) ] . join ( "" ) ;
1723+ return `<label><span>${ label } </span><select data-extra-metadata-key="${ key } ">${ optionMarkup } </select></label>` ;
1724+ }
1725+
1726+ return `<label><span>${ label } </span><input data-extra-metadata-key="${ key } " type="text" value="${ value } " /></label>` ;
1727+ } ) . join ( "" ) ;
1728+
1729+ for ( const input of fields . extraMetadataContainer . querySelectorAll ( "[data-extra-metadata-key]" ) ) {
1730+ input . addEventListener ( "input" , ( ) => {
1731+ setDirtyState ( "structured" , true ) ;
1732+ renderPreview ( ) ;
1733+ } ) ;
1734+ input . addEventListener ( "change" , ( ) => {
1735+ setDirtyState ( "structured" , true ) ;
1736+ renderPreview ( ) ;
1737+ } ) ;
1738+ }
1739+ }
1740+
16291741function buildBoardGroupsWithMovedItem ( itemId , targetGroupIndex , boardGroups = buildBoardGroupsPayload ( ) ) {
16301742 if ( ! itemId || ! Number . isInteger ( targetGroupIndex ) || targetGroupIndex < 0 || targetGroupIndex >= boardGroups . length ) {
16311743 return null ;
@@ -1725,7 +1837,7 @@ function buildBoardCardBodyMarkup(item, activeLensKey, extraMetaHtml = "") {
17251837 </span>
17261838 <span class="board-item-id">${ escapeHtml ( item . id ) } </span>
17271839 ${ overview }
1728- <span class="badge-row">${ renderBadges ( item , activeLensKey ) } </span>
1840+ <span class="badge-row">${ renderBadges ( item , activeLensKey , { cardMode : true } ) } </span>
17291841 ` ;
17301842}
17311843
@@ -2468,6 +2580,10 @@ function resetEditor() {
24682580 fields . boardGroup . innerHTML = "" ;
24692581 fields . boardGroup . disabled = true ;
24702582 }
2583+ if ( fields . extraMetadataContainer ) {
2584+ fields . extraMetadataContainer . hidden = true ;
2585+ fields . extraMetadataContainer . innerHTML = "" ;
2586+ }
24712587 sectionsContainer . innerHTML = "" ;
24722588 rawTextElement . value = "" ;
24732589 previewElement . className = "preview-surface preview-empty" ;
@@ -2548,14 +2664,27 @@ function getStructuredSections() {
25482664}
25492665
25502666function getStructuredMetadata ( ) {
2551- return {
2667+ const metadata = {
2668+ ...( state . currentItem ?. metadata || { } ) ,
25522669 id : fields . id . value ,
25532670 title : fields . title . value ,
25542671 status : fields . status . value ,
25552672 priority : fields . priority . value ,
25562673 commitment : fields . commitment . value ,
25572674 milestone : fields . milestone . value . trim ( ) ,
25582675 } ;
2676+
2677+ if ( fields . extraMetadataContainer ) {
2678+ for ( const input of fields . extraMetadataContainer . querySelectorAll ( "[data-extra-metadata-key]" ) ) {
2679+ const key = input . dataset . extraMetadataKey ;
2680+ if ( ! key ) {
2681+ continue ;
2682+ }
2683+ metadata [ key ] = input . value . trim ( ) ;
2684+ }
2685+ }
2686+
2687+ return metadata ;
25592688}
25602689
25612690function renderPreview ( ) {
@@ -2565,19 +2694,15 @@ function renderPreview() {
25652694 return ;
25662695 }
25672696
2568- const metadata = getStructuredMetadata ( ) ;
2569- const sections = getStructuredSections ( ) ;
2570- const orderedSections = getStructuredSectionHeadings ( ) . filter ( ( heading ) => Object . hasOwn ( sections , heading ) ) ;
2571- const previewBadges = [
2572- { field : "status" , value : metadata . status } ,
2573- { field : "priority" , value : metadata . priority } ,
2574- { field : "commitment" , value : metadata . commitment } ,
2575- { field : "milestone" , value : metadata . milestone } ,
2576- ]
2577- . filter ( ( entry ) => entry . value )
2578- . map ( ( entry ) => renderBadge ( entry . value , entry . field ) )
2579- . join ( "" ) ;
2580- const sectionHtml = orderedSections . map ( ( heading ) => `
2697+ const useDraftState = state . dirtyStructured ;
2698+ const metadata = useDraftState ? getStructuredMetadata ( ) : state . currentItem . metadata ;
2699+ const orderedSections = getStructuredSectionHeadings ( state . currentItem ) ;
2700+ const sections = useDraftState
2701+ ? getStructuredSections ( )
2702+ : Object . fromEntries ( orderedSections . map ( ( heading ) => [ heading , getSectionValueFromItem ( state . currentItem , heading ) ] ) ) ;
2703+ const visibleSections = orderedSections . filter ( ( heading ) => Object . hasOwn ( sections , heading ) ) ;
2704+ const previewBadges = renderMetadataBadges ( metadata ) ;
2705+ const sectionHtml = visibleSections . map ( ( heading ) => `
25812706 <section class="preview-section">
25822707 <div class="preview-section-header">
25832708 <h3>${ escapeHtml ( heading ) } </h3>
@@ -2606,6 +2731,7 @@ function renderItem(item) {
26062731 ensureSelectValue ( fields . commitment , item . metadata . commitment || "uncommitted" ) ;
26072732 renderBoardGroupField ( item . metadata . id || item . id ) ;
26082733 fields . milestone . value = item . metadata . milestone || "" ;
2734+ renderExtraMetadataFields ( item ) ;
26092735 renderStructuredSections ( item ) ;
26102736 rawTextElement . value = item . rawText || "" ;
26112737 autosizeStructuredTextareas ( ) ;
@@ -3047,7 +3173,10 @@ saveButton.addEventListener("click", () => {
30473173} ) ;
30483174
30493175refreshButton . addEventListener ( "click" , ( ) => {
3050- void loadWorkspace ( ) ;
3176+ void loadWorkspace ( state . selectedItemId , {
3177+ forceReloadItem : Boolean ( state . selectedItemId ) ,
3178+ replaceRoute : true ,
3179+ } ) ;
30513180} ) ;
30523181
30533182setupViewElement . addEventListener ( "click" , ( event ) => {
@@ -3267,3 +3396,8 @@ void loadWorkspace(initialRoute.itemId || state.selectedItemId, {
32673396 }
32683397} ) ;
32693398
3399+
3400+
3401+
3402+
3403+
0 commit comments