From 9bae85ff0fdcd6fbf1ad264ec8f4130c1ca06669 Mon Sep 17 00:00:00 2001 From: Greg Trihus Date: Wed, 27 May 2026 17:10:26 -0500 Subject: [PATCH 1/4] TT-7350 AssignmentTable and AssignSection components to utilize new section resolution functions. Introduce useShowAssignment hook for conditional rendering of assignment filters. Add tests for section resolution logic and show assignment functionality. Fixes: - TT-7350 assign to user - TT-6482 assigned column & Assignment tab in personal projects - TT-6474 Hide assigned rows in personal projects - TT6372 Select All on Assignment Tab --- src/renderer/src/components/AssignSection.tsx | 46 +++++-- .../src/components/AssignmentTable.tsx | 32 ++--- src/renderer/src/components/PlanTabs.tsx | 11 +- .../src/components/Sheet/PlanBar.cy.tsx | 112 ++++++++++++++++-- .../src/components/Sheet/PlanSheet.tsx | 11 +- .../src/components/Sheet/PlanTabSelect.tsx | 14 +-- .../src/components/Sheet/ScriptureTable.tsx | 74 +++++++++++- .../src/components/Sheet/filterMenu.tsx | 47 +++++--- .../src/components/Sheet/usePlanSheetFill.tsx | 11 +- .../components/resolveSectionForRecId.test.ts | 58 +++++++++ .../src/components/resolveSectionForRecId.ts | 40 +++++++ src/renderer/src/crud/index.ts | 1 + .../src/crud/useShowAssignment.test.ts | 69 +++++++++++ src/renderer/src/crud/useShowAssignment.ts | 17 +++ 14 files changed, 450 insertions(+), 93 deletions(-) create mode 100644 src/renderer/src/components/resolveSectionForRecId.test.ts create mode 100644 src/renderer/src/components/resolveSectionForRecId.ts create mode 100644 src/renderer/src/crud/useShowAssignment.test.ts create mode 100644 src/renderer/src/crud/useShowAssignment.ts diff --git a/src/renderer/src/components/AssignSection.tsx b/src/renderer/src/components/AssignSection.tsx index 1ed6f5c4..31880bfb 100644 --- a/src/renderer/src/components/AssignSection.tsx +++ b/src/renderer/src/components/AssignSection.tsx @@ -29,7 +29,13 @@ import { AddRecord, UpdateRelatedRecord } from '../model/baseModel'; import { AltButton, GrowingSpacer, PriButton } from '../control'; import { useOrbitData } from '../hoc/useOrbitData'; import { useSelector } from 'react-redux'; -import { assignSectionSelector, sharedSelector } from '../selector'; +import { + assignSectionSelector, + assignmentSelector, + sharedSelector, +} from '../selector'; +import { useSnackBar } from '../hoc/SnackBar'; +import { IAssignmentTableStrings } from '../model'; import GroupOrUserAssignment from '../control/GroupOrUserAssignment'; import { OrganizationSchemeD } from '../model/organizationScheme'; import { waitForIt } from '../utils/waitForIt'; @@ -68,7 +74,12 @@ function AssignSection(props: IProps) { assignSectionSelector, shallowEqual ); + const tAssign: IAssignmentTableStrings = useSelector( + assignmentSelector, + shallowEqual + ); const ts: ISharedStrings = useSelector(sharedSelector, shallowEqual); + const { showMessage } = useSnackBar(); const allOrgSteps = useOrbitData('orgworkflowstep'); const schemes = useOrbitData('organizationscheme'); const steps = useOrbitData( @@ -274,9 +285,15 @@ function AssignSection(props: IProps) { return schemeRec.id as string; }; - const doAssign = async (schemeId: string) => { - if (!sections.some((s) => related(s, 'organizationScheme') !== schemeId)) - return; + const doAssign = async (schemeId: string): Promise => { + const needsLink = sections.some( + (s) => related(s, 'organizationScheme') !== schemeId + ); + if (!needsLink) return true; + if (sections.length === 0) { + showMessage(tAssign.selectRowsToAssign); + return false; + } const ids = sections.map( (s) => remoteId('section', s.id, memory.keyMap as RecordKeyMap) as string ); @@ -286,8 +303,11 @@ function AssignSection(props: IProps) { 'organizationscheme', schemeId ) as OrganizationSchemeD; - const id = await waitForRemoteId(schemeRec, memory.keyMap as RecordKeyMap); try { + const id = await waitForRemoteId( + schemeRec, + memory.keyMap as RecordKeyMap + ); await axiosPatch(`sections/assign/${id}/${list}`, undefined, token); await pullTableList( 'section', @@ -297,8 +317,11 @@ function AssignSection(props: IProps) { backup, errorReporter ); + return true; } catch (err) { logError(Severity.error, errorReporter, err as Error); + showMessage((err as Error).message); + return false; } }; @@ -351,10 +374,15 @@ function AssignSection(props: IProps) { const confirmClose = async () => { setSaving(true); setBusy(true); - const schemeId = await handleAdd(); - await doAssign(schemeId); - setBusy(false); - justClose(); + try { + const schemeId = await handleAdd(); + const ok = await doAssign(schemeId); + if (!ok) return; + justClose(); + } finally { + setBusy(false); + setSaving(false); + } }; const handleClose = async () => { diff --git a/src/renderer/src/components/AssignmentTable.tsx b/src/renderer/src/components/AssignmentTable.tsx index 3f49db1f..5111f5d8 100644 --- a/src/renderer/src/components/AssignmentTable.tsx +++ b/src/renderer/src/components/AssignmentTable.tsx @@ -65,6 +65,10 @@ import { } from '@mui/x-data-grid'; import { TreeDataGrid } from './TreeDataGrid'; import { pad2 } from '../utils/pad2'; +import { + resolveSectionForRecId, + resolveSelectedSections, +} from './resolveSectionForRecId'; const AssignmentDiv = styled('div')(() => ({ display: 'flex', @@ -355,11 +359,12 @@ export function AssignmentTable() { } else { let count = 0; check.forEach((recId) => { - const row = data.find((r) => r.recId === recId); - if (!row) return; - const sectId = row.scheme as string; - if (!sectId) return; - const section = sections.find((s) => s.id === sectId); + const section = resolveSectionForRecId( + recId, + data, + sections, + passages + ); if (!section) return; const schemeId = related(section, 'organizationScheme'); if (schemeId) count++; @@ -387,7 +392,7 @@ export function AssignmentTable() { t, s as RecordIdentity, 'organizationScheme', - 'user', + 'organizationscheme', '' ), ...UpdateLastModifiedBy( @@ -413,7 +418,7 @@ export function AssignmentTable() { setCheck([]); setSelectedSections([]); setSelectedRows({ type: 'include', ids: new Set() }); - setRefresh(refresh + 1); + setRefresh((n) => n + 1); }; const handleRemoveAssignmentsRefused = () => setConfirmAction(''); @@ -478,13 +483,10 @@ export function AssignmentTable() { ]); useEffect(() => { - const selected = Array(); - check.forEach((recId) => { - const section = sections.find((s) => s.id === recId); - if (section !== undefined) selected.push(section); - }); - setSelectedSections(selected); - }, [check, sections]); + setSelectedSections( + resolveSelectedSections(check, data, sections, passages) + ); + }, [check, sections, data, passages]); const sortModel: GridSortModel = [{ field: 'sort', sort: 'asc' }]; const columnVisibilityModel: GridColumnVisibilityModel = { sort: false }; @@ -568,7 +570,7 @@ export function AssignmentTable() { scheme={assignSectionVisible} visible={assignSectionVisible != null} closeMethod={handleCloseAssignSection} - refresh={() => setRefresh(refresh + 1)} + refresh={() => setRefresh((n) => n + 1)} readOnly={readOnly} inChange={hasAssignmentChange(assignSectionVisible)} /> diff --git a/src/renderer/src/components/PlanTabs.tsx b/src/renderer/src/components/PlanTabs.tsx index 1f1a12b7..c3d8acd7 100644 --- a/src/renderer/src/components/PlanTabs.tsx +++ b/src/renderer/src/components/PlanTabs.tsx @@ -11,7 +11,6 @@ import { levScrColNames, levGenColNames, MediaFileD, - OrganizationD, } from '../model'; import { AppBar, Tabs, Tab, Box } from '@mui/material'; import ScriptureTable from './Sheet/ScriptureTable'; @@ -24,7 +23,7 @@ import { useOrganizedBy, useMediaCounts, useSectionCounts, - isPersonalTeam, + useShowAssignment, } from '../crud'; import { HeadHeight } from '../App'; import { useMobile } from '../utils'; @@ -61,13 +60,7 @@ const ScrollableTabsButtonAuto = (props: IProps) => { sections, passages ); - const [team] = useGlobal('organization'); - const teams = useOrbitData('organization'); - - const showAssign = useMemo( - () => !isPersonalTeam(team, teams) && !offlineOnly, - [team, teams, offlineOnly] - ); + const showAssign = useShowAssignment(); const colNames = React.useMemo(() => { return scripture && flat diff --git a/src/renderer/src/components/Sheet/PlanBar.cy.tsx b/src/renderer/src/components/Sheet/PlanBar.cy.tsx index c0b6f9ac..675c94be 100644 --- a/src/renderer/src/components/Sheet/PlanBar.cy.tsx +++ b/src/renderer/src/components/Sheet/PlanBar.cy.tsx @@ -24,16 +24,39 @@ const createMockLiveQuery = () => ({ query: () => [], }); -const mockMemory = { - cache: { - query: () => [], - liveQuery: createMockLiveQuery, +const createMockMemory = (organizations: any[] = []) => + ({ + cache: { + query: () => [], + liveQuery: (queryBuildFn: (q: any) => any) => { + const q = { + findRecords: (type: string) => { + if (type === 'organization') return organizations; + return []; + }, + }; + const records = queryBuildFn(q); + return { + subscribe: () => () => {}, + query: () => records, + }; + }, + }, + update: () => {}, + }) as unknown as Memory; + +let currentTestMemory: Memory = createMockMemory(); + +const personalTeamOrgs = [ + { + id: 'org-1', + type: 'organization', + attributes: { name: '>Test User Personal<' }, }, - update: () => {}, -} as unknown as Memory; +]; const mockCoordinator = { - getSource: () => mockMemory, + getSource: () => currentTestMemory, } as unknown as Coordinator; // Mock Redux selectors @@ -128,7 +151,7 @@ describe('PlanBar', () => { coordinator: mockCoordinator, errorReporter: bugsnagClient, fingerprint: 'test-fingerprint', - memory: mockMemory, + memory: currentTestMemory, lang: 'en', latestVersion: '', loadComplete: false, @@ -253,8 +276,10 @@ describe('PlanBar', () => { rowInfo: ISheet[]; }, planContextOverrides = {}, - globalStateOverrides = {} + globalStateOverrides = {}, + organizations: any[] = [] ) => { + currentTestMemory = createMockMemory(organizations); const initialState = createInitialState(globalStateOverrides); const planContextState = createMockPlanContextState(planContextOverrides); const unsavedState = { @@ -264,7 +289,7 @@ describe('PlanBar', () => { cy.mount( - + { } }); }); + + it('should not show assignedToMe filter for personal audio projects', () => { + const filterState = createMockFilterState(); + const orgSteps = createMockOrgSteps(); + const rowInfo = createMockRowInfo(2); + + mountPlanBar( + { + publishingOn: false, + hidePublishing: true, + handlePublishToggle: mockHandlePublishToggle, + data: [1, 2], + canSetDefault: false, + filterState, + onFilterChange: mockOnFilterChange, + orgSteps, + minimumSection: 1, + maximumSection: 10, + filtered: true, + rowInfo, + }, + {}, + { organization: 'org-1' }, + personalTeamOrgs + ); + + cy.wait(100); + cy.get('#filterMenu', { timeout: 5000 }).click(); + cy.get('#assignedToMe').should('not.exist'); + }); + + it('should show assignedToMe filter for team projects', () => { + const filterState = createMockFilterState(); + const orgSteps = createMockOrgSteps(); + const rowInfo = createMockRowInfo(2); + const teamOrgs = [ + { + id: 'org-1', + type: 'organization', + attributes: { name: 'My Team' }, + }, + ]; + + mountPlanBar( + { + publishingOn: false, + hidePublishing: true, + handlePublishToggle: mockHandlePublishToggle, + data: [1, 2], + canSetDefault: false, + filterState, + onFilterChange: mockOnFilterChange, + orgSteps, + minimumSection: 1, + maximumSection: 10, + filtered: true, + rowInfo, + }, + {}, + { organization: 'org-1' }, + teamOrgs + ); + + cy.wait(100); + cy.get('#filterMenu', { timeout: 5000 }).click(); + cy.get('#assignedToMe').should('exist'); + }); }); diff --git a/src/renderer/src/components/Sheet/PlanSheet.tsx b/src/renderer/src/components/Sheet/PlanSheet.tsx index e5ad1c88..ea33e623 100644 --- a/src/renderer/src/components/Sheet/PlanSheet.tsx +++ b/src/renderer/src/components/Sheet/PlanSheet.tsx @@ -18,7 +18,6 @@ import { ISheet, OrgWorkflowStep, SheetLevel, - OrganizationD, } from '../../model'; import { Box, @@ -64,7 +63,7 @@ import { useCheckOnline, } from '../../utils'; import { - isPersonalTeam, + useShowAssignment, PublishDestinationEnum, remoteIdGuid, usePublishDestination, @@ -85,7 +84,6 @@ import { usePlanSheetFill } from './usePlanSheetFill'; import { useShowIcon } from './useShowIcon'; import { RecordKeyMap } from '@orbit/records'; import ConfirmPublishDialog from '../ConfirmPublishDialog'; -import { useOrbitData } from '../../hoc/useOrbitData'; import { findPlanSheetRowFromReferenceQuery } from './findPlanSheetRowFromReferenceQuery'; import { useOrganizedBy } from '../../crud/useOrganizedBy'; @@ -353,15 +351,10 @@ export function PlanSheet(props: IProps) { const moveUp = true; const moveDown = false; const moveToNewSection = true; - const [org] = useGlobal('organization'); const getGlobal = useGetGlobal(); - const teams = useOrbitData('organization'); const checkOnline = useCheckOnline('PlanSheet'); - const showAssign = useMemo( - () => !isPersonalTeam(org, teams) && !offlineOnly, - [org, teams, offlineOnly] - ); + const showAssign = useShowAssignment(); useEffect(() => { if (!goToOpen) return; diff --git a/src/renderer/src/components/Sheet/PlanTabSelect.tsx b/src/renderer/src/components/Sheet/PlanTabSelect.tsx index 564de913..bc7f0640 100644 --- a/src/renderer/src/components/Sheet/PlanTabSelect.tsx +++ b/src/renderer/src/components/Sheet/PlanTabSelect.tsx @@ -1,15 +1,13 @@ import { useContext, useMemo, useState } from 'react'; import { shallowEqual, useSelector } from 'react-redux'; -import { IPlanTabsStrings, OrganizationD } from '@model/index'; +import { IPlanTabsStrings } from '@model/index'; import { Menu, MenuItem } from '@mui/material'; import DropDownIcon from '@mui/icons-material/ArrowDropDown'; import { AltButton } from '../../control/AltButton'; import { planTabsSelector } from '../../selector'; import { useOrganizedBy } from '../../crud/useOrganizedBy'; -import { isPersonalTeam } from '../../crud/isPersonalTeam'; +import { useShowAssignment } from '../../crud/useShowAssignment'; import { PlanContext } from '../../context/PlanContext'; -import { useGlobal } from '../../context/useGlobal'; -import { useOrbitData } from '../../hoc/useOrbitData'; import { PlanTabEnum } from '../PlanTabsEnum'; import { UnsavedContext } from '../../context/UnsavedContext'; @@ -23,13 +21,7 @@ export const PlanTabSelect = () => { const organizedBy = getOrganizedBy(false); const ctx = useContext(PlanContext); const { flat, tab, setTab } = ctx.state; - const [team] = useGlobal('organization'); - const [offlineOnly] = useGlobal('offlineOnly'); - const teams = useOrbitData('organization'); - const showAssign = useMemo( - () => !isPersonalTeam(team, teams) && !offlineOnly, - [team, teams, offlineOnly] - ); + const showAssign = useShowAssignment(); const defaultItem = useMemo( () => (flat ? organizedBy : t.sectionsPassages.replace('{0}', organizedBy)), [flat, organizedBy, t] diff --git a/src/renderer/src/components/Sheet/ScriptureTable.tsx b/src/renderer/src/components/Sheet/ScriptureTable.tsx index 63acbbb2..c4a27c8a 100644 --- a/src/renderer/src/components/Sheet/ScriptureTable.tsx +++ b/src/renderer/src/components/Sheet/ScriptureTable.tsx @@ -4,6 +4,7 @@ import React, { useRef, useContext, useMemo, + useCallback, ReactNode, MouseEventHandler, } from 'react'; @@ -64,6 +65,7 @@ import { PublishDestinationEnum, usePublishDestination, useNotes, + useShowAssignment, } from '../../crud'; import { lookupBook, @@ -206,6 +208,7 @@ export function ScriptureTable(props: IProps) { const remote = coordinator?.getSource('remote') as JSONAPISource; const [user] = useGlobal('user'); const [offlineOnly] = useGlobal('offlineOnly'); //will be constant here + const showAssign = useShowAssignment(); const [, setBusy] = useGlobal('importexportBusy'); const myChangedRef = useRef(false); const savingRef = useRef(false); @@ -389,6 +392,10 @@ export function ScriptureTable(props: IProps) { getProjectDefault(projDefFilterParam) ?? fs) as ISTFilterState; + if (!showAssign && filter.assignedToMe) { + filter.assignedToMe = false; + } + if (filter.minStep && !isNaN(Number(filter.minStep))) filter.minStep = remoteIdGuid( 'orgworkflowstep', @@ -1129,8 +1136,6 @@ export function ScriptureTable(props: IProps) { }); }; - const handleAssignClose = () => () => setAssignSectionVisible(false); - const showUpload = (i: number) => { waitForPassageId(i, () => { const { ws } = getByIndex(sheetRef.current, i); @@ -1379,7 +1384,7 @@ export function ScriptureTable(props: IProps) { useEffect(() => { setFilterState(getFilter(defaultFilterState)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [project, defaultFilterState]); + }, [project, defaultFilterState, showAssign]); useEffect(() => { const fm = getProjectDefault(projDefFirstMovement) as number; setFirstMovement(fm ?? 1); @@ -1415,6 +1420,66 @@ export function ScriptureTable(props: IProps) { return tmp?.id ?? 'noDoneStep'; }, [defaultFilterState, orgSteps]); + const refreshSheetAfterAssign = useCallback(() => { + if (!plan) return; + const newWorkflow = getSheet({ + plan, + sections, + passages, + organizationSchemeSteps, + flat, + projectShared: shared, + memory, + orgWorkflowSteps: orgSteps, + wfStr, + filterState, + minSection, + hasPublishing: publishingOn, + hidePublishing, + doneStepId, + getDiscussionCount, + graphicFind, + getPublishTo, + publishStatus, + getSharedResource, + user, + myGroups, + isDeveloper: developer, + }); + setSheet(newWorkflow); + }, [ + plan, + sections, + passages, + organizationSchemeSteps, + flat, + shared, + memory, + orgSteps, + wfStr, + filterState, + minSection, + publishingOn, + hidePublishing, + doneStepId, + getDiscussionCount, + graphicFind, + getPublishTo, + publishStatus, + getSharedResource, + user, + myGroups, + developer, + ]); + + const handleAssignClose = useCallback( + (cancel?: boolean) => { + setAssignSectionVisible(false); + if (!cancel) refreshSheetAfterAssign(); + }, + [refreshSheetAfterAssign] + ); + // Save locally or online in batches useEffect(() => { let prevSave = ''; @@ -2197,7 +2262,8 @@ export function ScriptureTable(props: IProps) { scheme={getScheme(assignSections) as string} sections={getSectionsWhere(assignSections)} visible={assignSectionVisible} - closeMethod={handleAssignClose()} + closeMethod={handleAssignClose} + refresh={refreshSheetAfterAssign} /> )} ) => { event.stopPropagation(); @@ -112,7 +113,11 @@ export function FilterMenu(props: IProps) { projDefault: boolean ) => { setApplying(true); - onFilterChange(filterState, projDefault); + const state = + filterState && !showAssignmentFilters + ? { ...filterState, assignedToMe: false } + : filterState; + onFilterChange(state, projDefault); setApplying(false); handleDefaultCheck(false); setChanged(false); @@ -132,8 +137,14 @@ export function FilterMenu(props: IProps) { apply({ ...localState, disabled: event.target.checked }, false); }; useEffect(() => { - if (!changed) setLocalState({ ...props.state }); - }, [props.state, changed]); + if (!changed) { + setLocalState( + showAssignmentFilters + ? { ...props.state } + : { ...props.state, assignedToMe: false } + ); + } + }, [props.state, changed, showAssignmentFilters]); const filterChange = (what: string, value: any) => { const newstate = { ...localState } as any; @@ -210,19 +221,21 @@ export function FilterMenu(props: IProps) { onClose={handleClose} > - - handle('assignedToMe', event.target.checked) - } - /> - } - label={t.assignedToMe} - /> + {showAssignmentFilters && ( + + handle('assignedToMe', event.target.checked) + } + /> + } + label={t.assignedToMe} + /> + )} {localState.canHideDone && ( ('organization'); - - const showAssign = useMemo( - () => !isPersonalTeam(team, teams) && !offlineOnly, - [teams, team, offlineOnly] - ); + const showAssign = useShowAssignment(); const refErrTest = useRefErrTest(); const { getOrganizedBy } = useOrganizedBy(); diff --git a/src/renderer/src/components/resolveSectionForRecId.test.ts b/src/renderer/src/components/resolveSectionForRecId.test.ts new file mode 100644 index 00000000..cbced29d --- /dev/null +++ b/src/renderer/src/components/resolveSectionForRecId.test.ts @@ -0,0 +1,58 @@ +import { PassageD, SectionD } from '../model'; +import { + resolveSectionForRecId, + resolveSelectedSections, +} from './resolveSectionForRecId'; + +const section1 = { + id: 'sec-1', + type: 'section', + attributes: { sequencenum: 1 }, +} as SectionD; + +const passage1 = { + id: 'pas-1', + type: 'passage', + attributes: { sequencenum: 1 }, + relationships: { + section: { data: { type: 'section', id: 'sec-1' } }, + }, +} as PassageD; + +const data = [ + { recId: 'sec-1', parentId: '' }, + { recId: 'pas-1', parentId: 'sec-1' }, +]; + +describe('resolveSectionForRecId', () => { + it('resolves a section row by recId', () => { + expect( + resolveSectionForRecId('sec-1', data, [section1], [passage1]) + ).toBe(section1); + }); + + it('resolves a passage row to its parent section', () => { + expect( + resolveSectionForRecId('pas-1', data, [section1], [passage1]) + ).toBe(section1); + }); + + it('returns undefined for unknown recId', () => { + expect( + resolveSectionForRecId('missing', data, [section1], [passage1]) + ).toBeUndefined(); + }); +}); + +describe('resolveSelectedSections', () => { + it('deduplicates sections when multiple passage rows share a parent', () => { + const selected = resolveSelectedSections( + ['pas-1', 'sec-1'], + data, + [section1], + [passage1] + ); + expect(selected).toHaveLength(1); + expect(selected[0]).toBe(section1); + }); +}); diff --git a/src/renderer/src/components/resolveSectionForRecId.ts b/src/renderer/src/components/resolveSectionForRecId.ts new file mode 100644 index 00000000..9308ffc1 --- /dev/null +++ b/src/renderer/src/components/resolveSectionForRecId.ts @@ -0,0 +1,40 @@ +import { PassageD, SectionD } from '../model'; +import { related } from '../crud/related'; + +export interface IAssignmentRow { + recId: string; + parentId: string; +} + +export function resolveSectionForRecId( + recId: string, + data: IAssignmentRow[], + sections: SectionD[], + passages: PassageD[] +): SectionD | undefined { + const row = data.find((r) => r.recId === recId); + if (!row) return undefined; + if (row.parentId === '') return sections.find((s) => s.id === recId); + const parentId = + row.parentId || + related(passages.find((p) => p.id === recId), 'section'); + return sections.find((s) => s.id === parentId); +} + +export function resolveSelectedSections( + check: string[], + data: IAssignmentRow[], + sections: SectionD[], + passages: PassageD[] +): SectionD[] { + const selected: SectionD[] = []; + const seen = new Set(); + check.forEach((recId) => { + const section = resolveSectionForRecId(recId, data, sections, passages); + if (section !== undefined && !seen.has(section.id)) { + seen.add(section.id); + selected.push(section); + } + }); + return selected; +} diff --git a/src/renderer/src/crud/index.ts b/src/renderer/src/crud/index.ts index d67bcb59..0ed7cf99 100644 --- a/src/renderer/src/crud/index.ts +++ b/src/renderer/src/crud/index.ts @@ -20,6 +20,7 @@ export * from './updatePassageState'; export * from './useAllUserGroup'; export * from './useFlatAdd'; export * from './isPersonalTeam'; +export * from './useShowAssignment'; export * from './useNewTeamId'; export * from './useOrganizedBy'; export * from './usePassageRec'; diff --git a/src/renderer/src/crud/useShowAssignment.test.ts b/src/renderer/src/crud/useShowAssignment.test.ts new file mode 100644 index 00000000..ac54ed38 --- /dev/null +++ b/src/renderer/src/crud/useShowAssignment.test.ts @@ -0,0 +1,69 @@ +import { renderHook } from '@testing-library/react'; +import { useShowAssignment } from './useShowAssignment'; +import { OrganizationD } from '../model'; + +let mockOfflineOnly = false; +let mockTeam = 'team-1'; +let mockOrganizations: OrganizationD[] = []; + +jest.mock('../context/useGlobal', () => ({ + useGlobal: jest.fn((key: string) => { + if (key === 'offlineOnly') return [mockOfflineOnly, jest.fn()]; + if (key === 'organization') return [mockTeam, jest.fn()]; + return [undefined, jest.fn()]; + }), +})); + +jest.mock('../hoc/useOrbitData', () => ({ + useOrbitData: () => mockOrganizations, +})); + +describe('useShowAssignment', () => { + beforeEach(() => { + mockOfflineOnly = false; + mockTeam = 'team-1'; + mockOrganizations = []; + }); + + it('returns false when organizations are not loaded yet', () => { + const { result } = renderHook(() => useShowAssignment()); + expect(result.current).toBe(false); + }); + + it('returns false for personal team org name', () => { + mockOrganizations = [ + { + id: 'team-1', + type: 'organization', + attributes: { name: '>User Personal<' }, + } as OrganizationD, + ]; + const { result } = renderHook(() => useShowAssignment()); + expect(result.current).toBe(false); + }); + + it('returns true for non-personal team when org is loaded', () => { + mockOrganizations = [ + { + id: 'team-1', + type: 'organization', + attributes: { name: 'My Team' }, + } as OrganizationD, + ]; + const { result } = renderHook(() => useShowAssignment()); + expect(result.current).toBe(true); + }); + + it('returns false when offlineOnly', () => { + mockOfflineOnly = true; + mockOrganizations = [ + { + id: 'team-1', + type: 'organization', + attributes: { name: 'My Team' }, + } as OrganizationD, + ]; + const { result } = renderHook(() => useShowAssignment()); + expect(result.current).toBe(false); + }); +}); diff --git a/src/renderer/src/crud/useShowAssignment.ts b/src/renderer/src/crud/useShowAssignment.ts new file mode 100644 index 00000000..6fefffab --- /dev/null +++ b/src/renderer/src/crud/useShowAssignment.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react'; +import { useGlobal } from '../context/useGlobal'; +import { useOrbitData } from '../hoc/useOrbitData'; +import { OrganizationD } from '../model'; +import { isPersonalTeam } from './isPersonalTeam'; + +export function useShowAssignment(): boolean { + const [team] = useGlobal('organization'); + const [offlineOnly] = useGlobal('offlineOnly'); + const teams = useOrbitData('organization'); + return useMemo(() => { + if (offlineOnly) return false; + const org = teams?.find((o) => o.id === team); + if (!org) return false; + return !isPersonalTeam(team, teams); + }, [team, teams, offlineOnly]); +} From 35a29f462b38d7a8ac2d586ba5c0c35cd746f0be Mon Sep 17 00:00:00 2001 From: Greg Trihus Date: Wed, 27 May 2026 17:10:26 -0500 Subject: [PATCH 2/4] Refactor AssignmentTable to simplify section counting logic by removing unused function and utilizing filter method. Clean up ScriptureTable by removing unnecessary refresh prop from Uploader component. --- .../src/components/AssignmentTable.tsx | 20 ++++--------------- .../src/components/Sheet/ScriptureTable.tsx | 1 - 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/components/AssignmentTable.tsx b/src/renderer/src/components/AssignmentTable.tsx index 5111f5d8..b61b9aa1 100644 --- a/src/renderer/src/components/AssignmentTable.tsx +++ b/src/renderer/src/components/AssignmentTable.tsx @@ -65,10 +65,7 @@ import { } from '@mui/x-data-grid'; import { TreeDataGrid } from './TreeDataGrid'; import { pad2 } from '../utils/pad2'; -import { - resolveSectionForRecId, - resolveSelectedSections, -} from './resolveSectionForRecId'; +import { resolveSelectedSections } from './resolveSectionForRecId'; const AssignmentDiv = styled('div')(() => ({ display: 'flex', @@ -357,18 +354,9 @@ export function AssignmentTable() { if (check.length === 0) { showMessage(t.selectRowsToRemove); } else { - let count = 0; - check.forEach((recId) => { - const section = resolveSectionForRecId( - recId, - data, - sections, - passages - ); - if (!section) return; - const schemeId = related(section, 'organizationScheme'); - if (schemeId) count++; - }); + const count = selectedSections.filter((s) => + related(s, 'organizationScheme') + ).length; if (count === 0) { showMessage(t.selectRowsToRemove); } else { diff --git a/src/renderer/src/components/Sheet/ScriptureTable.tsx b/src/renderer/src/components/Sheet/ScriptureTable.tsx index c4a27c8a..f2adb22b 100644 --- a/src/renderer/src/components/Sheet/ScriptureTable.tsx +++ b/src/renderer/src/components/Sheet/ScriptureTable.tsx @@ -2263,7 +2263,6 @@ export function ScriptureTable(props: IProps) { sections={getSectionsWhere(assignSections)} visible={assignSectionVisible} closeMethod={handleAssignClose} - refresh={refreshSheetAfterAssign} /> )} Date: Wed, 27 May 2026 17:10:26 -0500 Subject: [PATCH 3/4] Enhance PlanTabSelect tests by incorporating team organization data. Update mountPlanTabSelect calls to include teamGlobal and teamOrgs for improved test coverage of non-personal team scenarios. --- .../src/components/Sheet/PlanTabSelect.cy.tsx | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/Sheet/PlanTabSelect.cy.tsx b/src/renderer/src/components/Sheet/PlanTabSelect.cy.tsx index 2ea985e4..fdef2fdd 100644 --- a/src/renderer/src/components/Sheet/PlanTabSelect.cy.tsx +++ b/src/renderer/src/components/Sheet/PlanTabSelect.cy.tsx @@ -108,6 +108,17 @@ const personalTeamOrgs = [ }, ]; +/** Non-personal team — useShowAssignment needs org in orbit + matching global organization. */ +const teamOrgs = [ + { + id: 'org-1', + type: 'organization', + attributes: { name: 'My Team' }, + }, +]; + +const teamGlobal = { organization: 'org-1' }; + describe('PlanTabSelect', () => { let mockSetTab: ReturnType; @@ -261,14 +272,14 @@ describe('PlanTabSelect', () => { }); it('should update button text when tab changes', () => { - mountPlanTabSelect({ flat: false, tab: 0 }); + mountPlanTabSelect({ flat: false, tab: 0 }, teamGlobal, teamOrgs); cy.wait(100); cy.get('#planTabSelect') .should('contain.text', sectionsPassagesLabel) .should('contain.text', organizedByDefault); - mountPlanTabSelect({ flat: false, tab: 2 }); + mountPlanTabSelect({ flat: false, tab: 2 }, teamGlobal, teamOrgs); cy.wait(100); cy.get('#planTabSelect').should( 'contain.text', @@ -316,7 +327,7 @@ describe('PlanTabSelect', () => { describe('with assignments tab (non-personal team, online)', () => { it('should display all menu options including assignments', () => { - mountPlanTabSelect({ flat: false }); + mountPlanTabSelect({ flat: false }, teamGlobal, teamOrgs); cy.wait(100); openPlanTabMenu(); @@ -333,7 +344,7 @@ describe('PlanTabSelect', () => { }); it('should call setTab when Media menu item is clicked', () => { - mountPlanTabSelect({ flat: false, tab: 0 }); + mountPlanTabSelect({ flat: false, tab: 0 }, teamGlobal, teamOrgs); cy.wait(100); openPlanTabMenu(); @@ -342,7 +353,7 @@ describe('PlanTabSelect', () => { }); it('should call setTab with correct index for assignments and transcriptions', () => { - mountPlanTabSelect({ flat: false, tab: 0 }); + mountPlanTabSelect({ flat: false, tab: 0 }, teamGlobal, teamOrgs); cy.wait(100); openPlanTabMenu(); @@ -357,7 +368,7 @@ describe('PlanTabSelect', () => { }); it('should render menu items with stable ids from localized labels', () => { - mountPlanTabSelect({ flat: false }); + mountPlanTabSelect({ flat: false }, teamGlobal, teamOrgs); cy.wait(100); openPlanTabMenu(); @@ -371,7 +382,7 @@ describe('PlanTabSelect', () => { }); it('should handle menu options with flat mode correctly', () => { - mountPlanTabSelect({ flat: true }); + mountPlanTabSelect({ flat: true }, teamGlobal, teamOrgs); cy.wait(100); openPlanTabMenu(); From c3baac64715247fcc4e3303472e9c095bcb79044 Mon Sep 17 00:00:00 2001 From: Greg Trihus Date: Wed, 27 May 2026 17:10:27 -0500 Subject: [PATCH 4/4] Refactor AssignSection and ScriptureTable components for improved logic and clarity. Move needsLink check in AssignSection to ensure proper assignment flow. Update filter handling in ScriptureTable to create a new filter object from the source, enhancing state management. --- src/renderer/src/components/AssignSection.tsx | 8 ++++---- src/renderer/src/components/Sheet/ScriptureTable.tsx | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/AssignSection.tsx b/src/renderer/src/components/AssignSection.tsx index 31880bfb..e6acc999 100644 --- a/src/renderer/src/components/AssignSection.tsx +++ b/src/renderer/src/components/AssignSection.tsx @@ -286,14 +286,14 @@ function AssignSection(props: IProps) { }; const doAssign = async (schemeId: string): Promise => { - const needsLink = sections.some( - (s) => related(s, 'organizationScheme') !== schemeId - ); - if (!needsLink) return true; if (sections.length === 0) { showMessage(tAssign.selectRowsToAssign); return false; } + const needsLink = sections.some( + (s) => related(s, 'organizationScheme') !== schemeId + ); + if (!needsLink) return true; const ids = sections.map( (s) => remoteId('section', s.id, memory.keyMap as RecordKeyMap) as string ); diff --git a/src/renderer/src/components/Sheet/ScriptureTable.tsx b/src/renderer/src/components/Sheet/ScriptureTable.tsx index f2adb22b..a2cd2c31 100644 --- a/src/renderer/src/components/Sheet/ScriptureTable.tsx +++ b/src/renderer/src/components/Sheet/ScriptureTable.tsx @@ -388,9 +388,10 @@ export function ScriptureTable(props: IProps) { passageType + ' ' + firstBook; const getFilter = (fs: ISTFilterState) => { - const filter = (getLocalDefault(projDefFilterParam) ?? + const source = (getLocalDefault(projDefFilterParam) ?? getProjectDefault(projDefFilterParam) ?? fs) as ISTFilterState; + const filter: ISTFilterState = { ...source }; if (!showAssign && filter.assignedToMe) { filter.assignedToMe = false; @@ -1470,6 +1471,7 @@ export function ScriptureTable(props: IProps) { user, myGroups, developer, + setSheet, ]); const handleAssignClose = useCallback(