musora-content-services 2.162.1 → 2.163.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent/decisions/2026-05-19-content-progress-ts-part-2.md +53 -0
- package/.claude/settings.local.json +7 -36
- package/CHANGELOG.md +13 -0
- package/package.json +1 -1
- package/src/contentTypeConfig.js +3 -1
- package/src/lib/sanity/decorators/examples.ts +1 -1
- package/src/lib/sanity/decorators/navigate-to.ts +50 -25
- package/src/services/content-org/learning-paths.ts +1 -1
- package/src/services/contentProgress.js +1 -1
- package/src/services/permissions/PermissionsV2Adapter.ts +4 -0
- package/src/services/progress/.indexignore +0 -0
- package/src/services/progress/collections.ts +33 -0
- package/src/services/progress/index.ts +6 -0
- package/src/services/progress/internal/queries.ts +43 -0
- package/src/services/progress/state.ts +50 -0
- package/src/services/progress/types.ts +16 -0
- package/src/services/sanity.js +29 -14
- package/test/unit/lib/sanity/decorators/navigate-to.test.ts +124 -29
- package/test/unit/services/progress.test.ts +300 -0
- package/usage/sanity/decorators/examples.ts +237 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
date: 2026-05-19
|
|
3
|
+
branch: refactor/content-progress-ts-part-2
|
|
4
|
+
pr: https://github.com/railroadmedia/musora-content-services/pull/978
|
|
5
|
+
status: open
|
|
6
|
+
tags: [[chore]]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Split progress module into typed sub-modules with Progress namespace
|
|
10
|
+
|
|
11
|
+
## Context
|
|
12
|
+
|
|
13
|
+
`src/services/progress/index.ts` started as a flat file during the initial migration of `contentProgress.js` to TypeScript. As the migration continued it became clear the file would grow significantly, mixing query helpers, read operations, collection queries, and shared types. Naming also conflicted with the legacy JS exports still living in `contentProgress.js` during the transition.
|
|
14
|
+
|
|
15
|
+
## Decision
|
|
16
|
+
|
|
17
|
+
Split `progress/index.ts` into focused sub-modules:
|
|
18
|
+
|
|
19
|
+
- `state.ts` — point queries for progress state, playback position, last interacted
|
|
20
|
+
- `collections.ts` — bulk queries (allStarted, allCompleted, allStartedOrCompleted)
|
|
21
|
+
- `types.ts` — shared interfaces (GetAllQueryOptions, QueryMetadata, StartedOrCompletedOptions)
|
|
22
|
+
- `internal/queries.ts` — private DB helpers (getById, getByIds, getByRecordIds)
|
|
23
|
+
|
|
24
|
+
`index.ts` becomes a 5-line file that spreads both modules into a `Progress` namespace object:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
export const Progress = { ...state, ...collections }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Naming convention: nouns for reads, verbs for writes.**
|
|
31
|
+
Query functions drop their `get`/`find`/`getAll` prefixes since the namespace provides context (`Progress.state`, `Progress.allStarted`). Write operations not yet migrated will keep verb names when they land (`Progress.recordWatchSession`, `Progress.markCompleted`).
|
|
32
|
+
|
|
33
|
+
`resumeTimeByIds` was renamed to `playbackPositionByIds` — `resumeTime` read as a verb phrase in camelCase and was semantically ambiguous.
|
|
34
|
+
|
|
35
|
+
`navigate-to.ts` updated to consume `Progress.*` instead of named imports from the old flat index.
|
|
36
|
+
|
|
37
|
+
## Alternatives Considered
|
|
38
|
+
|
|
39
|
+
**Drop `get` prefix only (standalone names):** Rejected because `findIncompleteLesson` has a `find` prefix with semantic meaning (sync local search, not a DB fetch). Mixing bare nouns with `find*` names produces an inconsistent surface. The namespace approach sidesteps the verb question entirely.
|
|
40
|
+
|
|
41
|
+
**Nested read/write namespace (`Progress.get.*` / `Progress.set.*`):** Cleaner separation on paper but adds nesting for what is currently a read-only module. The simpler convention (nouns=reads, verbs=writes in flat namespace) was chosen to avoid premature structure.
|
|
42
|
+
|
|
43
|
+
## Process Notes
|
|
44
|
+
|
|
45
|
+
- `getByIds` and `getById` in `internal/queries.ts` originally had `collection` as the second parameter. The user's linter moved it to last as an optional param — this required updating all call sites in `state.ts` and cascading arg-order fixes in navigate-to.ts and tests.
|
|
46
|
+
- LSP diagnostics repeatedly fired false positives after the param reorder, claiming `string` was not assignable to `CollectionParameter`. `npx tsc --noEmit` was consistently the source of truth — all those diagnostics were stale cache.
|
|
47
|
+
- `findIncompleteLesson` param order was changed by the linter to `(progressOnItems, contentType, currentContentId?)` — both `navigate-to.ts` and the test file required arg-order updates.
|
|
48
|
+
|
|
49
|
+
## Consequences
|
|
50
|
+
|
|
51
|
+
- External callers import `Progress` and call `Progress.state(id)`, `Progress.allStarted()`, etc. — no ambiguity with `contentProgress.js` exports during the remaining migration
|
|
52
|
+
- Write operations remain in `contentProgress.js` until a follow-up PR migrates them into the `Progress` namespace with verb names
|
|
53
|
+
- The `internal/` directory convention signals private helpers not intended for direct import outside the progress module
|
|
@@ -1,41 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"permissions": {
|
|
3
3
|
"allow": [
|
|
4
|
-
"Bash(
|
|
5
|
-
"Bash(
|
|
6
|
-
"
|
|
7
|
-
"Bash(counselors ls *)",
|
|
8
|
-
"Bash(counselors groups *)",
|
|
9
|
-
"Bash(counselors run *)",
|
|
4
|
+
"Bash(rg:*)",
|
|
5
|
+
"Bash(npm run lint:*)",
|
|
6
|
+
"Bash(ls:*)",
|
|
10
7
|
"Bash(npm test *)",
|
|
11
|
-
"Bash(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"Bash(cat /home/alesevero/railenvironment/applications/musora-content-services/AGENTS.md)",
|
|
16
|
-
"Bash(echo \"no AGENTS.md\")",
|
|
17
|
-
"Bash(echo \"exit=$?\")",
|
|
18
|
-
"Bash(git checkout *)",
|
|
19
|
-
"Skill(pr)",
|
|
20
|
-
"Skill(create-decision)"
|
|
21
|
-
]
|
|
22
|
-
},
|
|
23
|
-
"model": "sonnet",
|
|
24
|
-
"effortLevel": "medium",
|
|
25
|
-
"enabledMcpjsonServers": [
|
|
26
|
-
"atlassian",
|
|
27
|
-
"figma",
|
|
28
|
-
"google-workspace",
|
|
29
|
-
"snowflake",
|
|
30
|
-
"aws",
|
|
31
|
-
"hex",
|
|
32
|
-
"sanity",
|
|
33
|
-
"mysql",
|
|
34
|
-
"slack",
|
|
35
|
-
"langfuse",
|
|
36
|
-
"chrome-devtools",
|
|
37
|
-
"railway",
|
|
38
|
-
"github",
|
|
39
|
-
"asana"
|
|
40
|
-
]
|
|
8
|
+
"Bash(npx jest *)"
|
|
9
|
+
],
|
|
10
|
+
"deny": []
|
|
11
|
+
}
|
|
41
12
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
## [2.163.0](https://github.com/railroadmedia/musora-content-services/compare/v2.162.1...v2.163.0) (2026-06-04)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* add need access logic branch for LPs ([#993](https://github.com/railroadmedia/musora-content-services/issues/993)) ([1d935aa](https://github.com/railroadmedia/musora-content-services/commit/1d935aaf147b45cd01bc1d438424f5322991aa69))
|
|
11
|
+
* **TP-1195:** optimize live event loading ([#971](https://github.com/railroadmedia/musora-content-services/issues/971)) ([8daa616](https://github.com/railroadmedia/musora-content-services/commit/8daa6160c5bc1ee005093719beef1dd9e83ac3c8))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* dont set resume-time to null ([#992](https://github.com/railroadmedia/musora-content-services/issues/992)) ([d7f0f3e](https://github.com/railroadmedia/musora-content-services/commit/d7f0f3e6aab53dcb332a9c149dd0a33f95bc1d75))
|
|
17
|
+
|
|
5
18
|
### [2.162.1](https://github.com/railroadmedia/musora-content-services/compare/v2.162.0...v2.162.1) (2026-06-04)
|
|
6
19
|
|
|
7
20
|
## [2.162.0](https://github.com/railroadmedia/musora-content-services/compare/v2.161.4...v2.162.0) (2026-06-03)
|
package/package.json
CHANGED
package/src/contentTypeConfig.js
CHANGED
|
@@ -212,7 +212,7 @@ export async function navigateToParallelWithProgress() {
|
|
|
212
212
|
|
|
213
213
|
const items = navigateRows as ContentWithNavAndProgress[]
|
|
214
214
|
const decorators: FieldDecoratorAsync<ContentWithNavAndProgress>[] = [
|
|
215
|
-
navigateToDecorator as FieldDecoratorAsync<ContentWithNavAndProgress>,
|
|
215
|
+
navigateToDecorator() as FieldDecoratorAsync<ContentWithNavAndProgress>,
|
|
216
216
|
{
|
|
217
217
|
field: 'progress_percent',
|
|
218
218
|
compute: (item) => fetchProgress(item.id),
|
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import { decorateAllAsync, type Decoratable, type FieldDecoratorAsync } from './base'
|
|
2
|
-
import {
|
|
3
|
-
findIncompleteLesson,
|
|
4
|
-
getLastInteractedOf,
|
|
5
|
-
getProgressState,
|
|
6
|
-
getProgressStateByIds,
|
|
7
|
-
} from '../../../services/contentProgress.js'
|
|
8
2
|
import {
|
|
9
3
|
COLLECTION_TYPE,
|
|
10
4
|
CollectionParameter,
|
|
11
5
|
STATE,
|
|
12
6
|
} from '../../../services/sync/models/ContentProgress'
|
|
7
|
+
import { Progress } from '@/services/progress'
|
|
13
8
|
|
|
14
9
|
export const NAVIGATE_TO_FIELD = 'navigateTo' as const
|
|
15
10
|
|
|
@@ -18,7 +13,7 @@ const NAVIGABLE_TYPES = [
|
|
|
18
13
|
'guided-course',
|
|
19
14
|
'course-collection',
|
|
20
15
|
'song-tutorial',
|
|
21
|
-
|
|
16
|
+
COLLECTION_TYPE.LEARNING_PATH,
|
|
22
17
|
'skill-pack',
|
|
23
18
|
] as const
|
|
24
19
|
|
|
@@ -68,38 +63,66 @@ function buildNavigateTo(
|
|
|
68
63
|
}
|
|
69
64
|
}
|
|
70
65
|
|
|
71
|
-
|
|
66
|
+
interface NavigateContext {
|
|
67
|
+
states: Map<number, string>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function prefetchStates(items: NavigateToDecoratable[]): Promise<NavigateContext> {
|
|
71
|
+
const ids = new Set<number>()
|
|
72
|
+
for (const item of items) {
|
|
73
|
+
if (!item || !NAVIGABLE_TYPES.includes(item.type as (typeof NAVIGABLE_TYPES)[number])) continue
|
|
74
|
+
ids.add(item.id)
|
|
75
|
+
for (const child of item.children ?? []) {
|
|
76
|
+
ids.add(child.id)
|
|
77
|
+
if (TWO_DEPTH_TYPES.includes(item.type)) {
|
|
78
|
+
for (const grandchild of child.children ?? []) {
|
|
79
|
+
ids.add(grandchild.id)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (ids.size === 0) return { states: new Map() }
|
|
85
|
+
const states = await Progress.stateByIds(Array.from(ids))
|
|
86
|
+
return { states }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function computeNavigateTo(
|
|
90
|
+
content: NavigateToDecoratable,
|
|
91
|
+
ctx?: NavigateContext
|
|
92
|
+
): Promise<NavigateTo | null> {
|
|
72
93
|
if (!NAVIGABLE_TYPES.includes(content.type as (typeof NAVIGABLE_TYPES)[number])) return null
|
|
73
94
|
|
|
74
95
|
const children = content.children
|
|
75
96
|
if (!children || children.length === 0) return null
|
|
76
97
|
|
|
77
|
-
const contentState = await
|
|
98
|
+
const contentState = ctx?.states.get(content.id) ?? (await Progress.state(content.id))
|
|
78
99
|
if (contentState !== STATE.STARTED) {
|
|
79
100
|
const firstChild = children[0]
|
|
80
101
|
const childNav = TWO_DEPTH_TYPES.includes(content.type)
|
|
81
|
-
? await computeNavigateTo(firstChild)
|
|
102
|
+
? await computeNavigateTo(firstChild, ctx)
|
|
82
103
|
: null
|
|
83
104
|
return buildNavigateTo(firstChild, childNav)
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
const childrenIds = children.map((c) => c.id)
|
|
87
108
|
const childrenById = new Map(children.map((c) => [c.id, c]))
|
|
88
|
-
const childrenStates =
|
|
89
|
-
|
|
109
|
+
const childrenStates = ctx
|
|
110
|
+
? new Map(childrenIds.map((id) => [id, ctx.states.get(id) ?? '']))
|
|
111
|
+
: await Progress.stateByIds(childrenIds)
|
|
112
|
+
const lastInteractedId = await Progress.lastInteractedOf(childrenIds)
|
|
90
113
|
|
|
91
114
|
if (COURSE_FLOW_TYPES.includes(content.type)) {
|
|
92
115
|
const lastInteractedStatus = childrenStates.get(lastInteractedId)
|
|
93
116
|
const targetId =
|
|
94
117
|
lastInteractedStatus === STATE.STARTED
|
|
95
118
|
? lastInteractedId
|
|
96
|
-
:
|
|
119
|
+
: Progress.incompleteLesson(childrenStates, content.type, lastInteractedId)
|
|
97
120
|
const target = childrenById.get(targetId)
|
|
98
121
|
return target ? buildNavigateTo(target) : null
|
|
99
122
|
}
|
|
100
123
|
|
|
101
124
|
if (GUIDED_FLOW_TYPES.includes(content.type)) {
|
|
102
|
-
const targetId =
|
|
125
|
+
const targetId = Progress.incompleteLesson(childrenStates, content.type, lastInteractedId)
|
|
103
126
|
const target = childrenById.get(targetId)
|
|
104
127
|
return target ? buildNavigateTo(target) : null
|
|
105
128
|
}
|
|
@@ -107,21 +130,21 @@ async function computeNavigateTo(content: NavigateToDecoratable): Promise<Naviga
|
|
|
107
130
|
if (TWO_DEPTH_TYPES.includes(content.type)) {
|
|
108
131
|
const lastChild = childrenById.get(lastInteractedId)
|
|
109
132
|
if (!lastChild) return null
|
|
110
|
-
const childNav = await computeNavigateTo(lastChild)
|
|
133
|
+
const childNav = await computeNavigateTo(lastChild, ctx)
|
|
111
134
|
return buildNavigateTo(lastChild, childNav)
|
|
112
135
|
}
|
|
113
136
|
|
|
114
137
|
return null
|
|
115
138
|
}
|
|
116
139
|
|
|
117
|
-
export
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
140
|
+
export function navigateToDecorator(
|
|
141
|
+
ctx?: NavigateContext
|
|
142
|
+
): FieldDecoratorAsync<NavigateToDecoratable, typeof NAVIGATE_TO_FIELD, NavigateTo | null> {
|
|
143
|
+
return {
|
|
144
|
+
field: NAVIGATE_TO_FIELD,
|
|
145
|
+
compute: (item) => computeNavigateTo(item, ctx),
|
|
146
|
+
recurse: false,
|
|
147
|
+
}
|
|
125
148
|
}
|
|
126
149
|
|
|
127
150
|
export function decorateNavigateTo<T extends NavigateToDecoratable>(
|
|
@@ -130,10 +153,12 @@ export function decorateNavigateTo<T extends NavigateToDecoratable>(
|
|
|
130
153
|
export function decorateNavigateTo<T extends NavigateToDecoratable>(
|
|
131
154
|
items: T
|
|
132
155
|
): Promise<WithNavigateTo<T>>
|
|
133
|
-
export function decorateNavigateTo<T extends NavigateToDecoratable>(
|
|
156
|
+
export async function decorateNavigateTo<T extends NavigateToDecoratable>(
|
|
134
157
|
items: T | T[]
|
|
135
158
|
): Promise<WithNavigateTo<T> | WithNavigateTo<T>[]> {
|
|
136
|
-
|
|
159
|
+
const list = Array.isArray(items) ? items : [items]
|
|
160
|
+
const ctx = await prefetchStates(list)
|
|
161
|
+
return decorateAllAsync(items as NavigateToDecoratable, [navigateToDecorator(ctx)]) as Promise<
|
|
137
162
|
WithNavigateTo<T> | WithNavigateTo<T>[]
|
|
138
163
|
>
|
|
139
164
|
}
|
|
@@ -222,7 +222,7 @@ export async function resetAllLearningPaths() {
|
|
|
222
222
|
* @param {number} learningPathId - The learning path ID
|
|
223
223
|
* @returns {Promise<Object>} Learning path with enriched lesson data
|
|
224
224
|
*/
|
|
225
|
-
export async function getEnrichedLearningPath(learningPathId) {
|
|
225
|
+
export async function getEnrichedLearningPath(learningPathId: number) {
|
|
226
226
|
let response = (await addContextToLearningPaths(
|
|
227
227
|
fetchByRailContentId,
|
|
228
228
|
learningPathId,
|
|
@@ -841,7 +841,7 @@ export function filterOutLearningPathsForDuplication(progresses, collection) {
|
|
|
841
841
|
|
|
842
842
|
export async function duplicateProgressForIds(entries) {
|
|
843
843
|
return Promise.all(Object.entries(entries).map(([id, pct]) => {
|
|
844
|
-
return saveContentProgress(parseInt(id), null, pct,
|
|
844
|
+
return saveContentProgress(parseInt(id), null, pct, undefined, { skipPush: true, accessedDirectly: false })
|
|
845
845
|
}))
|
|
846
846
|
}
|
|
847
847
|
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from './permissions'
|
|
20
20
|
import {arrayToRawRepresentation, arrayToStringRepresentation} from '../../filterBuilder.js'
|
|
21
21
|
import {basicMembershipTier, plusMembershipTier} from "../../contentTypeConfig";
|
|
22
|
+
import { COLLECTION_TYPE } from '../sync/models/ContentProgress'
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* V2 Permissions Adapter for the new permissions system.
|
|
@@ -55,6 +56,9 @@ export class PermissionsV2Adapter extends PermissionsAdapter {
|
|
|
55
56
|
|
|
56
57
|
// Content with no permissions is accessible to all
|
|
57
58
|
if (contentPermissions.size === 0) {
|
|
59
|
+
if (content?.type === COLLECTION_TYPE.LEARNING_PATH && content?.children?.length) {
|
|
60
|
+
return content.children.every(child => child.need_access)
|
|
61
|
+
}
|
|
58
62
|
return false
|
|
59
63
|
}
|
|
60
64
|
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { db } from '../sync'
|
|
2
|
+
import { ProgressQueryOptions, StartedOrCompletedOptions } from './types'
|
|
3
|
+
|
|
4
|
+
const SIXTY_DAYS_IN_SECONDS = 60 * 24 * 60 * 60
|
|
5
|
+
|
|
6
|
+
const defaultQueryOptions: ProgressQueryOptions = {
|
|
7
|
+
onlyIds: true,
|
|
8
|
+
include: {
|
|
9
|
+
aLaCarte: true,
|
|
10
|
+
learningPaths: false,
|
|
11
|
+
},
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const allStarted = async (limit: number | null = null, options?: ProgressQueryOptions) =>
|
|
15
|
+
db.contentProgress.started(limit, options ?? defaultQueryOptions)
|
|
16
|
+
|
|
17
|
+
export const allCompleted = async (limit: number | null = null, options?: ProgressQueryOptions) =>
|
|
18
|
+
db.contentProgress.completed(limit, options ?? defaultQueryOptions)
|
|
19
|
+
|
|
20
|
+
export const allCompletedByIds = async (contentIds: number[]) =>
|
|
21
|
+
db.contentProgress.completedByContentIds(contentIds)
|
|
22
|
+
|
|
23
|
+
export const allStartedOrCompleted = async (
|
|
24
|
+
limit?: number,
|
|
25
|
+
options: StartedOrCompletedOptions = {}
|
|
26
|
+
) =>
|
|
27
|
+
db.contentProgress
|
|
28
|
+
.startedOrCompleted({
|
|
29
|
+
...options,
|
|
30
|
+
limit,
|
|
31
|
+
updatedAfter: Math.floor(Date.now() / 1000) - SIXTY_DAYS_IN_SECONDS,
|
|
32
|
+
})
|
|
33
|
+
.then((r) => r.data)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { db } from '../../sync'
|
|
2
|
+
import { CollectionParameter } from '../../sync/models/ContentProgress'
|
|
3
|
+
|
|
4
|
+
export const getByIds = async <V>(
|
|
5
|
+
contentIds: number[],
|
|
6
|
+
dataKey: string,
|
|
7
|
+
defaultValue: V,
|
|
8
|
+
collection?: CollectionParameter
|
|
9
|
+
): Promise<Map<number, V>> => {
|
|
10
|
+
if (contentIds.length === 0) return new Map()
|
|
11
|
+
|
|
12
|
+
const progress = new Map(contentIds.map((id) => [id, defaultValue]))
|
|
13
|
+
await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then((r) => {
|
|
14
|
+
r.data.forEach((p) => {
|
|
15
|
+
progress.set(p.content_id, p[dataKey] ?? defaultValue)
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
return progress
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const getById = async <V>(
|
|
22
|
+
contentId: number,
|
|
23
|
+
dataKey: string,
|
|
24
|
+
defaultValue: V,
|
|
25
|
+
collection?: CollectionParameter
|
|
26
|
+
): Promise<V> => {
|
|
27
|
+
if (!contentId) return defaultValue
|
|
28
|
+
return db.contentProgress
|
|
29
|
+
.getOneProgressByContentId(contentId, collection)
|
|
30
|
+
.then((r) => r.data?.[dataKey] ?? defaultValue)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const getByRecordIds = async <V>(ids: string[], dataKey: string, defaultValue: V) => {
|
|
34
|
+
const progress = Object.fromEntries(ids.map((id) => [id, defaultValue]))
|
|
35
|
+
|
|
36
|
+
await db.contentProgress.getSomeProgressByRecordIds(ids).then((r) => {
|
|
37
|
+
r.data.forEach((p) => {
|
|
38
|
+
progress[p.id] = p[dataKey] ?? defaultValue
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
return progress
|
|
43
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { db } from '../sync'
|
|
2
|
+
import { COLLECTION_TYPE, CollectionParameter } from '../sync/models/ContentProgress'
|
|
3
|
+
import { getById, getByIds, getByRecordIds } from './internal/queries'
|
|
4
|
+
|
|
5
|
+
export const state = async (contentId: number, collection?: CollectionParameter) =>
|
|
6
|
+
getById(contentId, 'state', '', collection)
|
|
7
|
+
|
|
8
|
+
export const stateByIds = async (contentIds: number[], collection?: CollectionParameter) =>
|
|
9
|
+
getByIds(contentIds, 'state', '', collection)
|
|
10
|
+
|
|
11
|
+
export const stateByRecordIds = async (ids: string[]) => getByRecordIds(ids, 'state', '')
|
|
12
|
+
|
|
13
|
+
export const playbackPositionByIds = async (contentIds: number[], collection?: CollectionParameter) =>
|
|
14
|
+
getByIds(contentIds, 'resume_time_seconds', 0, collection)
|
|
15
|
+
|
|
16
|
+
export const playbackPositionByRecordIds = async (ids: string[]) =>
|
|
17
|
+
getByRecordIds(ids, 'resume_time_seconds', 0)
|
|
18
|
+
|
|
19
|
+
export const lastInteractedOf = (
|
|
20
|
+
contentIds: number[],
|
|
21
|
+
collection?: CollectionParameter
|
|
22
|
+
): Promise<number | undefined> =>
|
|
23
|
+
db.contentProgress
|
|
24
|
+
.mostRecentlyUpdatedId(contentIds, collection)
|
|
25
|
+
.then((r) => (r.data ? parseInt(r.data, 10) : undefined))
|
|
26
|
+
|
|
27
|
+
export const incompleteLesson = (
|
|
28
|
+
progressOnItems: Map<number, string>,
|
|
29
|
+
contentType: string,
|
|
30
|
+
currentContentId?: number
|
|
31
|
+
): number | null | undefined => {
|
|
32
|
+
const ids = Array.from(progressOnItems.keys())
|
|
33
|
+
const getProgress = (id: number) => progressOnItems.get(id)
|
|
34
|
+
|
|
35
|
+
if (contentType === 'guided-course' || contentType === COLLECTION_TYPE.LEARNING_PATH) {
|
|
36
|
+
return ids.find((id) => getProgress(id) !== 'completed') || ids.at(0)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const currentIndex = ids.indexOf(Number(currentContentId))
|
|
40
|
+
const startIndex = currentIndex === -1 ? 0 : currentIndex + 1
|
|
41
|
+
|
|
42
|
+
for (let i = startIndex; i < ids.length; i++) {
|
|
43
|
+
const id = ids[i]
|
|
44
|
+
if (getProgress(id) !== 'completed') {
|
|
45
|
+
return id
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return ids[0]
|
|
50
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface ProgressContentFilter {
|
|
2
|
+
aLaCarte?: boolean
|
|
3
|
+
learningPaths?: boolean
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ProgressQueryOptions {
|
|
7
|
+
onlyIds?: boolean
|
|
8
|
+
include?: ProgressContentFilter
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface StartedOrCompletedOptions {
|
|
12
|
+
brand?: string
|
|
13
|
+
contentTypes?: string[]
|
|
14
|
+
parentId?: number
|
|
15
|
+
include?: ProgressContentFilter
|
|
16
|
+
}
|
package/src/services/sanity.js
CHANGED
|
@@ -1193,13 +1193,11 @@ export async function fetchLiveEvent(brand, forcedContentId = null) {
|
|
|
1193
1193
|
default:
|
|
1194
1194
|
break
|
|
1195
1195
|
}
|
|
1196
|
-
let startDateTemp = new Date()
|
|
1197
|
-
let endDateTemp = new Date()
|
|
1198
1196
|
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
)
|
|
1202
|
-
|
|
1197
|
+
const now = new Date()
|
|
1198
|
+
|
|
1199
|
+
const startOfYesterday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1)).toISOString()
|
|
1200
|
+
const endOfTomorrow = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 2)).toISOString()
|
|
1203
1201
|
|
|
1204
1202
|
const liveEventFields = getLiveFields().concat(
|
|
1205
1203
|
`'event_coach_calendar_id': coalesce(calendar_id, '${defaultCalendarID}')`
|
|
@@ -1212,15 +1210,25 @@ export async function fetchLiveEvent(brand, forcedContentId = null) {
|
|
|
1212
1210
|
: `status == 'scheduled'
|
|
1213
1211
|
&& (brand == '${brand}' || live_global_event == true)
|
|
1214
1212
|
&& defined(live_event_start_time)
|
|
1215
|
-
&& live_event_start_time
|
|
1216
|
-
&&
|
|
1213
|
+
&& live_event_start_time >= '${startOfYesterday}'
|
|
1214
|
+
&& live_event_start_time < '${endOfTomorrow}'`
|
|
1217
1215
|
|
|
1218
1216
|
const filter = await new FilterBuilder(baseFilter, { bypassPermissions: true }).buildFilter()
|
|
1219
1217
|
|
|
1220
|
-
|
|
1221
|
-
|
|
1218
|
+
const events = await fetchSanity(
|
|
1219
|
+
`*[${filter}]{${fieldsString}} | order(live_event_start_time asc)`,
|
|
1220
|
+
true,
|
|
1221
|
+
{ processNeedAccess: false }
|
|
1222
|
+
)
|
|
1222
1223
|
|
|
1223
|
-
|
|
1224
|
+
const clientNow = new Date()
|
|
1225
|
+
const windowStart = new Date(clientNow.getTime() - LIVE_EXTRA_MINUTES * 60000).toISOString()
|
|
1226
|
+
const windowEnd = new Date(clientNow.getTime() + LIVE_EXTRA_MINUTES * 60000).toISOString()
|
|
1227
|
+
|
|
1228
|
+
return events?.find(event =>
|
|
1229
|
+
event.live_event_end_time >= windowStart &&
|
|
1230
|
+
event.live_event_start_time <= windowEnd
|
|
1231
|
+
) ?? null
|
|
1224
1232
|
}
|
|
1225
1233
|
|
|
1226
1234
|
/**
|
|
@@ -1667,8 +1675,8 @@ function contentResultsDecorator(results, fieldName, callback) {
|
|
|
1667
1675
|
processChildren(contentItem)
|
|
1668
1676
|
})
|
|
1669
1677
|
} else {
|
|
1670
|
-
result[fieldName] = callback(result)
|
|
1671
1678
|
processChildren(result)
|
|
1679
|
+
result[fieldName] = callback(result)
|
|
1672
1680
|
}
|
|
1673
1681
|
})
|
|
1674
1682
|
} else if (results.entity && Array.isArray(results.entity)) {
|
|
@@ -1705,8 +1713,8 @@ function contentResultsDecorator(results, fieldName, callback) {
|
|
|
1705
1713
|
}
|
|
1706
1714
|
})
|
|
1707
1715
|
} else {
|
|
1716
|
+
processChildren(results)
|
|
1708
1717
|
results[fieldName] = callback(results)
|
|
1709
|
-
processChildren(results) // this on was always true
|
|
1710
1718
|
}
|
|
1711
1719
|
|
|
1712
1720
|
return results
|
|
@@ -2224,7 +2232,14 @@ export async function fetchScheduledAndNewReleases(
|
|
|
2224
2232
|
return []
|
|
2225
2233
|
}
|
|
2226
2234
|
|
|
2227
|
-
|
|
2235
|
+
const reordered = reorderScheduledAndNewReleases(r, limit)
|
|
2236
|
+
const computedNow = new Date()
|
|
2237
|
+
return reordered.map(item => ({
|
|
2238
|
+
...item,
|
|
2239
|
+
isLive: item.live_event_start_time && item.live_event_end_time
|
|
2240
|
+
? new Date(item.live_event_start_time) <= computedNow && new Date(item.live_event_end_time) >= computedNow
|
|
2241
|
+
: false,
|
|
2242
|
+
}))
|
|
2228
2243
|
}
|
|
2229
2244
|
|
|
2230
2245
|
function reorderScheduledAndNewReleases(r, limit) {
|
|
@@ -16,25 +16,10 @@ jest.mock('../../../../../src/services/sync/repository-proxy', () => {
|
|
|
16
16
|
return Promise.resolve({ data: mockLastInteracted })
|
|
17
17
|
}),
|
|
18
18
|
},
|
|
19
|
-
practices: {
|
|
20
|
-
queryAll: jest.fn().mockResolvedValue({ data: [] }),
|
|
21
|
-
getAll: jest.fn().mockResolvedValue({ data: [] }),
|
|
22
|
-
},
|
|
23
19
|
}
|
|
24
20
|
return { default: mockFns, ...mockFns }
|
|
25
21
|
})
|
|
26
22
|
|
|
27
|
-
jest.mock('../../../../../src/services/content-org/learning-paths', () => ({
|
|
28
|
-
getDailySession: jest.fn().mockResolvedValue(null),
|
|
29
|
-
onLearningPathCompletedActions: jest.fn().mockResolvedValue(undefined),
|
|
30
|
-
}))
|
|
31
|
-
|
|
32
|
-
jest.mock('../../../../../src/services/sanity.js', () => ({
|
|
33
|
-
getHierarchy: jest.fn().mockResolvedValue({ metadata: {}, parents: {}, children: {} }),
|
|
34
|
-
getHierarchies: jest.fn().mockResolvedValue({ metadata: {}, parents: {}, children: {} }),
|
|
35
|
-
getSanityDate: jest.fn((date: Date) => date.toISOString()),
|
|
36
|
-
}))
|
|
37
|
-
|
|
38
23
|
import { initializeTestService } from '../../../../initializeTests.js'
|
|
39
24
|
import {
|
|
40
25
|
NAVIGATE_TO_FIELD,
|
|
@@ -75,11 +60,32 @@ beforeEach(() => {
|
|
|
75
60
|
})
|
|
76
61
|
|
|
77
62
|
describe('navigate-to decorator', () => {
|
|
78
|
-
describe('navigateToDecorator (
|
|
63
|
+
describe('navigateToDecorator (factory)', () => {
|
|
79
64
|
test('field is navigateTo', () => {
|
|
80
|
-
expect(navigateToDecorator.field).toBe('navigateTo')
|
|
65
|
+
expect(navigateToDecorator().field).toBe('navigateTo')
|
|
81
66
|
expect(NAVIGATE_TO_FIELD).toBe('navigateTo')
|
|
82
67
|
})
|
|
68
|
+
|
|
69
|
+
test('recurse is false', () => {
|
|
70
|
+
expect(navigateToDecorator().recurse).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('without ctx — navigates single item via DB', async () => {
|
|
74
|
+
const item = parent(1, 'course', [child(101), child(102)])
|
|
75
|
+
const result = await navigateToDecorator().compute(item)
|
|
76
|
+
expect(result).toMatchObject({ id: 101, child: null })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('with ctx — uses ctx states, not DB', async () => {
|
|
80
|
+
// DB says course is started; ctx overrides to not-started → should go to first child
|
|
81
|
+
mockProgressRecords = [
|
|
82
|
+
{ content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
|
|
83
|
+
]
|
|
84
|
+
const ctx = { states: new Map<number, string>([[1, '']]) }
|
|
85
|
+
const item = parent(1, 'course', [child(101), child(102)])
|
|
86
|
+
const result = await navigateToDecorator(ctx).compute(item)
|
|
87
|
+
expect(result).toMatchObject({ id: 101 })
|
|
88
|
+
})
|
|
83
89
|
})
|
|
84
90
|
|
|
85
91
|
describe('decorateNavigateTo', () => {
|
|
@@ -179,6 +185,21 @@ describe('navigate-to decorator', () => {
|
|
|
179
185
|
})
|
|
180
186
|
})
|
|
181
187
|
|
|
188
|
+
test('two-depth: started course-collection, last-interacted course has completed lessons → first incomplete lesson, not lesson[0]', async () => {
|
|
189
|
+
mockProgressRecords = [
|
|
190
|
+
{ content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
|
|
191
|
+
{ content_id: 101, state: 'started', progress_percent: 50, updated_at: 900 },
|
|
192
|
+
{ content_id: 201, state: 'completed', progress_percent: 100, updated_at: 800 },
|
|
193
|
+
{ content_id: 202, state: '', progress_percent: 0, updated_at: 0 },
|
|
194
|
+
{ content_id: 203, state: '', progress_percent: 0, updated_at: 0 },
|
|
195
|
+
]
|
|
196
|
+
mockLastInteracted = 101
|
|
197
|
+
const course = parent(101, 'course', [child(201), child(202), child(203)])
|
|
198
|
+
const collection = parent(1, 'course-collection', [course])
|
|
199
|
+
const result = await decorateNavigateTo(collection)
|
|
200
|
+
expect(result.navigateTo).toMatchObject({ id: 101, child: { id: 202 } })
|
|
201
|
+
})
|
|
202
|
+
|
|
182
203
|
test('decorates every item in an array', async () => {
|
|
183
204
|
const items = [parent(1, 'course', [child(101)]), parent(2, 'lesson', [child(201)])]
|
|
184
205
|
const result = await decorateNavigateTo(items)
|
|
@@ -192,18 +213,6 @@ describe('navigate-to decorator', () => {
|
|
|
192
213
|
expect(result).toBe(items)
|
|
193
214
|
})
|
|
194
215
|
|
|
195
|
-
test('navigateToDecorator.compute fires once per top-level item, never on descendants', async () => {
|
|
196
|
-
const spy = jest.spyOn(navigateToDecorator, 'compute')
|
|
197
|
-
const courseChild = parent(101, 'course', [child(201), child(202)])
|
|
198
|
-
const collection = parent(1, 'course-collection', [courseChild, parent(102, 'course', [])])
|
|
199
|
-
const standalone = parent(2, 'course', [child(301)])
|
|
200
|
-
await decorateNavigateTo([collection, standalone])
|
|
201
|
-
expect(spy).toHaveBeenCalledTimes(2)
|
|
202
|
-
expect(spy).toHaveBeenCalledWith(collection)
|
|
203
|
-
expect(spy).toHaveBeenCalledWith(standalone)
|
|
204
|
-
spy.mockRestore()
|
|
205
|
-
})
|
|
206
|
-
|
|
207
216
|
test('descendants of decorated items do not receive navigateTo field', async () => {
|
|
208
217
|
const item = parent(1, 'course', [child(101), child(102)])
|
|
209
218
|
const result = await decorateNavigateTo(item)
|
|
@@ -237,6 +246,92 @@ describe('navigate-to decorator', () => {
|
|
|
237
246
|
expect(result.navigateTo).toMatchObject({ id: 102 })
|
|
238
247
|
})
|
|
239
248
|
|
|
249
|
+
test('guided-course STARTED, lastInteracted not in children → first incomplete', async () => {
|
|
250
|
+
mockProgressRecords = [
|
|
251
|
+
{ content_id: 1, state: 'started', progress_percent: 40, updated_at: 1000 },
|
|
252
|
+
{ content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
|
|
253
|
+
{ content_id: 102, state: '', progress_percent: 0, updated_at: 0 },
|
|
254
|
+
{ content_id: 103, state: '', progress_percent: 0, updated_at: 0 },
|
|
255
|
+
]
|
|
256
|
+
mockLastInteracted = 999
|
|
257
|
+
const item = parent(1, 'guided-course', [child(101), child(102), child(103)])
|
|
258
|
+
const result = await decorateNavigateTo(item)
|
|
259
|
+
expect(result.navigateTo).toMatchObject({ id: 102 })
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('guided-course STARTED, lastInteracted not in children, all complete → first child', async () => {
|
|
263
|
+
mockProgressRecords = [
|
|
264
|
+
{ content_id: 1, state: 'started', progress_percent: 100, updated_at: 1000 },
|
|
265
|
+
{ content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
|
|
266
|
+
{ content_id: 102, state: 'completed', progress_percent: 100, updated_at: 1000 },
|
|
267
|
+
]
|
|
268
|
+
mockLastInteracted = 999
|
|
269
|
+
const item = parent(1, 'guided-course', [child(101), child(102)])
|
|
270
|
+
const result = await decorateNavigateTo(item)
|
|
271
|
+
expect(result.navigateTo).toMatchObject({ id: 101 })
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('learning-path-v2 STARTED, lastInteracted not in children → first incomplete', async () => {
|
|
275
|
+
mockProgressRecords = [
|
|
276
|
+
{ content_id: 1, state: 'started', progress_percent: 40, updated_at: 1000 },
|
|
277
|
+
{ content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
|
|
278
|
+
{ content_id: 102, state: '', progress_percent: 0, updated_at: 0 },
|
|
279
|
+
]
|
|
280
|
+
mockLastInteracted = 999
|
|
281
|
+
const item = parent(1, COLLECTION_TYPE.LEARNING_PATH, [child(101), child(102)])
|
|
282
|
+
const result = await decorateNavigateTo(item)
|
|
283
|
+
expect(result.navigateTo).toMatchObject({ id: 102 })
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('course STARTED, lastInteracted not in children → first incomplete', async () => {
|
|
287
|
+
mockProgressRecords = [
|
|
288
|
+
{ content_id: 1, state: 'started', progress_percent: 40, updated_at: 1000 },
|
|
289
|
+
{ content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
|
|
290
|
+
{ content_id: 102, state: '', progress_percent: 0, updated_at: 0 },
|
|
291
|
+
{ content_id: 103, state: '', progress_percent: 0, updated_at: 0 },
|
|
292
|
+
]
|
|
293
|
+
mockLastInteracted = 999
|
|
294
|
+
const item = parent(1, 'course', [child(101), child(102), child(103)])
|
|
295
|
+
const result = await decorateNavigateTo(item)
|
|
296
|
+
expect(result.navigateTo).toMatchObject({ id: 102 })
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('skill-pack STARTED, lastInteracted not in children → first incomplete', async () => {
|
|
300
|
+
mockProgressRecords = [
|
|
301
|
+
{ content_id: 1, state: 'started', progress_percent: 40, updated_at: 1000 },
|
|
302
|
+
{ content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
|
|
303
|
+
{ content_id: 102, state: '', progress_percent: 0, updated_at: 0 },
|
|
304
|
+
]
|
|
305
|
+
mockLastInteracted = 999
|
|
306
|
+
const item = parent(1, 'skill-pack', [child(101), child(102)])
|
|
307
|
+
const result = await decorateNavigateTo(item)
|
|
308
|
+
expect(result.navigateTo).toMatchObject({ id: 102 })
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('song-tutorial STARTED, lastInteracted not in children → first incomplete', async () => {
|
|
312
|
+
mockProgressRecords = [
|
|
313
|
+
{ content_id: 1, state: 'started', progress_percent: 40, updated_at: 1000 },
|
|
314
|
+
{ content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
|
|
315
|
+
{ content_id: 102, state: '', progress_percent: 0, updated_at: 0 },
|
|
316
|
+
]
|
|
317
|
+
mockLastInteracted = 999
|
|
318
|
+
const item = parent(1, 'song-tutorial', [child(101), child(102)])
|
|
319
|
+
const result = await decorateNavigateTo(item)
|
|
320
|
+
expect(result.navigateTo).toMatchObject({ id: 102 })
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test('course STARTED, lastInteracted not in children, all complete → first child', async () => {
|
|
324
|
+
mockProgressRecords = [
|
|
325
|
+
{ content_id: 1, state: 'started', progress_percent: 100, updated_at: 1000 },
|
|
326
|
+
{ content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
|
|
327
|
+
{ content_id: 102, state: 'completed', progress_percent: 100, updated_at: 1000 },
|
|
328
|
+
]
|
|
329
|
+
mockLastInteracted = 999
|
|
330
|
+
const item = parent(1, 'course', [child(101), child(102)])
|
|
331
|
+
const result = await decorateNavigateTo(item)
|
|
332
|
+
expect(result.navigateTo).toMatchObject({ id: 101 })
|
|
333
|
+
})
|
|
334
|
+
|
|
240
335
|
test('two-depth started but lastInteracted child not in collection → null', async () => {
|
|
241
336
|
mockProgressRecords = [
|
|
242
337
|
{ content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
let mockProgressRecords: any[] = []
|
|
2
|
+
let mockRecordsById: Record<string, any> = {}
|
|
3
|
+
let mockLastInteracted: string | null = null
|
|
4
|
+
let mockStarted: any = { data: [] }
|
|
5
|
+
let mockCompleted: any = { data: [] }
|
|
6
|
+
let mockCompletedByContentIds: any = { data: [] }
|
|
7
|
+
let mockStartedOrCompleted: any = { data: [] }
|
|
8
|
+
|
|
9
|
+
const repoMocks = {
|
|
10
|
+
contentProgress: {
|
|
11
|
+
getOneProgressByContentId: jest.fn().mockImplementation((contentId: number) => {
|
|
12
|
+
const record = mockProgressRecords.find((r) => r.content_id === contentId)
|
|
13
|
+
return Promise.resolve({ data: record || null })
|
|
14
|
+
}),
|
|
15
|
+
getSomeProgressByContentIds: jest.fn().mockImplementation((contentIds: number[]) => {
|
|
16
|
+
const records = mockProgressRecords.filter((r) => contentIds.includes(r.content_id))
|
|
17
|
+
return Promise.resolve({ data: records })
|
|
18
|
+
}),
|
|
19
|
+
getSomeProgressByRecordIds: jest.fn().mockImplementation((ids: string[]) => {
|
|
20
|
+
const records = ids.map((id) => mockRecordsById[id]).filter(Boolean)
|
|
21
|
+
return Promise.resolve({ data: records })
|
|
22
|
+
}),
|
|
23
|
+
mostRecentlyUpdatedId: jest.fn().mockImplementation(() => {
|
|
24
|
+
return Promise.resolve({ data: mockLastInteracted })
|
|
25
|
+
}),
|
|
26
|
+
started: jest.fn().mockImplementation(() => Promise.resolve(mockStarted)),
|
|
27
|
+
completed: jest.fn().mockImplementation(() => Promise.resolve(mockCompleted)),
|
|
28
|
+
completedByContentIds: jest
|
|
29
|
+
.fn()
|
|
30
|
+
.mockImplementation(() => Promise.resolve(mockCompletedByContentIds)),
|
|
31
|
+
startedOrCompleted: jest.fn().mockImplementation(() => Promise.resolve(mockStartedOrCompleted)),
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
jest.mock('../../../src/services/sync/repository-proxy', () => ({
|
|
36
|
+
__esModule: true,
|
|
37
|
+
default: repoMocks,
|
|
38
|
+
...repoMocks,
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
import { Progress } from '../../../src/services/progress'
|
|
42
|
+
import { COLLECTION_TYPE } from '../../../src/services/sync/models/ContentProgress'
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
jest.clearAllMocks()
|
|
46
|
+
mockProgressRecords = []
|
|
47
|
+
mockRecordsById = {}
|
|
48
|
+
mockLastInteracted = null
|
|
49
|
+
mockStarted = { data: [] }
|
|
50
|
+
mockCompleted = { data: [] }
|
|
51
|
+
mockCompletedByContentIds = { data: [] }
|
|
52
|
+
mockStartedOrCompleted = { data: [] }
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('Progress.state', () => {
|
|
56
|
+
test('returns state from record', async () => {
|
|
57
|
+
mockProgressRecords = [{ content_id: 100, state: 'started' }]
|
|
58
|
+
expect(await Progress.state(100)).toBe('started')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('returns empty string when record missing', async () => {
|
|
62
|
+
expect(await Progress.state(999)).toBe('')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('returns empty string when contentId is 0', async () => {
|
|
66
|
+
expect(await Progress.state(0)).toBe('')
|
|
67
|
+
expect(repoMocks.contentProgress.getOneProgressByContentId).not.toHaveBeenCalled()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('forwards collection argument', async () => {
|
|
71
|
+
const collection = { id: 5, type: COLLECTION_TYPE.LEARNING_PATH }
|
|
72
|
+
await Progress.state(100, collection)
|
|
73
|
+
expect(repoMocks.contentProgress.getOneProgressByContentId).toHaveBeenCalledWith(
|
|
74
|
+
100,
|
|
75
|
+
collection
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('Progress.stateByIds', () => {
|
|
81
|
+
test('returns Map with states for found ids and defaults for missing', async () => {
|
|
82
|
+
mockProgressRecords = [
|
|
83
|
+
{ content_id: 100, state: 'started' },
|
|
84
|
+
{ content_id: 300, state: 'completed' },
|
|
85
|
+
]
|
|
86
|
+
const result = await Progress.stateByIds([100, 300, 999])
|
|
87
|
+
expect(result.get(100)).toBe('started')
|
|
88
|
+
expect(result.get(300)).toBe('completed')
|
|
89
|
+
expect(result.get(999)).toBe('')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('returns empty Map for empty input without hitting db', async () => {
|
|
93
|
+
const result = await Progress.stateByIds([])
|
|
94
|
+
expect(result.size).toBe(0)
|
|
95
|
+
expect(repoMocks.contentProgress.getSomeProgressByContentIds).not.toHaveBeenCalled()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('forwards collection argument', async () => {
|
|
99
|
+
const collection = { id: 7, type: COLLECTION_TYPE.PLAYLIST }
|
|
100
|
+
await Progress.stateByIds([1, 2], collection)
|
|
101
|
+
expect(repoMocks.contentProgress.getSomeProgressByContentIds).toHaveBeenCalledWith(
|
|
102
|
+
[1, 2],
|
|
103
|
+
collection
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('Progress.stateByRecordIds', () => {
|
|
109
|
+
test('returns object with states keyed by record id', async () => {
|
|
110
|
+
mockRecordsById = {
|
|
111
|
+
'100:self:0': { id: '100:self:0', state: 'started' },
|
|
112
|
+
'300:self:0': { id: '300:self:0', state: 'completed' },
|
|
113
|
+
}
|
|
114
|
+
const result = await Progress.stateByRecordIds(['100:self:0', '300:self:0', '999:self:0'])
|
|
115
|
+
expect(result['100:self:0']).toBe('started')
|
|
116
|
+
expect(result['300:self:0']).toBe('completed')
|
|
117
|
+
expect(result['999:self:0']).toBe('')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('Progress.playbackPositionByIds', () => {
|
|
122
|
+
test('returns Map with resume_time_seconds for found ids and 0 default', async () => {
|
|
123
|
+
mockProgressRecords = [
|
|
124
|
+
{ content_id: 1, resume_time_seconds: 42 },
|
|
125
|
+
{ content_id: 2, resume_time_seconds: 0 },
|
|
126
|
+
]
|
|
127
|
+
const result = await Progress.playbackPositionByIds([1, 2, 3])
|
|
128
|
+
expect(result.get(1)).toBe(42)
|
|
129
|
+
expect(result.get(2)).toBe(0)
|
|
130
|
+
expect(result.get(3)).toBe(0)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('returns default 0 when field is null', async () => {
|
|
134
|
+
mockProgressRecords = [{ content_id: 1, resume_time_seconds: null }]
|
|
135
|
+
const result = await Progress.playbackPositionByIds([1])
|
|
136
|
+
expect(result.get(1)).toBe(0)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('Progress.playbackPositionByRecordIds', () => {
|
|
141
|
+
test('returns object with resume_time_seconds keyed by record id', async () => {
|
|
142
|
+
mockRecordsById = {
|
|
143
|
+
'100:self:0': { id: '100:self:0', resume_time_seconds: 120 },
|
|
144
|
+
'300:self:0': { id: '300:self:0', resume_time_seconds: 0 },
|
|
145
|
+
}
|
|
146
|
+
const result = await Progress.playbackPositionByRecordIds(['100:self:0', '300:self:0', '999:self:0'])
|
|
147
|
+
expect(result['100:self:0']).toBe(120)
|
|
148
|
+
expect(result['300:self:0']).toBe(0)
|
|
149
|
+
expect(result['999:self:0']).toBe(0)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('returns default 0 when field is null', async () => {
|
|
153
|
+
mockRecordsById = { '100:self:0': { id: '100:self:0', resume_time_seconds: null } }
|
|
154
|
+
const result = await Progress.playbackPositionByRecordIds(['100:self:0'])
|
|
155
|
+
expect(result['100:self:0']).toBe(0)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('Progress.lastInteractedOf', () => {
|
|
160
|
+
test('parses numeric string to integer', async () => {
|
|
161
|
+
mockLastInteracted = '101'
|
|
162
|
+
expect(await Progress.lastInteractedOf([100, 101])).toBe(101)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('returns undefined when repository returns null', async () => {
|
|
166
|
+
mockLastInteracted = null
|
|
167
|
+
expect(await Progress.lastInteractedOf([100, 101])).toBeUndefined()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('forwards args to repository', async () => {
|
|
171
|
+
const collection = { id: 9, type: COLLECTION_TYPE.LEARNING_PATH }
|
|
172
|
+
await Progress.lastInteractedOf([1, 2, 3], collection)
|
|
173
|
+
expect(repoMocks.contentProgress.mostRecentlyUpdatedId).toHaveBeenCalledWith(
|
|
174
|
+
[1, 2, 3],
|
|
175
|
+
collection
|
|
176
|
+
)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('Progress.incompleteLesson', () => {
|
|
181
|
+
test('guided-course returns first non-completed id', () => {
|
|
182
|
+
const states = new Map<number, string>([
|
|
183
|
+
[1, 'completed'],
|
|
184
|
+
[2, 'started'],
|
|
185
|
+
[3, ''],
|
|
186
|
+
])
|
|
187
|
+
expect(Progress.incompleteLesson(states, 'guided-course', 999)).toBe(2)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('learning-path returns first non-completed id', () => {
|
|
191
|
+
const states = new Map<number, string>([
|
|
192
|
+
[1, 'completed'],
|
|
193
|
+
[2, 'completed'],
|
|
194
|
+
[3, 'started'],
|
|
195
|
+
])
|
|
196
|
+
expect(Progress.incompleteLesson(states, COLLECTION_TYPE.LEARNING_PATH, 999)).toBe(3)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('guided-course falls back to first id when all completed', () => {
|
|
200
|
+
const states = new Map<number, string>([
|
|
201
|
+
[10, 'completed'],
|
|
202
|
+
[20, 'completed'],
|
|
203
|
+
])
|
|
204
|
+
expect(Progress.incompleteLesson(states, 'guided-course', 999)).toBe(10)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('other type: returns next non-completed after currentContentId', () => {
|
|
208
|
+
const states = new Map<number, string>([
|
|
209
|
+
[1, 'completed'],
|
|
210
|
+
[2, 'started'],
|
|
211
|
+
[3, 'completed'],
|
|
212
|
+
[4, 'started'],
|
|
213
|
+
])
|
|
214
|
+
expect(Progress.incompleteLesson(states, 'course', 2)).toBe(4)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('other type: wraps to first id when no incomplete after current', () => {
|
|
218
|
+
const states = new Map<number, string>([
|
|
219
|
+
[1, 'started'],
|
|
220
|
+
[2, 'started'],
|
|
221
|
+
[3, 'completed'],
|
|
222
|
+
])
|
|
223
|
+
expect(Progress.incompleteLesson(states, 'course', 3)).toBe(1)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('other type: scans from start when currentContentId not in map → first incomplete', () => {
|
|
227
|
+
const states = new Map<number, string>([
|
|
228
|
+
[1, 'completed'],
|
|
229
|
+
[2, 'started'],
|
|
230
|
+
])
|
|
231
|
+
expect(Progress.incompleteLesson(states, 'course', 999)).toBe(2)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('other type: scans from start when currentContentId not in map and all complete → first id', () => {
|
|
235
|
+
const states = new Map<number, string>([
|
|
236
|
+
[1, 'completed'],
|
|
237
|
+
[2, 'completed'],
|
|
238
|
+
])
|
|
239
|
+
expect(Progress.incompleteLesson(states, 'course', 999)).toBe(1)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('Progress.allStarted', () => {
|
|
244
|
+
test('delegates to repo and applies default options', async () => {
|
|
245
|
+
mockStarted = { data: [1, 2, 3] }
|
|
246
|
+
const result = await Progress.allStarted()
|
|
247
|
+
expect(result).toEqual({ data: [1, 2, 3] })
|
|
248
|
+
expect(repoMocks.contentProgress.started).toHaveBeenCalledWith(null, {
|
|
249
|
+
onlyIds: true,
|
|
250
|
+
include: { aLaCarte: true, learningPaths: false },
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('forwards custom limit and options', async () => {
|
|
255
|
+
const opts = { onlyIds: false, include: { aLaCarte: false, learningPaths: true } }
|
|
256
|
+
await Progress.allStarted(10, opts)
|
|
257
|
+
expect(repoMocks.contentProgress.started).toHaveBeenCalledWith(10, opts)
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe('Progress.allCompleted', () => {
|
|
262
|
+
test('delegates to repo with default options', async () => {
|
|
263
|
+
mockCompleted = { data: [9] }
|
|
264
|
+
const result = await Progress.allCompleted()
|
|
265
|
+
expect(result).toEqual({ data: [9] })
|
|
266
|
+
expect(repoMocks.contentProgress.completed).toHaveBeenCalledWith(null, {
|
|
267
|
+
onlyIds: true,
|
|
268
|
+
include: { aLaCarte: true, learningPaths: false },
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe('Progress.allCompletedByIds', () => {
|
|
274
|
+
test('delegates to repo with passed contentIds', async () => {
|
|
275
|
+
mockCompletedByContentIds = { data: [{ content_id: 5 }] }
|
|
276
|
+
const result = await Progress.allCompletedByIds([5, 6])
|
|
277
|
+
expect(result).toEqual({ data: [{ content_id: 5 }] })
|
|
278
|
+
expect(repoMocks.contentProgress.completedByContentIds).toHaveBeenCalledWith([5, 6])
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
describe('Progress.allStartedOrCompleted', () => {
|
|
283
|
+
test('unwraps r.data from repo response', async () => {
|
|
284
|
+
mockStartedOrCompleted = { data: [{ content_id: 1 }, { content_id: 2 }] }
|
|
285
|
+
const result = await Progress.allStartedOrCompleted()
|
|
286
|
+
expect(result).toEqual([{ content_id: 1 }, { content_id: 2 }])
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('passes limit and updatedAfter window (60 days)', async () => {
|
|
290
|
+
const before = Math.floor(Date.now() / 1000) - 60 * 24 * 60 * 60
|
|
291
|
+
await Progress.allStartedOrCompleted(25, { brand: 'drumeo' })
|
|
292
|
+
const after = Math.floor(Date.now() / 1000) - 60 * 24 * 60 * 60
|
|
293
|
+
|
|
294
|
+
const args = repoMocks.contentProgress.startedOrCompleted.mock.calls[0][0]
|
|
295
|
+
expect(args.limit).toBe(25)
|
|
296
|
+
expect(args.brand).toBe('drumeo')
|
|
297
|
+
expect(args.updatedAfter).toBeGreaterThanOrEqual(before)
|
|
298
|
+
expect(args.updatedAfter).toBeLessThanOrEqual(after)
|
|
299
|
+
})
|
|
300
|
+
})
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decorateAll,
|
|
3
|
+
decorateAllAsync,
|
|
4
|
+
decorateAsync,
|
|
5
|
+
type FieldDecorator,
|
|
6
|
+
type FieldDecoratorAsync,
|
|
7
|
+
} from '../../../src/lib/sanity/decorators/base'
|
|
8
|
+
import {
|
|
9
|
+
accessDecorator,
|
|
10
|
+
decorateAccess,
|
|
11
|
+
type AccessDecoratable,
|
|
12
|
+
} from '../../../src/lib/sanity/decorators/need-access'
|
|
13
|
+
import {
|
|
14
|
+
pageTypeDecorator,
|
|
15
|
+
decoratePageType,
|
|
16
|
+
type PageTypeDecoratable,
|
|
17
|
+
} from '../../../src/lib/sanity/decorators/page-type'
|
|
18
|
+
import {
|
|
19
|
+
decorateNavigateTo,
|
|
20
|
+
navigateToDecorator,
|
|
21
|
+
WithNavigateTo,
|
|
22
|
+
type NavigateToDecoratable,
|
|
23
|
+
} from '../../../src/lib/sanity/decorators/navigate-to'
|
|
24
|
+
import type { UserPermissions } from '../../../src/services/permissions'
|
|
25
|
+
|
|
26
|
+
interface ContentRow extends AccessDecoratable, PageTypeDecoratable {
|
|
27
|
+
id: number
|
|
28
|
+
type?: string
|
|
29
|
+
permission_id?: number[]
|
|
30
|
+
children?: ContentRow[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const perms: UserPermissions = {
|
|
34
|
+
permissions: [78, 91],
|
|
35
|
+
isAdmin: false,
|
|
36
|
+
isModerator: false,
|
|
37
|
+
isABasicMember: true,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const rows: ContentRow[] = [
|
|
41
|
+
{
|
|
42
|
+
id: 1,
|
|
43
|
+
type: 'course',
|
|
44
|
+
permission_id: [78],
|
|
45
|
+
children: [
|
|
46
|
+
{ id: 2, type: 'song', permission_id: [91] },
|
|
47
|
+
{ id: 3, type: 'play-along' },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
export function singleDecoratorViaWrapper() {
|
|
53
|
+
const decorated = decorateAccess(rows, perms)
|
|
54
|
+
decorated[0].need_access satisfies boolean
|
|
55
|
+
return decorated
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function chainedWrappers() {
|
|
59
|
+
const withAccess = decorateAccess(rows, perms)
|
|
60
|
+
const withBoth = decoratePageType(withAccess)
|
|
61
|
+
withBoth[0].need_access satisfies boolean
|
|
62
|
+
withBoth[0].page_type satisfies 'song' | 'lesson'
|
|
63
|
+
return withBoth
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function composedSingleWalk() {
|
|
67
|
+
type Composed = AccessDecoratable & PageTypeDecoratable
|
|
68
|
+
const decorators: FieldDecorator<Composed>[] = [
|
|
69
|
+
accessDecorator(perms) as FieldDecorator<Composed>,
|
|
70
|
+
pageTypeDecorator as FieldDecorator<Composed>,
|
|
71
|
+
]
|
|
72
|
+
return decorateAll(rows, decorators)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function conditionalComposition(opts: { withAccess: boolean; withPageType: boolean }) {
|
|
76
|
+
type Composed = AccessDecoratable & PageTypeDecoratable
|
|
77
|
+
const decorators: FieldDecorator<Composed>[] = []
|
|
78
|
+
if (opts.withAccess) {
|
|
79
|
+
decorators.push(accessDecorator(perms) as FieldDecorator<Composed>)
|
|
80
|
+
}
|
|
81
|
+
if (opts.withPageType) {
|
|
82
|
+
decorators.push(pageTypeDecorator as FieldDecorator<Composed>)
|
|
83
|
+
}
|
|
84
|
+
return decorateAll(rows, decorators)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface ProgressDecoratable extends AccessDecoratable {
|
|
88
|
+
progress_percent?: number
|
|
89
|
+
is_liked?: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function fetchProgress(id: number): Promise<number> {
|
|
93
|
+
return id * 10
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function fetchLiked(id: number): Promise<boolean> {
|
|
97
|
+
return id % 2 === 0
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function singleAsyncDecorator() {
|
|
101
|
+
const decorated = (await decorateAsync(rows, 'progress_percent', (item) =>
|
|
102
|
+
fetchProgress(item.id as number)
|
|
103
|
+
)) as ProgressDecoratable[]
|
|
104
|
+
decorated[0].progress_percent satisfies number | undefined
|
|
105
|
+
return decorated
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function parallelAsyncDecorators() {
|
|
109
|
+
const decorators: FieldDecoratorAsync<ProgressDecoratable>[] = [
|
|
110
|
+
{
|
|
111
|
+
field: 'progress_percent',
|
|
112
|
+
compute: (item) => fetchProgress(item.id as number),
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
field: 'is_liked',
|
|
116
|
+
compute: (item) => fetchLiked(item.id as number),
|
|
117
|
+
},
|
|
118
|
+
]
|
|
119
|
+
const decorated = (await decorateAllAsync(
|
|
120
|
+
rows as ProgressDecoratable[],
|
|
121
|
+
decorators
|
|
122
|
+
)) as ProgressDecoratable[]
|
|
123
|
+
decorated[0].progress_percent satisfies number | undefined
|
|
124
|
+
decorated[0].is_liked satisfies boolean | undefined
|
|
125
|
+
return decorated
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function mixedSyncThenAsync() {
|
|
129
|
+
const withAccess = decorateAll(rows, [
|
|
130
|
+
accessDecorator(perms) as FieldDecorator<ProgressDecoratable>,
|
|
131
|
+
]) as ProgressDecoratable[]
|
|
132
|
+
return decorateAllAsync(withAccess, [
|
|
133
|
+
{
|
|
134
|
+
field: 'progress_percent',
|
|
135
|
+
compute: (item) => fetchProgress(item.id as number),
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
field: 'is_liked',
|
|
139
|
+
compute: (item) => fetchLiked(item.id as number),
|
|
140
|
+
},
|
|
141
|
+
])
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const courseLesson = (id: number): NavigateToDecoratable => ({
|
|
145
|
+
id,
|
|
146
|
+
type: 'course-lesson',
|
|
147
|
+
brand: 'drumeo',
|
|
148
|
+
thumbnail: '',
|
|
149
|
+
published_on: null,
|
|
150
|
+
status: 'published',
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const navigateRows: NavigateToDecoratable[] = [
|
|
154
|
+
{
|
|
155
|
+
id: 1,
|
|
156
|
+
type: 'course',
|
|
157
|
+
brand: 'drumeo',
|
|
158
|
+
thumbnail: '',
|
|
159
|
+
published_on: null,
|
|
160
|
+
status: 'published',
|
|
161
|
+
children: [courseLesson(101), courseLesson(102), courseLesson(103)],
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: 2,
|
|
165
|
+
type: 'course-collection',
|
|
166
|
+
brand: 'drumeo',
|
|
167
|
+
thumbnail: '',
|
|
168
|
+
published_on: null,
|
|
169
|
+
status: 'published',
|
|
170
|
+
children: [
|
|
171
|
+
{
|
|
172
|
+
id: 201,
|
|
173
|
+
type: 'course',
|
|
174
|
+
brand: 'drumeo',
|
|
175
|
+
thumbnail: '',
|
|
176
|
+
published_on: null,
|
|
177
|
+
status: 'published',
|
|
178
|
+
children: [courseLesson(301), courseLesson(302)],
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
export async function singleAsyncNavigateTo() {
|
|
185
|
+
const decorated = await decorateNavigateTo(navigateRows)
|
|
186
|
+
void decorated[0].navigateTo
|
|
187
|
+
void decorated[1].navigateTo?.child
|
|
188
|
+
return decorated
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function navigateToOnSingleItem() {
|
|
192
|
+
const decorated = await decorateNavigateTo(navigateRows[0])
|
|
193
|
+
void decorated.navigateTo
|
|
194
|
+
return decorated
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function navigateToComposedWithAccess() {
|
|
198
|
+
interface ContentWithNav extends NavigateToDecoratable, AccessDecoratable {
|
|
199
|
+
permission_id?: number[]
|
|
200
|
+
children?: ContentWithNav[]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const items: ContentWithNav[] = navigateRows.map((row) => ({
|
|
204
|
+
...row,
|
|
205
|
+
permission_id: [78],
|
|
206
|
+
}))
|
|
207
|
+
|
|
208
|
+
const withAccess = decorateAccess(items, perms)
|
|
209
|
+
const withBoth = await decorateNavigateTo(withAccess)
|
|
210
|
+
|
|
211
|
+
withBoth[0].need_access satisfies boolean
|
|
212
|
+
void withBoth[0].navigateTo
|
|
213
|
+
return withBoth
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function navigateToParallelWithProgress() {
|
|
217
|
+
interface ContentWithNavAndProgress extends NavigateToDecoratable {
|
|
218
|
+
progress_percent?: number
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const items = navigateRows as ContentWithNavAndProgress[]
|
|
222
|
+
const decorators: FieldDecoratorAsync<ContentWithNavAndProgress>[] = [
|
|
223
|
+
navigateToDecorator as FieldDecoratorAsync<ContentWithNavAndProgress>,
|
|
224
|
+
{
|
|
225
|
+
field: 'progress_percent',
|
|
226
|
+
compute: (item) => fetchProgress(item.id),
|
|
227
|
+
},
|
|
228
|
+
]
|
|
229
|
+
const decorated = (await decorateAllAsync(
|
|
230
|
+
items,
|
|
231
|
+
decorators
|
|
232
|
+
)) as WithNavigateTo<ContentWithNavAndProgress>[]
|
|
233
|
+
|
|
234
|
+
void decorated[0].navigateTo
|
|
235
|
+
decorated[0].progress_percent satisfies number | undefined
|
|
236
|
+
return decorated
|
|
237
|
+
}
|