diff --git a/src/renderer/src/components/AssignSection.tsx b/src/renderer/src/components/AssignSection.tsx index 1ed6f5c4..e6acc999 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 => { + 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 ); @@ -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..b61b9aa1 100644 --- a/src/renderer/src/components/AssignmentTable.tsx +++ b/src/renderer/src/components/AssignmentTable.tsx @@ -65,6 +65,7 @@ import { } from '@mui/x-data-grid'; import { TreeDataGrid } from './TreeDataGrid'; import { pad2 } from '../utils/pad2'; +import { resolveSelectedSections } from './resolveSectionForRecId'; const AssignmentDiv = styled('div')(() => ({ display: 'flex', @@ -353,17 +354,9 @@ export function AssignmentTable() { if (check.length === 0) { showMessage(t.selectRowsToRemove); } 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); - 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 { @@ -387,7 +380,7 @@ export function AssignmentTable() { t, s as RecordIdentity, 'organizationScheme', - 'user', + 'organizationscheme', '' ), ...UpdateLastModifiedBy( @@ -413,7 +406,7 @@ export function AssignmentTable() { setCheck([]); setSelectedSections([]); setSelectedRows({ type: 'include', ids: new Set() }); - setRefresh(refresh + 1); + setRefresh((n) => n + 1); }; const handleRemoveAssignmentsRefused = () => setConfirmAction(''); @@ -478,13 +471,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 +558,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.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(); 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..a2cd2c31 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); @@ -385,9 +388,14 @@ 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; + } if (filter.minStep && !isNaN(Number(filter.minStep))) filter.minStep = remoteIdGuid( @@ -1129,8 +1137,6 @@ export function ScriptureTable(props: IProps) { }); }; - const handleAssignClose = () => () => setAssignSectionVisible(false); - const showUpload = (i: number) => { waitForPassageId(i, () => { const { ws } = getByIndex(sheetRef.current, i); @@ -1379,7 +1385,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 +1421,67 @@ 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, + setSheet, + ]); + + const handleAssignClose = useCallback( + (cancel?: boolean) => { + setAssignSectionVisible(false); + if (!cancel) refreshSheetAfterAssign(); + }, + [refreshSheetAfterAssign] + ); + // Save locally or online in batches useEffect(() => { let prevSave = ''; @@ -2197,7 +2264,7 @@ export function ScriptureTable(props: IProps) { scheme={getScheme(assignSections) as string} sections={getSectionsWhere(assignSections)} visible={assignSectionVisible} - closeMethod={handleAssignClose()} + closeMethod={handleAssignClose} /> )} ) => { 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]); +}