diff --git a/src/renderer/src/__tests__/refilterSheet.assignedToMe.test.ts b/src/renderer/src/__tests__/refilterSheet.assignedToMe.test.ts new file mode 100644 index 000000000..4000a3d7f --- /dev/null +++ b/src/renderer/src/__tests__/refilterSheet.assignedToMe.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect } from '@jest/globals'; +import { ISheet, IwsKind, SheetLevel, IMediaShare } from '../model'; +import { BookSeq, AltBkSeq } from '../model/section'; +import { PassageTypeEnum } from '../model/passageType'; +import { PublishDestinationEnum } from '../crud/usePublishDestination'; +import { ISTFilterState } from '../components/Sheet/filterMenu'; +import { isPassageFiltered } from '../components/Sheet/getSheet'; +import { refilterSheet } from '../components/Sheet/refilterSheet'; + +const currentUser = 'u-me'; +const otherUser = 'u-other'; +const scheme = { type: 'organizationscheme', id: 'scheme-1' }; + +const assignedToMeFilter: ISTFilterState = { + minStep: '', + maxStep: '', + minSection: -1, + maxSection: 99999, + assignedToMe: true, + hideDone: false, + disabled: false, + canHideDone: false, +}; + +const baseRow = (): Pick< + ISheet, + | 'deleted' + | 'filtered' + | 'discussionCount' + | 'published' + | 'mediaShared' + | 'scheme' +> => ({ + deleted: false, + filtered: false, + discussionCount: 0, + published: [] as PublishDestinationEnum[], + mediaShared: IMediaShare.NotPublic, + scheme, +}); + +const flatRow = ( + sectionSeq: number, + assign?: { type: string; id: string } +): ISheet => ({ + ...baseRow(), + level: SheetLevel.Section, + kind: IwsKind.SectionPassage, + sectionSeq, + passageSeq: 1, + title: `Section ${sectionSeq}`, + assign, + passageType: PassageTypeEnum.PASSAGE, + reference: `${sectionSeq}:1`, +}); + +const sectionRow = ( + sectionSeq: number, + overrides: Partial = {} +): ISheet => ({ + ...baseRow(), + level: SheetLevel.Section, + kind: IwsKind.Section, + sectionSeq, + passageSeq: 0, + title: `Section ${sectionSeq}`, + passageType: PassageTypeEnum.PASSAGE, + reference: '', + ...overrides, +}); + +const passageRow = ( + sectionSeq: number, + passageSeq: number, + assign?: { type: string; id: string } +): ISheet => ({ + ...baseRow(), + level: SheetLevel.Passage, + kind: IwsKind.Passage, + sectionSeq, + passageSeq, + title: `Passage ${sectionSeq}.${passageSeq}`, + assign, + passageType: PassageTypeEnum.PASSAGE, + reference: `${sectionSeq}:${passageSeq}`, +}); + +const bookRow = (): ISheet => + sectionRow(BookSeq, { + level: SheetLevel.Book, + title: 'Luke', + passageType: PassageTypeEnum.BOOK, + reference: PassageTypeEnum.BOOK, + }); + +const altBookRow = (): ISheet => + sectionRow(AltBkSeq, { + level: SheetLevel.Book, + title: 'Alternate title', + passageType: PassageTypeEnum.ALTBOOK, + reference: PassageTypeEnum.ALTBOOK, + }); + +/** TT-7048 scenario: flat sheet — unassigned, other user, current user (last). */ +const tt7048FlatSheet = (): ISheet[] => [ + flatRow(1), // unassigned + flatRow(2, { type: 'user', id: otherUser }), + flatRow(13, { type: 'user', id: currentUser }), +]; + +const refilter = (sheet: ISheet[], flat = false) => + refilterSheet({ + sheet, + filterState: assignedToMeFilter, + minSection: -1, + hidePublishing: false, + orgSteps: [], + doneStepId: 'done-1', + flat, + user: currentUser, + myGroups: [], + }); + +describe('isPassageFiltered assignedToMe (Hide rows assigned to others)', () => { + it('hides a passage assigned to another user', () => { + const w = flatRow(2, { type: 'user', id: otherUser }); + expect( + isPassageFiltered( + w, + assignedToMeFilter, + -1, + false, + [], + 'done-1', + scheme, + w.assign, + currentUser, + [] + ) + ).toBe(true); + }); + + it('keeps a passage assigned to the current user', () => { + const w = flatRow(13, { type: 'user', id: currentUser }); + expect( + isPassageFiltered( + w, + assignedToMeFilter, + -1, + false, + [], + 'done-1', + scheme, + w.assign, + currentUser, + [] + ) + ).toBe(false); + }); + + it('keeps an unassigned passage when the section has no scheme', () => { + const w = { ...flatRow(1), scheme: undefined, assign: undefined }; + expect( + isPassageFiltered( + w, + assignedToMeFilter, + -1, + false, + [], + 'done-1', + undefined, + undefined, + currentUser, + [] + ) + ).toBe(false); + }); + + it('keeps an unassigned passage when the section has a scheme', () => { + // "Hide rows assigned to others" — unassigned is available to anyone. + const w = { ...flatRow(1), assign: undefined }; + expect( + isPassageFiltered( + w, + assignedToMeFilter, + -1, + false, + [], + 'done-1', + scheme, + undefined, + currentUser, + [] + ) + ).toBe(false); + }); +}); + +describe('refilterSheet assignedToMe flat layout (TT-7048)', () => { + it('hides rows assigned to others but keeps unassigned and current-user rows', () => { + const { sheet } = refilter(tt7048FlatSheet(), true); + + const bySeq = (n: number) => sheet.find((r) => r.sectionSeq === n)!; + + // Unassigned → not assigned to someone else → must stay visible + expect(bySeq(1).filtered).toBe(false); + // Assigned to someone else → hidden + expect(bySeq(2).filtered).toBe(true); + // Assigned to me (last row) → must stay visible (was incorrectly hidden) + expect(bySeq(13).filtered).toBe(false); + }); + + it('does not hide the last flat row solely because it is SectionPassage', () => { + // Single row assigned to current user — discriminating case for hasOnePassage + // counting only IwsKind.Passage (never true for flat SectionPassage rows). + const { sheet } = refilter( + [flatRow(13, { type: 'user', id: currentUser })], + true + ); + + expect(sheet[0].filtered).toBe(false); + }); +}); + +describe('refilterSheet assignedToMe hierarchical empty sections', () => { + it('hides a section when all of its passages are assigned to others', () => { + const { sheet } = refilter([ + sectionRow(1), + passageRow(1, 1, { type: 'user', id: otherUser }), + passageRow(1, 2, { type: 'user', id: otherUser }), + sectionRow(2), + passageRow(2, 1, { type: 'user', id: currentUser }), + ]); + + expect(sheet[0].filtered).toBe(true); // empty section header + expect(sheet[1].filtered).toBe(true); + expect(sheet[2].filtered).toBe(true); + expect(sheet[3].filtered).toBe(false); // section with my passage + expect(sheet[4].filtered).toBe(false); + }); + + it('keeps a section that still has an unassigned passage', () => { + const { sheet } = refilter([ + sectionRow(1), + passageRow(1, 1), // unassigned + passageRow(1, 2, { type: 'user', id: otherUser }), + ]); + + expect(sheet[0].filtered).toBe(false); + expect(sheet[1].filtered).toBe(false); // unassigned stays + expect(sheet[2].filtered).toBe(true); + }); + + it('keeps Book and AltBook header rows even though they have no passages', () => { + const { sheet } = refilter([ + bookRow(), + altBookRow(), + sectionRow(1), + passageRow(1, 1, { type: 'user', id: otherUser }), + sectionRow(2), + passageRow(2, 1, { type: 'user', id: currentUser }), + ]); + + expect(sheet[0].filtered).toBe(false); // BOOK + expect(sheet[1].filtered).toBe(false); // ALTBOOK + expect(sheet[2].filtered).toBe(true); // section with only others + expect(sheet[3].filtered).toBe(true); + expect(sheet[4].filtered).toBe(false); + expect(sheet[5].filtered).toBe(false); + }); +}); diff --git a/src/renderer/src/components/Sheet/ScriptureTable.tsx b/src/renderer/src/components/Sheet/ScriptureTable.tsx index 7ab1accd1..880be45f5 100644 --- a/src/renderer/src/components/Sheet/ScriptureTable.tsx +++ b/src/renderer/src/components/Sheet/ScriptureTable.tsx @@ -13,7 +13,7 @@ import { useParams } from 'react-router-dom'; import { Badge, Box } from '@mui/material'; import JSONAPISource from '@orbit/jsonapi'; import Memory from '@orbit/memory'; -import { RecordIdentity, RecordKeyMap } from '@orbit/records'; +import { RecordKeyMap } from '@orbit/records'; import { debounce } from 'lodash'; import bookSortJson from '../../assets/akuosort.json'; import { @@ -128,8 +128,7 @@ import { shtNumChanges, getSheet, workSheet, - isSectionFiltered, - isPassageFiltered, + refilterSheet, nextNum, getMinSection, } from '.'; @@ -1313,8 +1312,7 @@ export function ScriptureTable(props: IProps) { const secRec = ws?.kind === IwsKind.Section ? (findRecord(memory, 'section', ws?.sectionId?.id ?? '') as - | Section - | undefined) + Section | undefined) : undefined; const resourceId = ws?.kind === IwsKind.Section @@ -1671,88 +1669,19 @@ export function ScriptureTable(props: IProps) { }, [sheet, width, colNames, flat, lang]); useEffect(() => { - const newWork: ISheet[] = []; - let changed = false; - let sectionfiltered = false; - let filtered = false; - if (!updateRef.current) { setUpdate(true); - let sectionIndex = -1; - let sectionScheme: RecordIdentity | undefined; - let hasOnePassage = false; - sheetRef.current.forEach((s, index) => { - if (isSectionRow(s)) { - if (sectionIndex >= 0) { - if (!hasOnePassage && filterState.assignedToMe && !flat) { - (newWork[sectionIndex] as ISheet).filtered = true; - } - } - sectionIndex = index; - sectionScheme = s.scheme; - hasOnePassage = false; - sectionfiltered = isSectionFiltered( - filterState, - minSection, - s.sectionSeq, - hidePublishing, - s.reference || '' - ); - if ( - !sectionfiltered && - hidePublishing && - s.kind === IwsKind.Section && - s.level !== SheetLevel.Section - ) { - let allMyPassagesArePublishing = true; - for ( - let ix = index + 1; - ix < sheetRef.current.length && - isPassageRow(sheetRef.current[ix] as ISheet) && - allMyPassagesArePublishing; - ix++ - ) { - if ( - !isPublishingTitle( - (sheetRef.current[ix] as ISheet).reference, - flat - ) - ) { - allMyPassagesArePublishing = false; - } - } - sectionfiltered = allMyPassagesArePublishing; - } - } - if (isPassageRow(s)) { - filtered = - sectionfiltered || - isPassageFiltered( - s, - filterState, - minSection, - hidePublishing, - orgSteps, - doneStepId, - sectionScheme, - s.assign, - user, - myGroups - ); - } else filtered = sectionfiltered; - hasOnePassage ||= s.kind === IwsKind.Passage && filtered === false; - if (filtered !== s.filtered) changed = true; - newWork.push({ - ...s, - filtered, - }); + const { sheet: newWork, changed } = refilterSheet({ + sheet: sheetRef.current, + filterState, + minSection, + hidePublishing, + orgSteps, + doneStepId, + flat, + user, + myGroups, }); - if (sectionIndex >= 0) { - if (!hasOnePassage && filterState.assignedToMe) { - (newWork[sectionIndex] as ISheet).filtered = true; - } - } - if (changed) { setSheet(newWork); } diff --git a/src/renderer/src/components/Sheet/getSheet.ts b/src/renderer/src/components/Sheet/getSheet.ts index d807e5e3a..489bfa61a 100644 --- a/src/renderer/src/components/Sheet/getSheet.ts +++ b/src/renderer/src/components/Sheet/getSheet.ts @@ -193,9 +193,9 @@ export const isPassageFiltered = ( (filterState.assignedToMe && w.discussionCount === 0 && sectionScheme !== undefined && - (!assign || - (assign.id !== user && - myGroups.findIndex((g) => g.id === assign.id) < 0)))) + !!assign && + assign.id !== user && + myGroups.findIndex((g) => g.id === assign.id) < 0)) ); }; @@ -470,7 +470,12 @@ export const getSheet = ({ if (myWork[sectionIndex!]) { const rec = myWork[sectionIndex!]; if (rec) { - if (!hasOnePassage && filterState.assignedToMe) { + // Book/AltBook headers have no passages by design — keep them visible. + if ( + !hasOnePassage && + filterState.assignedToMe && + rec.level !== SheetLevel.Book + ) { rec.filtered = true; } } diff --git a/src/renderer/src/components/Sheet/index.ts b/src/renderer/src/components/Sheet/index.ts index 0d872fe70..ac425bc47 100644 --- a/src/renderer/src/components/Sheet/index.ts +++ b/src/renderer/src/components/Sheet/index.ts @@ -1,4 +1,5 @@ export * from './getSheet'; +export * from './refilterSheet'; export * from './isSectionPassage'; export * from './isSectionPassageUpdated'; export * from './PlanPublishActions'; diff --git a/src/renderer/src/components/Sheet/refilterSheet.ts b/src/renderer/src/components/Sheet/refilterSheet.ts new file mode 100644 index 000000000..a18f4e612 --- /dev/null +++ b/src/renderer/src/components/Sheet/refilterSheet.ts @@ -0,0 +1,125 @@ +import { + GroupD, + ISheet, + IwsKind, + OrgWorkflowStep, + SheetLevel, +} from '../../model'; +import { RecordIdentity } from '@orbit/records'; +import { isPublishingTitle } from '../../control/passageTypeFromRef'; +import { ISTFilterState } from './filterMenu'; +import { isPassageFiltered, isSectionFiltered } from './getSheet'; +import { isPassageRow, isSectionRow } from './isSectionPassage'; + +export interface RefilterSheetProps { + sheet: ISheet[]; + filterState: ISTFilterState; + minSection: number; + hidePublishing: boolean; + orgSteps: OrgWorkflowStep[]; + doneStepId: string; + flat: boolean; + user: string; + myGroups: GroupD[]; +} + +/** + * Re-apply sheet filters (steps, section range, assigned-to-me) to existing rows. + * Used when filter state changes without rebuilding the sheet from Orbit records. + */ +export const refilterSheet = ({ + sheet, + filterState, + minSection, + hidePublishing, + orgSteps, + doneStepId, + flat, + user, + myGroups, +}: RefilterSheetProps): { sheet: ISheet[]; changed: boolean } => { + const newWork: ISheet[] = []; + let changed = false; + let sectionfiltered = false; + let filtered = false; + let sectionIndex = -1; + let sectionScheme: RecordIdentity | undefined; + let hasOnePassage = false; + + const hideEmptySection = (index: number) => { + const row = newWork[index] as ISheet; + // Book/AltBook headers have no passages by design — keep them visible. + if (row.level === SheetLevel.Book) return; + if (!row.filtered) changed = true; + row.filtered = true; + }; + + sheet.forEach((s, index) => { + if (isSectionRow(s)) { + if (sectionIndex >= 0) { + if (!hasOnePassage && filterState.assignedToMe && !flat) { + hideEmptySection(sectionIndex); + } + } + sectionIndex = index; + sectionScheme = s.scheme; + hasOnePassage = false; + sectionfiltered = isSectionFiltered( + filterState, + minSection, + s.sectionSeq, + hidePublishing, + s.reference || '' + ); + if ( + !sectionfiltered && + hidePublishing && + s.kind === IwsKind.Section && + s.level !== SheetLevel.Section + ) { + let allMyPassagesArePublishing = true; + for ( + let ix = index + 1; + ix < sheet.length && + isPassageRow(sheet[ix] as ISheet) && + allMyPassagesArePublishing; + ix++ + ) { + if (!isPublishingTitle((sheet[ix] as ISheet).reference, flat)) { + allMyPassagesArePublishing = false; + } + } + sectionfiltered = allMyPassagesArePublishing; + } + } + if (isPassageRow(s)) { + filtered = + sectionfiltered || + isPassageFiltered( + s, + filterState, + minSection, + hidePublishing, + orgSteps, + doneStepId, + sectionScheme, + s.assign, + user, + myGroups + ); + } else filtered = sectionfiltered; + hasOnePassage ||= isPassageRow(s) && filtered === false; + if (filtered !== s.filtered) changed = true; + newWork.push({ + ...s, + filtered, + }); + }); + if (sectionIndex >= 0) { + if (!hasOnePassage && filterState.assignedToMe && !flat) { + hideEmptySection(sectionIndex); + } + } + + return { sheet: newWork, changed }; +};