1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 05:47:05 +09:00

DROID-933 Widgets | Enhancement | React to changes in order and number of widgets (#2887)

This commit is contained in:
Evgenii Kozlov 2023-02-03 14:22:52 +01:00 committed by GitHub
parent 1abdf1a3b0
commit 779b10cc2e
Signed by: github
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 510 additions and 34 deletions

View file

@ -2,6 +2,7 @@ package com.anytypeio.anytype.di.feature.home
import androidx.lifecycle.ViewModelProvider
import com.anytypeio.anytype.analytics.base.Analytics
import com.anytypeio.anytype.core_models.Payload
import com.anytypeio.anytype.core_utils.di.scope.PerScreen
import com.anytypeio.anytype.di.common.ComponentDependencies
import com.anytypeio.anytype.di.feature.widgets.SelectWidgetSourceSubcomponent
@ -9,6 +10,8 @@ import com.anytypeio.anytype.domain.auth.repo.AuthRepository
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.domain.config.ConfigStorage
import com.anytypeio.anytype.domain.event.interactor.EventChannel
import com.anytypeio.anytype.domain.event.interactor.InterceptEvents
import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.`object`.OpenObject
import com.anytypeio.anytype.domain.objects.ObjectStore
@ -102,6 +105,19 @@ object HomeScreenModule {
@PerScreen
fun widgetEventDispatcher() : Dispatcher<WidgetDispatchEvent> = Dispatcher.Default()
@JvmStatic
@Provides
@PerScreen
fun objectPayloadDispatcher() : Dispatcher<Payload> = Dispatcher.Default()
@JvmStatic
@Provides
@PerScreen
fun interceptEvents(channel: EventChannel) : InterceptEvents = InterceptEvents(
context = Dispatchers.IO,
channel = channel
)
@Module
interface Declarations {
@PerScreen
@ -119,4 +135,5 @@ interface HomeScreenDependencies : ComponentDependencies {
fun subscriptionEventChannel(): SubscriptionEventChannel
fun workspaceManager(): WorkspaceManager
fun analytics(): Analytics
fun eventChannel() : EventChannel
}

View file

@ -15,6 +15,7 @@ import androidx.transition.ChangeBounds
import androidx.transition.TransitionManager
import androidx.transition.TransitionSet
import androidx.viewpager2.widget.ViewPager2
import com.anytypeio.anytype.BuildConfig
import com.anytypeio.anytype.R
import com.anytypeio.anytype.core_models.ObjectWrapper
import com.anytypeio.anytype.core_ui.reactive.click
@ -35,9 +36,9 @@ import com.anytypeio.anytype.ui.base.NavigationFragment
import com.anytypeio.anytype.ui.editor.EditorFragment
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import javax.inject.Inject
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class DashboardFragment :
NavigationFragment<FragmentDashboardBinding>(R.layout.fragment_dashboard) {
@ -317,6 +318,16 @@ class DashboardFragment :
}
}
}
if (BuildConfig.DEBUG) {
// For debugging and testing widgets
binding.widgets.visible()
binding.widgets.setOnClickListener {
findNavController().navigate(
R.id.homeScreen
)
}
}
}
private fun animateSelectionHiding() {

View file

@ -6,6 +6,21 @@
android:layout_height="match_parent"
app:layoutDescription="@xml/fragment_dashboard_scene">
<TextView
android:id="@+id/widgets"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:visibility="visible"
android:text="[Go to Widgets]"
android:stateListAnimator="@animator/scale_shrink"
android:textSize="10sp"
android:textColor="@color/white"
android:fontFamily="monospace"
app:layout_constraintBottom_toBottomOf="@+id/ivSettings"
app:layout_constraintEnd_toStartOf="@+id/ivSettings"
app:layout_constraintTop_toTopOf="@+id/ivSettings" />
<TextView
android:id="@+id/tvGreeting"
android:layout_width="0dp"
@ -214,12 +229,12 @@
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/default_ripple"
android:fontFamily="@font/inter_regular"
android:gravity="center"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:text="@string/fragment_dashboard_restore"
android:background="@drawable/default_ripple"
android:textColor="@color/text_primary"
android:textSize="17sp" />

View file

@ -69,6 +69,16 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:visibilityMode="ignore" />
<Constraint
android:id="@+id/widgets"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="Widgets"
app:visibilityMode="ignore"
app:layout_constraintBottom_toBottomOf="@+id/ivSettings"
app:layout_constraintEnd_toStartOf="@+id/ivSettings"
app:layout_constraintTop_toTopOf="@+id/ivSettings"/>
</ConstraintSet>
<ConstraintSet android:id="@+id/end">
@ -138,6 +148,16 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:visibilityMode="ignore" />
<Constraint
android:id="@+id/widgets"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="Widgets"
app:visibilityMode="ignore"
app:layout_constraintBottom_toBottomOf="@+id/ivSettings"
app:layout_constraintEnd_toStartOf="@+id/ivSettings"
app:layout_constraintTop_toTopOf="@+id/ivSettings"/>
</ConstraintSet>
<Transition

View file

@ -197,7 +197,8 @@ data class Block(
val iconSize: IconSize,
val cardStyle: CardStyle,
val description: Description,
val relations: Set<Relation>,
@Deprecated("To be removed")
val relations: Set<Relation> = emptySet(),
) : Content() {
sealed interface Relation {
object COVER : Relation

View file

@ -3,11 +3,16 @@ package com.anytypeio.anytype.presentation.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.anytypeio.anytype.core_models.Event
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.ObjectView
import com.anytypeio.anytype.core_models.Payload
import com.anytypeio.anytype.core_utils.ext.replace
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.base.Resultat
import com.anytypeio.anytype.domain.config.ConfigStorage
import com.anytypeio.anytype.domain.event.interactor.InterceptEvents
import com.anytypeio.anytype.domain.misc.Reducer
import com.anytypeio.anytype.domain.`object`.OpenObject
import com.anytypeio.anytype.domain.search.ObjectSearchSubscriptionContainer
import com.anytypeio.anytype.domain.widgets.CreateWidget
@ -21,12 +26,14 @@ import com.anytypeio.anytype.presentation.widgets.WidgetDispatchEvent
import com.anytypeio.anytype.presentation.widgets.WidgetView
import com.anytypeio.anytype.presentation.widgets.parseWidgets
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.launch
import timber.log.Timber
@ -36,24 +43,61 @@ class HomeScreenViewModel(
private val createWidget: CreateWidget,
private val objectSearchSubscriptionContainer: ObjectSearchSubscriptionContainer,
private val appCoroutineDispatchers: AppCoroutineDispatchers,
private val dispatcher: Dispatcher<WidgetDispatchEvent>
) : BaseViewModel() {
private val widgetEventDispatcher: Dispatcher<WidgetDispatchEvent>,
private val objectPayloadDispatcher: Dispatcher<Payload>,
private val interceptEvents: InterceptEvents
) : BaseViewModel(), Reducer<ObjectView, Payload> {
val obj = MutableSharedFlow<ObjectView>()
val views = MutableStateFlow<List<WidgetView>>(emptyList())
private val objectViewState = MutableStateFlow<ObjectViewState>(ObjectViewState.Idle)
private val widgets = MutableStateFlow<List<Widget>>(emptyList())
private val containers = MutableStateFlow<List<TreeWidgetContainer>>(emptyList())
private val expanded = TreeWidgetBranchStateHolder()
init {
val config = configStorage.get()
val externalChannelEvents = interceptEvents.build(InterceptEvents.Params(config.widgets)).map {
Payload(
context = config.widgets,
events = it
)
}
val internalChannelEvents = objectPayloadDispatcher.flow()
val payloads = merge(externalChannelEvents, internalChannelEvents)
viewModelScope.launch {
obj.map { o ->
o.blocks.parseWidgets(
root = o.root,
details = o.details
)
objectViewState.flatMapLatest { state ->
when (state) {
is ObjectViewState.Idle -> flowOf(state)
is ObjectViewState.Failure -> flowOf(state)
is ObjectViewState.Loading -> flowOf(state)
is ObjectViewState.Success -> {
payloads.scan(state) { s, p -> s.copy(obj = reduce(s.obj, p)) }
}
}
}.map { state ->
when (state) {
is ObjectViewState.Failure -> {
emptyList()
}
is ObjectViewState.Idle -> {
emptyList()
}
is ObjectViewState.Loading -> {
emptyList()
}
is ObjectViewState.Success -> {
state.obj.blocks.parseWidgets(
root = state.obj.root,
details = state.obj.details
)
}
}
}.collect {
widgets.value = it
}
@ -87,32 +131,29 @@ class HomeScreenViewModel(
}
}.flowOn(appCoroutineDispatchers.io).collect {
Timber.d("Views update: $it")
views.value = it + listOf(
WidgetView.Action.EditWidgets,
WidgetView.Action.CreateWidget,
WidgetView.Action.Refresh
)
views.value = it + actions
}
}
proceedWithOpeningObject()
proceedWithOpeningWidgetObject(widgetObject = config.widgets)
proceedWithDispatches()
}
private fun proceedWithOpeningObject() {
private fun proceedWithOpeningWidgetObject(widgetObject: Id) {
viewModelScope.launch {
val config = configStorage.get()
openObject(config.widgets).flowOn(appCoroutineDispatchers.io).collect { result ->
openObject(widgetObject).flowOn(appCoroutineDispatchers.io).collect { result ->
when (result) {
is Resultat.Failure -> {
objectViewState.value = ObjectViewState.Failure(result.exception)
Timber.e(result.exception, "Error while opening object.")
}
is Resultat.Loading -> {
// Do nothing.
objectViewState.value = ObjectViewState.Loading
}
is Resultat.Success -> {
Timber.d("Object view on start:\n${result.value}")
obj.emit(result.value)
objectViewState.value = ObjectViewState.Success(
obj = result.value
)
}
}
}
@ -121,7 +162,7 @@ class HomeScreenViewModel(
private fun proceedWithDispatches() {
viewModelScope.launch {
dispatcher.flow().collect { dispatch ->
widgetEventDispatcher.flow().collect { dispatch ->
when (dispatch) {
is WidgetDispatchEvent.SourcePicked -> {
proceedWithCreatingWidget(source = dispatch.source)
@ -139,15 +180,27 @@ class HomeScreenViewModel(
ctx = config.widgets,
source = source
)
).collect { s ->
Timber.d("Status while creating widget: $s")
).collect { status ->
Timber.d("Status while creating widget: $status")
when (status) {
is Resultat.Failure -> {
sendToast("Error while creating widget: ${status.exception}")
Timber.e(status.exception, "Error while creating widget")
}
is Resultat.Loading -> {
// Do nothing?
}
is Resultat.Success -> {
objectPayloadDispatcher.send(status.value)
}
}
}
}
}
@Deprecated("For debugging only")
fun onRefresh() {
proceedWithOpeningObject()
proceedWithOpeningWidgetObject(widgetObject = configStorage.get().widgets)
}
fun onStart() {
@ -158,13 +211,43 @@ class HomeScreenViewModel(
expanded.onExpand(linkPath = path)
}
// TODO move to a separate reducer inject into this VM's constructor
override fun reduce(state: ObjectView, event: Payload): ObjectView {
var curr = state
event.events.forEach { e ->
when (e) {
is Event.Command.AddBlock -> {
curr = curr.copy(
blocks = curr.blocks + e.blocks
)
}
is Event.Command.UpdateStructure -> {
curr = curr.copy(
blocks = curr.blocks.replace(
replacement = { target ->
target.copy(children = e.children)
},
target = { block -> block.id == e.id }
)
)
}
else -> {
Timber.d("Skipping event: $e")
}
}
}
return curr
}
class Factory @Inject constructor(
private val configStorage: ConfigStorage,
private val openObject: OpenObject,
private val createWidget: CreateWidget,
private val objectSearchSubscriptionContainer: ObjectSearchSubscriptionContainer,
private val appCoroutineDispatchers: AppCoroutineDispatchers,
private val dispatcher: Dispatcher<WidgetDispatchEvent>
private val widgetEventDispatcher: Dispatcher<WidgetDispatchEvent>,
private val objectPayloadDispatcher: Dispatcher<Payload>,
private val interceptEvents: InterceptEvents
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = HomeScreenViewModel(
@ -173,7 +256,27 @@ class HomeScreenViewModel(
createWidget = createWidget,
objectSearchSubscriptionContainer = objectSearchSubscriptionContainer,
appCoroutineDispatchers = appCoroutineDispatchers,
dispatcher = dispatcher
widgetEventDispatcher = widgetEventDispatcher,
objectPayloadDispatcher = objectPayloadDispatcher,
interceptEvents = interceptEvents
) as T
}
companion object {
val actions = listOf(
WidgetView.Action.EditWidgets,
WidgetView.Action.CreateWidget,
WidgetView.Action.Refresh
)
}
}
/**
* State representing session while working with an object.
*/
sealed class ObjectViewState {
object Idle : ObjectViewState()
object Loading : ObjectViewState()
data class Success(val obj: ObjectView) : ObjectViewState()
data class Failure(val e: Throwable) : ObjectViewState()
}

View file

@ -1,5 +1,256 @@
package com.anytypeio.anytype.presentation.home
import app.cash.turbine.test
import com.anytypeio.anytype.core_models.Block
import com.anytypeio.anytype.core_models.Event
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.Key
import com.anytypeio.anytype.core_models.ObjectView
import com.anytypeio.anytype.core_models.Payload
import com.anytypeio.anytype.core_models.SmartBlockType
import com.anytypeio.anytype.core_models.StubConfig
import com.anytypeio.anytype.core_models.StubLinkToObjectBlock
import com.anytypeio.anytype.core_models.StubObject
import com.anytypeio.anytype.core_models.StubObjectView
import com.anytypeio.anytype.core_models.StubSmartBlock
import com.anytypeio.anytype.core_models.StubWidgetBlock
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.base.Resultat
import com.anytypeio.anytype.domain.config.ConfigStorage
import com.anytypeio.anytype.domain.event.interactor.InterceptEvents
import com.anytypeio.anytype.domain.`object`.OpenObject
import com.anytypeio.anytype.domain.search.ObjectSearchSubscriptionContainer
import com.anytypeio.anytype.domain.widgets.CreateWidget
import com.anytypeio.anytype.presentation.util.DefaultCoroutineTestRule
import com.anytypeio.anytype.presentation.util.Dispatcher
import com.anytypeio.anytype.presentation.widgets.TreeWidgetContainer
import com.anytypeio.anytype.presentation.widgets.WidgetDispatchEvent
import com.anytypeio.anytype.presentation.widgets.WidgetView
import com.anytypeio.anytype.test_utils.MockDataFactory
import kotlin.test.assertEquals
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.stub
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
class HomeViewModelTest {
// TODO
@get:Rule
val coroutineTestRule = DefaultCoroutineTestRule()
@Mock
lateinit var configStorage: ConfigStorage
@Mock
lateinit var createWidget: CreateWidget
@Mock
lateinit var interceptEvents: InterceptEvents
@Mock
lateinit var openObject: OpenObject
@Mock
lateinit var objectSearchSubscriptionContainer: ObjectSearchSubscriptionContainer
private val objectPayloadDispatcher = Dispatcher.Default<Payload>()
private val widgetEventDispatcher = Dispatcher.Default<WidgetDispatchEvent>()
lateinit var vm: HomeScreenViewModel
private val config = StubConfig(
widgets = WIDGET_OBJECT_ID
)
private val appCoroutineDispatchers = AppCoroutineDispatchers(
io = coroutineTestRule.dispatcher,
main = coroutineTestRule.dispatcher,
computation = coroutineTestRule.dispatcher
)
@Before
fun setup() {
MockitoAnnotations.openMocks(this)
}
@Test
fun `should emit empty list if there is no block`() = runTest {
// SETUP
val smartBlock = StubSmartBlock(
id = WIDGET_OBJECT_ID,
children = emptyList(),
type = SmartBlockType.WIDGET
)
val givenObjectView = StubObjectView(
root = WIDGET_OBJECT_ID,
type = SmartBlockType.WIDGET,
blocks = listOf(smartBlock)
)
val events : Flow<List<Event>> = emptyFlow()
stubConfig()
stubInterceptEvents(events)
stubOpenObject(givenObjectView)
val vm = buildViewModel()
// TESTING
vm.views.test {
val firstTimeState = awaitItem()
assertEquals(
actual = firstTimeState,
expected = emptyList()
)
}
delay(1)
verify(openObject, times(1)).invoke(WIDGET_OBJECT_ID)
}
@Test
fun `should emit tree-widget with empty elements when source has no links`() = runTest {
// SETUP
val sourceObject = StubObject(
id = "SOURCE OBJECT",
links = emptyList()
)
val sourceLink = StubLinkToObjectBlock(
id = "SOURCE LINK",
target = sourceObject.id
)
val widgetBlock = StubWidgetBlock(
id = "WIDGET BLOCK",
layout = Block.Content.Widget.Layout.TREE,
children = listOf(sourceLink.id)
)
val smartBlock = StubSmartBlock(
id = WIDGET_OBJECT_ID,
children = listOf(widgetBlock.id),
type = SmartBlockType.WIDGET
)
val givenObjectView = StubObjectView(
root = WIDGET_OBJECT_ID,
type = SmartBlockType.WIDGET,
blocks = listOf(
smartBlock,
widgetBlock,
sourceLink
),
details = mapOf(
sourceObject.id to sourceObject.map
)
)
stubConfig()
stubInterceptEvents(events = emptyFlow())
stubOpenObject(givenObjectView)
stubObjectSearchContainer(
subscription = widgetBlock.id,
targets = emptyList()
)
val vm = buildViewModel()
// TESTING
vm.views.test {
val firstTimeState = awaitItem()
assertEquals(
actual = firstTimeState,
expected = emptyList()
)
val secondTimeItem = awaitItem()
assertEquals(
expected = buildList {
add(
WidgetView.Tree(
id = widgetBlock.id,
obj = sourceObject,
elements = emptyList()
)
)
addAll(HomeScreenViewModel.actions)
},
actual = secondTimeItem
)
verify(openObject, times(1)).invoke(WIDGET_OBJECT_ID)
}
}
private fun stubInterceptEvents(events: Flow<List<Event>>) {
interceptEvents.stub {
on { build(InterceptEvents.Params(WIDGET_OBJECT_ID)) } doReturn events
}
}
private fun stubConfig() {
configStorage.stub {
on { get() } doReturn config
}
}
private fun stubOpenObject(givenObjectView: ObjectView) {
openObject.stub {
on {
invoke(WIDGET_OBJECT_ID)
} doReturn flowOf(
Resultat.Success(
value = givenObjectView
)
)
}
}
private fun stubObjectSearchContainer(
subscription: Id,
targets: List<Id>,
keys: List<Key> = TreeWidgetContainer.keys
) {
objectSearchSubscriptionContainer.stub {
onBlocking {
get(
subscription = subscription,
keys = keys,
targets = targets
)
} doReturn emptyList()
}
}
private fun buildViewModel() = HomeScreenViewModel(
configStorage = configStorage,
interceptEvents = interceptEvents,
createWidget = createWidget,
objectPayloadDispatcher = objectPayloadDispatcher,
widgetEventDispatcher = widgetEventDispatcher,
openObject = openObject,
objectSearchSubscriptionContainer = objectSearchSubscriptionContainer,
appCoroutineDispatchers = appCoroutineDispatchers
)
companion object {
val WIDGET_OBJECT_ID: Id = MockDataFactory.randomUuid()
}
}

View file

@ -11,7 +11,7 @@ import org.junit.runner.Description
@ExperimentalCoroutinesApi
class DefaultCoroutineTestRule() : TestWatcher() {
private val dispatcher = StandardTestDispatcher(name = "Default test dispatcher")
val dispatcher = StandardTestDispatcher(name = "Default test dispatcher")
override fun starting(description: Description) {
super.starting(description)

View file

@ -234,12 +234,13 @@ fun StubBookmark(
fun StubSmartBlock(
id: Id = MockDataFactory.randomString(),
children: List<Id> = emptyList()
children: List<Id> = emptyList(),
type: SmartBlockType = SmartBlockType.PAGE
): Block = Block(
id = id,
children = children,
fields = Block.Fields.empty(),
content = Block.Content.Smart()
content = Block.Content.Smart(type = type)
)
fun StubTable(
@ -337,4 +338,41 @@ fun StubDataViewBlock(
children = children,
fields = fields,
backgroundColor = backgroundColor
)
fun StubLinkToObjectBlock(
id: Id = MockDataFactory.randomUuid(),
target: Id,
children: List<Id> = emptyList(),
fields: Block.Fields = Block.Fields.empty(),
backgroundColor: String? = null,
) : Block = Block(
id = id,
content = Block.Content.Link(
target = target,
description = Block.Content.Link.Description.values().random(),
cardStyle = Block.Content.Link.CardStyle.values().random(),
iconSize = Block.Content.Link.IconSize.values().random(),
relations = emptySet(),
type = Block.Content.Link.Type.PAGE
),
children = children,
fields = fields,
backgroundColor = backgroundColor
)
fun StubWidgetBlock(
id: Id = MockDataFactory.randomUuid(),
layout: Block.Content.Widget.Layout,
children: List<Id> = emptyList(),
fields: Block.Fields = Block.Fields.empty(),
backgroundColor: String? = null,
) : Block = Block(
id = id,
children = children,
fields = fields,
backgroundColor = backgroundColor,
content = Block.Content.Widget(
layout = layout
)
)

View file

@ -1,5 +1,7 @@
package com.anytypeio.anytype.core_models
import com.anytypeio.anytype.core_models.restrictions.DataViewRestrictions
import com.anytypeio.anytype.core_models.restrictions.ObjectRestriction
import com.anytypeio.anytype.test_utils.MockDataFactory
fun StubObject(
@ -32,6 +34,24 @@ fun StubObject(
)
)
fun StubObjectView(
root: Id,
type: SmartBlockType,
blocks: List<Block> = emptyList(),
details: Map<Id, Struct> = emptyMap(),
relations: List<RelationLink> = emptyList(),
objectRestrictions: List<ObjectRestriction> = emptyList(),
dataViewRestrictions: List<DataViewRestrictions> = emptyList()
): ObjectView = ObjectView(
root = root,
blocks = blocks,
details = details,
type = type,
relations = relations,
objectRestrictions = objectRestrictions,
dataViewRestrictions = dataViewRestrictions
)
fun StubObjectType(
id: String = MockDataFactory.randomUuid(),
name: String = MockDataFactory.randomString(),