Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ _Avoid_: Hierarchical; calling flat rows "passages"
Project structure with sections containing numbered passages.
_Avoid_: Flat

**Sections & Passages**:
The project page that lists sections and passages — desktop sheet or mobile cards.
_Avoid_: PlanSheet; PlanView (implementation names)

**Passage Card**:
A mobile card for a passage (or section+passage) on Sections & Passages.
_Avoid_: Sheet row; table cell

**Current Passage**:
The last passage the user opened for work, stored per user in local storage so Sections & Passages can restore focus.
_Avoid_: Last row (desktop-sheet wording); selected row

## Publishing & Akuo

Publishing in APM normally means releasing **oral content** to listeners — not transferring transcriptions. Text content follows the Paratext sync path instead.
Expand Down
18 changes: 17 additions & 1 deletion src/renderer/src/components/Sheet/PassageCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface IProps {
onGraphicClick?: () => void;
isPlaying: boolean;
isPersonal?: boolean;
isCurrent?: boolean;
}

export function PassageCard(props: IProps) {
Expand All @@ -30,6 +31,7 @@ export function PassageCard(props: IProps) {
onGraphicClick,
isPlaying,
isPersonal,
isCurrent,
} = props;
const getDescription = useSectionIdDescription();
const t: ICardsStrings = useSelector(cardsSelector, shallowEqual);
Expand All @@ -52,10 +54,23 @@ export function PassageCard(props: IProps) {
}
};

const passageId = cardInfo.passage?.id;

return (
<Card
elevation={3}
sx={{ minWidth: isMobileWidth ? '100%' : 275, maxWidth: 400 }}
id={passageId ? `passage-card-${passageId}` : undefined}
data-cy={passageId ? `passage-card-${passageId}` : undefined}
aria-current={isCurrent ? 'true' : undefined}
sx={{
minWidth: isMobileWidth ? '100%' : 275,
maxWidth: 400,
...(isCurrent && {
outline: '2px solid',
outlineColor: 'primary.light',
outlineOffset: 2,
}),
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
Expand Down Expand Up @@ -118,6 +133,7 @@ export function PassageCard(props: IProps) {
</Box>
)}
<Button
data-cy="passage-card-step"
sx={{
width: '100%',
position: 'relative',
Expand Down
210 changes: 206 additions & 4 deletions src/renderer/src/components/Sheet/PlanView.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
} from '../../model';
import { RecordIdentity } from '@orbit/records';
import { PublishDestinationEnum } from '../../crud/usePublishDestination';
import { Box } from '@mui/material';
import { LocalKey, localUserKey } from '../../utils/localUserKey';

// Mock dependencies
const createMockLiveQuery = (data: any[] = []) => ({
Expand All @@ -30,10 +32,20 @@ const createMockLiveQuery = (data: any[] = []) => ({
query: () => data,
});

type PassageKeyPair = { localId: string; remoteId: string };

const createPassageKeyMap = (passages: PassageKeyPair[]) => ({
keyToId: (_table: string, _key: string, remoteId: string) =>
passages.find((p) => p.remoteId === remoteId)?.localId,
idToKey: (_table: string, _key: string, localId: string) =>
passages.find((p) => p.localId === localId)?.remoteId,
});

// Create mock memory that can return section and organization records
const createMockMemory = (
sections: SectionD[] = [],
organizations: any[] = []
organizations: any[] = [],
keyMap?: ReturnType<typeof createPassageKeyMap>
): Memory => {
return {
cache: {
Expand Down Expand Up @@ -78,6 +90,7 @@ const createMockMemory = (
patch: () => {}, // Add a simple patch method
},
update: () => {},
keyMap,
} as unknown as Memory;
};

Expand Down Expand Up @@ -277,7 +290,11 @@ describe('PlanView', { tags: '@smoke' }, () => {
globalStateOverrides = {},
initialEntries: string[] = ['/project/test-prj'],
sections: SectionD[] = [],
organizationName: string = 'Test Organization'
organizationName: string = 'Test Organization',
options: {
scrollHeight?: number;
keyMap?: ReturnType<typeof createPassageKeyMap>;
} = {}
) => {
const initialState = createInitialState(globalStateOverrides);
const planContextState = createMockPlanContextState(planContextOverrides);
Expand All @@ -300,7 +317,11 @@ describe('PlanView', { tags: '@smoke' }, () => {
},
];

const memory = createMockMemory(sections, mockOrganizations);
const memory = createMockMemory(
sections,
mockOrganizations,
options.keyMap
);

// Mock the cache to return organizations
memory.cache.query = (queryFn: (q: any) => any) => {
Expand All @@ -327,6 +348,8 @@ describe('PlanView', { tags: '@smoke' }, () => {
return queryFn(mockQueryBuilder);
};

const planView = <PlanView {...props} />;

cy.mount(
<MemoryRouter initialEntries={initialEntries}>
<Provider store={mockStore}>
Expand All @@ -338,7 +361,20 @@ describe('PlanView', { tags: '@smoke' }, () => {
setState: cy.stub(),
}}
>
<PlanView {...props} />
{options.scrollHeight ? (
<Box
data-cy="plan-view-scroller"
sx={{
height: options.scrollHeight,
overflowY: 'auto',
overflowX: 'hidden',
}}
>
{planView}
</Box>
) : (
planView
)}
</PlanContext.Provider>
</DataProvider>
</GlobalProvider>
Expand All @@ -347,6 +383,62 @@ describe('PlanView', { tags: '@smoke' }, () => {
);
};

const createManyPassages = (count: number): ISheet[] =>
Array.from({ length: count }, (_, i) => {
const n = i + 1;
return createMockPassage({
sectionSeq: n,
passageSeq: 1,
reference: `${n}:1`,
step: `Step ${n}`,
passage: {
id: `passage-${n}`,
type: 'passage',
attributes: {
sequencenum: 1,
book: 'GEN',
reference: `${n}:1`,
state: '',
hold: false,
title: '',
lastComment: '',
stepComplete: '{}',
dateCreated: '',
dateUpdated: '',
lastModifiedBy: 0,
},
keys: {
remoteId: String(n),
},
},
});
});

const keyPairsFromPassages = (rowInfo: ISheet[]): PassageKeyPair[] =>
rowInfo
.filter((r) => r.passage?.id)
.map((r) => ({
localId: r.passage!.id,
remoteId: String(r.passage!.keys?.remoteId ?? r.passage!.id),
}));

const assertCardInScrollerViewport = (passageLocalId: string) => {
cy.get('[data-cy="plan-view-scroller"]').then(($scroller) => {
cy.get(`[data-cy="passage-card-${passageLocalId}"]`).then(($card) => {
const scrollerRect = $scroller[0].getBoundingClientRect();
const cardRect = $card[0].getBoundingClientRect();
expect(
cardRect.bottom,
'card bottom should be below scroller top'
).to.be.gte(scrollerRect.top);
expect(
cardRect.top,
'card top should be above scroller bottom'
).to.be.lte(scrollerRect.bottom);
});
});
};

it('should render Grid container', () => {
const rowInfo: ISheet[] = [];
const bookMap = createMockBookNameMap();
Expand Down Expand Up @@ -954,4 +1046,114 @@ describe('PlanView', { tags: '@smoke' }, () => {
cy.get('div[class*="MuiCard-root"]').should('be.visible');
});

it('restores highlight and scroll so the Current Passage card is in view', () => {
const rowInfo = createManyPassages(12);
const bookMap = createMockBookNameMap();
const keyMap = createPassageKeyMap(keyPairsFromPassages(rowInfo));

cy.window().then((win) => {
win.localStorage.setItem(LocalKey.userId, 'test-user-id');
win.localStorage.setItem(localUserKey(LocalKey.passage), '10');
});

mountPlanView(
{
rowInfo,
bookMap,
publishingView: false,
handlePublish: mockHandlePublish,
handleGraphic: mockHandleGraphic,
},
{},
{},
['/project/test-prj'],
[],
'Test Organization',
{ scrollHeight: 280, keyMap }
);

cy.get('[data-cy="passage-card-passage-10"]', { timeout: 5000 })
.should('exist')
.and('have.attr', 'aria-current', 'true');
cy.get('[data-cy="passage-card-passage-1"]').should(
'not.have.attr',
'aria-current'
);
cy.get('[data-cy="plan-view-scroller"]').should(($scroller) => {
expect($scroller[0].scrollTop, 'scroller moved to Current Passage').to.be
.greaterThan(0);
});
assertCardInScrollerViewport('passage-10');
});

it('remembers Current Passage when the step button is clicked', () => {
const rowInfo = createManyPassages(4);
const bookMap = createMockBookNameMap();
const keyMap = createPassageKeyMap(keyPairsFromPassages(rowInfo));

cy.window().then((win) => {
win.localStorage.setItem(LocalKey.userId, 'test-user-id');
win.localStorage.removeItem(localUserKey(LocalKey.passage));
});

mountPlanView(
{
rowInfo,
bookMap,
publishingView: false,
handlePublish: mockHandlePublish,
handleGraphic: mockHandleGraphic,
},
{},
{},
['/project/test-prj'],
[],
'Test Organization',
{ keyMap }
);

cy.get('[data-cy="passage-card-passage-3"]', { timeout: 5000 })
.find('[data-cy="passage-card-step"]')
.click();

cy.window().then((win) => {
expect(win.localStorage.getItem(localUserKey(LocalKey.passage))).to.equal(
'3'
);
});
});

it('does not scroll when Current Passage is missing from rowInfo', () => {
const rowInfo = createManyPassages(12);
const bookMap = createMockBookNameMap();
const keyMap = createPassageKeyMap(keyPairsFromPassages(rowInfo));

cy.window().then((win) => {
win.localStorage.setItem(LocalKey.userId, 'test-user-id');
win.localStorage.setItem(localUserKey(LocalKey.passage), '999');
});

mountPlanView(
{
rowInfo,
bookMap,
publishingView: false,
handlePublish: mockHandlePublish,
handleGraphic: mockHandleGraphic,
},
{},
{},
['/project/test-prj'],
[],
'Test Organization',
{ scrollHeight: 280, keyMap }
);

cy.get('[data-cy="passage-card-passage-1"]', { timeout: 5000 }).should(
'be.visible'
);
cy.get('[data-cy="plan-view-scroller"]').should(($scroller) => {
expect($scroller[0].scrollTop).to.eq(0);
});
});
});
Loading
Loading