mirror of
https://github.com/anyproto/anytype-kotlin.git
synced 2025-06-08 05:47:05 +09:00
Objects | Feature | Navigate to set of this type or create a new set (#1880)
This commit is contained in:
parent
6af7da4881
commit
d5ee41c7a5
27 changed files with 354 additions and 85 deletions
|
@ -13,6 +13,7 @@ import com.anytypeio.anytype.domain.base.Either
|
|||
import com.anytypeio.anytype.domain.base.Result
|
||||
import com.anytypeio.anytype.domain.block.UpdateDivider
|
||||
import com.anytypeio.anytype.domain.block.interactor.*
|
||||
import com.anytypeio.anytype.domain.block.interactor.sets.CreateObjectSet
|
||||
import com.anytypeio.anytype.domain.block.repo.BlockRepository
|
||||
import com.anytypeio.anytype.domain.clipboard.Clipboard
|
||||
import com.anytypeio.anytype.domain.clipboard.Copy
|
||||
|
@ -34,6 +35,7 @@ import com.anytypeio.anytype.domain.objects.SetObjectIsArchived
|
|||
import com.anytypeio.anytype.domain.page.*
|
||||
import com.anytypeio.anytype.domain.page.bookmark.SetupBookmark
|
||||
import com.anytypeio.anytype.domain.page.navigation.GetListPages
|
||||
import com.anytypeio.anytype.domain.sets.FindObjectSetForType
|
||||
import com.anytypeio.anytype.domain.status.InterceptThreadStatus
|
||||
import com.anytypeio.anytype.domain.status.ThreadStatusChannel
|
||||
import com.anytypeio.anytype.mocking.MockDataFactory
|
||||
|
@ -129,6 +131,9 @@ open class EditorTestSetup {
|
|||
|
||||
lateinit var getDefaultEditorType: GetDefaultEditorType
|
||||
|
||||
private lateinit var findObjectSetForType: FindObjectSetForType
|
||||
private lateinit var createObjectSet: CreateObjectSet
|
||||
|
||||
@Mock
|
||||
lateinit var updateDivider: UpdateDivider
|
||||
|
||||
|
@ -227,6 +232,8 @@ open class EditorTestSetup {
|
|||
updateDetail = UpdateDetail(repo)
|
||||
getCompatibleObjectTypes = GetCompatibleObjectTypes(repo)
|
||||
getDefaultEditorType = GetDefaultEditorType(userSettingsRepository)
|
||||
createObjectSet = CreateObjectSet(repo)
|
||||
findObjectSetForType = FindObjectSetForType(repo)
|
||||
|
||||
TestEditorFragment.testViewModelFactory = EditorViewModelFactory(
|
||||
openPage = openPage,
|
||||
|
@ -296,7 +303,9 @@ open class EditorTestSetup {
|
|||
getCompatibleObjectTypes = getCompatibleObjectTypes,
|
||||
objectTypesProvider = objectTypesProvider,
|
||||
searchObjects = getSearchObjects,
|
||||
getDefaultEditorType = getDefaultEditorType
|
||||
getDefaultEditorType = getDefaultEditorType,
|
||||
createObjectSet = createObjectSet,
|
||||
findObjectSetForType = findObjectSetForType
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import com.anytypeio.anytype.R
|
|||
import com.anytypeio.anytype.analytics.base.Analytics
|
||||
import com.anytypeio.anytype.core_models.*
|
||||
import com.anytypeio.anytype.domain.auth.repo.AuthRepository
|
||||
import com.anytypeio.anytype.domain.base.Result
|
||||
import com.anytypeio.anytype.domain.block.interactor.UpdateText
|
||||
import com.anytypeio.anytype.domain.block.repo.BlockRepository
|
||||
import com.anytypeio.anytype.domain.config.Gateway
|
||||
|
@ -157,15 +158,17 @@ abstract class TestObjectSetSetup {
|
|||
relations: List<Relation> = emptyList()
|
||||
) {
|
||||
repo.stub {
|
||||
onBlocking { openObjectSet(ctx) } doReturn Payload(
|
||||
context = ctx,
|
||||
events = listOf(
|
||||
Event.Command.ShowObject(
|
||||
context = ctx,
|
||||
root = ctx,
|
||||
details = details,
|
||||
blocks = set,
|
||||
relations = relations
|
||||
onBlocking { openObjectSet(ctx) } doReturn Result.Success(
|
||||
Payload(
|
||||
context = ctx,
|
||||
events = listOf(
|
||||
Event.Command.ShowObject(
|
||||
context = ctx,
|
||||
root = ctx,
|
||||
details = details,
|
||||
blocks = set,
|
||||
relations = relations
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@ -183,23 +186,25 @@ abstract class TestObjectSetSetup {
|
|||
objectTypes: List<ObjectType>
|
||||
) {
|
||||
repo.stub {
|
||||
onBlocking { openObjectSet(ctx) } doReturn Payload(
|
||||
context = ctx,
|
||||
events = listOf(
|
||||
Event.Command.ShowObject(
|
||||
context = ctx,
|
||||
root = ctx,
|
||||
details = details,
|
||||
blocks = set,
|
||||
relations = relations,
|
||||
objectTypes = objectTypes
|
||||
),
|
||||
Event.Command.DataView.SetRecords(
|
||||
context = ctx,
|
||||
id = dataview,
|
||||
view = viewer,
|
||||
total = total,
|
||||
records = records
|
||||
onBlocking { openObjectSet(ctx) } doReturn Result.Success(
|
||||
Payload(
|
||||
context = ctx,
|
||||
events = listOf(
|
||||
Event.Command.ShowObject(
|
||||
context = ctx,
|
||||
root = ctx,
|
||||
details = details,
|
||||
blocks = set,
|
||||
relations = relations,
|
||||
objectTypes = objectTypes
|
||||
),
|
||||
Event.Command.DataView.SetRecords(
|
||||
context = ctx,
|
||||
id = dataview,
|
||||
view = viewer,
|
||||
total = total,
|
||||
records = records
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@ -207,7 +212,7 @@ abstract class TestObjectSetSetup {
|
|||
}
|
||||
|
||||
fun launchFragment(args: Bundle): FragmentScenario<TestObjectSetFragment> {
|
||||
return launchFragmentInContainer<TestObjectSetFragment>(
|
||||
return launchFragmentInContainer(
|
||||
fragmentArgs = args,
|
||||
themeResId = R.style.AppTheme
|
||||
)
|
||||
|
|
|
@ -15,6 +15,7 @@ import com.anytypeio.anytype.domain.`object`.UpdateDetail
|
|||
import com.anytypeio.anytype.domain.auth.repo.AuthRepository
|
||||
import com.anytypeio.anytype.domain.block.UpdateDivider
|
||||
import com.anytypeio.anytype.domain.block.interactor.*
|
||||
import com.anytypeio.anytype.domain.block.interactor.sets.CreateObjectSet
|
||||
import com.anytypeio.anytype.domain.block.interactor.sets.GetObjectTypes
|
||||
import com.anytypeio.anytype.domain.block.repo.BlockRepository
|
||||
import com.anytypeio.anytype.domain.clipboard.Clipboard
|
||||
|
@ -35,6 +36,7 @@ import com.anytypeio.anytype.domain.objects.SetObjectIsArchived
|
|||
import com.anytypeio.anytype.domain.page.*
|
||||
import com.anytypeio.anytype.domain.page.bookmark.SetupBookmark
|
||||
import com.anytypeio.anytype.domain.relations.AddFileToObject
|
||||
import com.anytypeio.anytype.domain.sets.FindObjectSetForType
|
||||
import com.anytypeio.anytype.domain.status.InterceptThreadStatus
|
||||
import com.anytypeio.anytype.domain.status.ThreadStatusChannel
|
||||
import com.anytypeio.anytype.presentation.editor.DocumentExternalEventReducer
|
||||
|
@ -131,6 +133,7 @@ object EditorSessionModule {
|
|||
removeLinkMark: RemoveLinkMark,
|
||||
createPage: CreatePage,
|
||||
createDocument: CreateDocument,
|
||||
createObjectSet: CreateObjectSet,
|
||||
createObject: CreateObject,
|
||||
createNewDocument: CreateNewDocument,
|
||||
documentExternalEventReducer: DocumentExternalEventReducer,
|
||||
|
@ -145,7 +148,8 @@ object EditorSessionModule {
|
|||
getCompatibleObjectTypes: GetCompatibleObjectTypes,
|
||||
objectTypesProvider: ObjectTypesProvider,
|
||||
searchObjects: SearchObjects,
|
||||
getDefaultEditorType: GetDefaultEditorType
|
||||
getDefaultEditorType: GetDefaultEditorType,
|
||||
findObjectSetForType: FindObjectSetForType
|
||||
): EditorViewModelFactory = EditorViewModelFactory(
|
||||
openPage = openPage,
|
||||
closeObject = closePage,
|
||||
|
@ -169,7 +173,9 @@ object EditorSessionModule {
|
|||
getCompatibleObjectTypes = getCompatibleObjectTypes,
|
||||
objectTypesProvider = objectTypesProvider,
|
||||
searchObjects = searchObjects,
|
||||
getDefaultEditorType = getDefaultEditorType
|
||||
getDefaultEditorType = getDefaultEditorType,
|
||||
findObjectSetForType = findObjectSetForType,
|
||||
createObjectSet = createObjectSet
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
|
@ -734,4 +740,18 @@ object EditorUseCaseModule {
|
|||
fun provideGetCompatibleObjectTypesUseCase(
|
||||
repository: BlockRepository
|
||||
): GetCompatibleObjectTypes = GetCompatibleObjectTypes(repository)
|
||||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
@PerScreen
|
||||
fun findObjectSetForType(
|
||||
repo: BlockRepository
|
||||
): FindObjectSetForType = FindObjectSetForType(repo)
|
||||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
@PerScreen
|
||||
fun provideCreateObjectSetUseCase(
|
||||
repo: BlockRepository
|
||||
): CreateObjectSet = CreateObjectSet(repo = repo)
|
||||
}
|
|
@ -53,6 +53,7 @@ import com.anytypeio.anytype.core_models.SyncStatus
|
|||
import com.anytypeio.anytype.core_models.ext.getFirstLinkMarkupParam
|
||||
import com.anytypeio.anytype.core_models.ext.getSubstring
|
||||
import com.anytypeio.anytype.core_ui.extensions.addTextFromSelectedStart
|
||||
import com.anytypeio.anytype.core_ui.extensions.color
|
||||
import com.anytypeio.anytype.core_ui.extensions.cursorYBottomCoordinate
|
||||
import com.anytypeio.anytype.core_ui.extensions.isKeyboardVisible
|
||||
import com.anytypeio.anytype.core_ui.features.editor.*
|
||||
|
@ -74,6 +75,7 @@ import com.anytypeio.anytype.ext.extractMarks
|
|||
import com.anytypeio.anytype.presentation.editor.Editor
|
||||
import com.anytypeio.anytype.presentation.editor.EditorViewModel
|
||||
import com.anytypeio.anytype.presentation.editor.EditorViewModelFactory
|
||||
import com.anytypeio.anytype.presentation.editor.Snack
|
||||
import com.anytypeio.anytype.presentation.editor.editor.*
|
||||
import com.anytypeio.anytype.presentation.editor.editor.actions.ActionItemType
|
||||
import com.anytypeio.anytype.presentation.editor.editor.control.ControlPanelState
|
||||
|
@ -102,6 +104,7 @@ import com.anytypeio.anytype.ui.relations.RelationAddBaseFragment.Companion.CTX_
|
|||
import com.anytypeio.anytype.ui.relations.RelationAddToObjectBlockFragment.Companion.RELATION_ADD_RESULT_KEY
|
||||
import com.anytypeio.anytype.ui.relations.RelationCreateFromScratchForObjectBlockFragment.Companion.RELATION_NEW_RESULT_KEY
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.hbisoft.pickit.PickiT
|
||||
import com.hbisoft.pickit.PickiTCallbacks
|
||||
import jp.wasabeef.blurry.Blurry
|
||||
|
@ -260,7 +263,7 @@ open class EditorFragment : NavigationFragment(R.layout.fragment_editor),
|
|||
onSlashEvent = vm::onSlashTextWatcherEvent,
|
||||
onBackPressedCallback = { vm.onBackPressedCallback() },
|
||||
onKeyPressedEvent = vm::onKeyPressedEvent,
|
||||
onDragAndDropTrigger = { vh : RecyclerView.ViewHolder -> handleDragAndDropTrigger(vh) },
|
||||
onDragAndDropTrigger = { vh: RecyclerView.ViewHolder -> handleDragAndDropTrigger(vh) },
|
||||
onDragListener = dndListener
|
||||
)
|
||||
}
|
||||
|
@ -317,8 +320,10 @@ open class EditorFragment : NavigationFragment(R.layout.fragment_editor),
|
|||
FirstItemInvisibilityDetector { isVisible ->
|
||||
if (isVisible) {
|
||||
topToolbar.setBackgroundColor(0)
|
||||
topToolbar.statusText.animate().alpha(1f).setDuration(DEFAULT_TOOLBAR_ANIM_DURATION).start()
|
||||
topToolbar.container.animate().alpha(0f).setDuration(DEFAULT_TOOLBAR_ANIM_DURATION).start()
|
||||
topToolbar.statusText.animate().alpha(1f).setDuration(DEFAULT_TOOLBAR_ANIM_DURATION)
|
||||
.start()
|
||||
topToolbar.container.animate().alpha(0f).setDuration(DEFAULT_TOOLBAR_ANIM_DURATION)
|
||||
.start()
|
||||
if (blockAdapter.views.isNotEmpty()) {
|
||||
val firstView = blockAdapter.views.first()
|
||||
if (firstView is BlockView.Title && firstView.hasCover) {
|
||||
|
@ -329,8 +334,10 @@ open class EditorFragment : NavigationFragment(R.layout.fragment_editor),
|
|||
}
|
||||
} else {
|
||||
topToolbar.setBackgroundColor(Color.WHITE)
|
||||
topToolbar.statusText.animate().alpha(0f).setDuration(DEFAULT_TOOLBAR_ANIM_DURATION).start()
|
||||
topToolbar.container.animate().alpha(1f).setDuration(DEFAULT_TOOLBAR_ANIM_DURATION).start()
|
||||
topToolbar.statusText.animate().alpha(0f).setDuration(DEFAULT_TOOLBAR_ANIM_DURATION)
|
||||
.start()
|
||||
topToolbar.container.animate().alpha(1f).setDuration(DEFAULT_TOOLBAR_ANIM_DURATION)
|
||||
.start()
|
||||
topToolbar.setStyle(overCover = false)
|
||||
}
|
||||
}
|
||||
|
@ -687,7 +694,6 @@ open class EditorFragment : NavigationFragment(R.layout.fragment_editor),
|
|||
vm.navigation.observe(viewLifecycleOwner, navObserver)
|
||||
vm.controlPanelViewState.observe(viewLifecycleOwner) { render(it) }
|
||||
vm.commands.observe(viewLifecycleOwner) { execute(it) }
|
||||
vm.toasts.onEach { toast(it) }.launchIn(lifecycleScope)
|
||||
vm.searchResultScrollPosition
|
||||
.filter { it != EditorViewModel.NO_SEARCH_RESULT_POSITION }
|
||||
.onEach { recycler.smoothScrollToPosition(it) }
|
||||
|
@ -708,7 +714,23 @@ open class EditorFragment : NavigationFragment(R.layout.fragment_editor),
|
|||
}.launchIn(lifecycleScope)
|
||||
|
||||
with(lifecycleScope) {
|
||||
subscribe(vm.actions) { blockActionToolbar.bind(it) }
|
||||
jobs += subscribe(vm.actions) { blockActionToolbar.bind(it) }
|
||||
jobs += subscribe(vm.toasts) { toast(it) }
|
||||
jobs += subscribe(vm.snacks) { snack ->
|
||||
when (snack) {
|
||||
is Snack.ObjectSetNotFound -> {
|
||||
Snackbar
|
||||
.make(
|
||||
root,
|
||||
resources.getString(R.string.snack_object_set_not_found),
|
||||
Snackbar.LENGTH_LONG
|
||||
)
|
||||
.setActionTextColor(requireContext().color(R.color.orange))
|
||||
.setAction(R.string.create_new_set) { vm.onCreateNewSetForType(snack.type) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1026,7 +1048,8 @@ open class EditorFragment : NavigationFragment(R.layout.fragment_editor),
|
|||
}
|
||||
|
||||
private fun proceedWithScrollingToActionMenu(command: Command.ScrollToActionMenu): Unit {
|
||||
val lastSelected = (vm.state.value as ViewState.Success).blocks.indexOfLast { it.id == command.target }
|
||||
val lastSelected =
|
||||
(vm.state.value as ViewState.Success).blocks.indexOfLast { it.id == command.target }
|
||||
if (lastSelected != -1) {
|
||||
val lm = recycler.layoutManager as LinearLayoutManager
|
||||
val targetView = lm.findViewByPosition(lastSelected)
|
||||
|
@ -1132,7 +1155,8 @@ open class EditorFragment : NavigationFragment(R.layout.fragment_editor),
|
|||
topToolbar.setStyle(overCover = false)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1204,7 +1228,8 @@ open class EditorFragment : NavigationFragment(R.layout.fragment_editor),
|
|||
multiSelectTopToolbar.visible()
|
||||
when {
|
||||
count > 1 -> {
|
||||
multiSelectTopToolbar.selectText.text = getString(R.string.number_selected_blocks, count)
|
||||
multiSelectTopToolbar.selectText.text =
|
||||
getString(R.string.number_selected_blocks, count)
|
||||
}
|
||||
count == 1 -> {
|
||||
multiSelectTopToolbar.selectText.setText(R.string.one_selected_block)
|
||||
|
@ -1968,9 +1993,9 @@ open class EditorFragment : NavigationFragment(R.layout.fragment_editor),
|
|||
//region Drag-and-drop UI logic.
|
||||
|
||||
private var dndTargetPos = -1
|
||||
private var dndTargetPrevious : Pair<Float, Int>? = null
|
||||
private var dndTargetPrevious: Pair<Float, Int>? = null
|
||||
|
||||
var dndTargetLineAnimator : ViewPropertyAnimator? = null
|
||||
var dndTargetLineAnimator: ViewPropertyAnimator? = null
|
||||
|
||||
private var scrollDownJob: Job? = null
|
||||
private var scrollUpJob: Job? = null
|
||||
|
|
|
@ -245,4 +245,7 @@ Do the computation of an expensive paragraph of text on a background thread:
|
|||
<string name="object_not_exist">This object doesn\'t exist</string>
|
||||
<string name="back_to_dashboard">Back to dashboard</string>
|
||||
|
||||
<string name="snack_object_set_not_found">Set not found for this type.</string>
|
||||
<string name="create_new_set">Create</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -27,6 +27,7 @@ object Relations {
|
|||
const val SNIPPET = "snippet"
|
||||
const val IS_DRAFT = "isDraft"
|
||||
const val WORKSPACE_ID = "workspaceId"
|
||||
const val SET_OF = "setOf"
|
||||
|
||||
val defaultRelations = listOf(
|
||||
ID,
|
||||
|
|
|
@ -37,8 +37,8 @@ sealed class Response {
|
|||
|
||||
sealed class Set : Response() {
|
||||
data class Create(
|
||||
val blockId: String,
|
||||
val targetId: String,
|
||||
val blockId: Id?,
|
||||
val targetId: Id,
|
||||
val payload: Payload
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
package com.anytypeio.anytype.core_ui.menu
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.widget.PopupMenu
|
||||
import com.anytypeio.anytype.core_ui.R
|
||||
|
||||
class ObjectTypePopupMenu(
|
||||
context: Context,
|
||||
view: View,
|
||||
onChangeTypeClicked: () -> Unit,
|
||||
onOpenSetClicked: () -> Unit
|
||||
) : PopupMenu(context, view) {
|
||||
init {
|
||||
menuInflater.inflate(R.menu.menu_object_type, menu)
|
||||
setOnMenuItemClickListener { item ->
|
||||
when(item.itemId) {
|
||||
R.id.change_type -> onChangeTypeClicked()
|
||||
R.id.open_set -> onOpenSetClicked()
|
||||
else -> throw IllegalStateException("Unexpected menu item: $item")
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,6 +10,8 @@ import androidx.constraintlayout.helper.widget.Flow
|
|||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import com.anytypeio.anytype.core_models.Relation
|
||||
import com.anytypeio.anytype.core_ui.R
|
||||
import com.anytypeio.anytype.core_ui.menu.DataViewEditViewPopupMenu
|
||||
import com.anytypeio.anytype.core_ui.menu.ObjectTypePopupMenu
|
||||
import com.anytypeio.anytype.core_utils.ext.dimen
|
||||
import com.anytypeio.anytype.core_utils.ext.setDrawableColor
|
||||
import com.anytypeio.anytype.presentation.editor.editor.ThemeColor
|
||||
|
@ -30,7 +32,10 @@ class FeaturedRelationGroupWidget : ConstraintLayout {
|
|||
private val defaultTextSize: Float = context.dimen(R.dimen.sp_13)
|
||||
private val dividerSize: Int = context.dimen(R.dimen.dp_4).toInt()
|
||||
|
||||
fun set(item: BlockView.FeaturedRelation, click: (ListenerType.Relation) -> Unit) {
|
||||
fun set(
|
||||
item: BlockView.FeaturedRelation,
|
||||
click: (ListenerType.Relation) -> Unit
|
||||
) {
|
||||
clear()
|
||||
|
||||
val flow = Flow(context).apply {
|
||||
|
@ -188,7 +193,17 @@ class FeaturedRelationGroupWidget : ConstraintLayout {
|
|||
setTextSize(TypedValue.COMPLEX_UNIT_PX, defaultTextSize)
|
||||
}
|
||||
view.setOnClickListener {
|
||||
click.invoke(ListenerType.Relation.ObjectType(type = relation.relationId))
|
||||
val popup = ObjectTypePopupMenu(
|
||||
context = context,
|
||||
view = it,
|
||||
onChangeTypeClicked = {
|
||||
click(ListenerType.Relation.ObjectType(type = relation.relationId))
|
||||
},
|
||||
onOpenSetClicked = {
|
||||
click(ListenerType.Relation.ObjectTypeOpenSet(type = relation.type))
|
||||
}
|
||||
)
|
||||
popup.show()
|
||||
}
|
||||
addView(view)
|
||||
ids.add(view.id)
|
||||
|
|
9
core-ui/src/main/res/menu/menu_object_type.xml
Normal file
9
core-ui/src/main/res/menu/menu_object_type.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/change_type"
|
||||
android:title="@string/change_type" />
|
||||
<item
|
||||
android:id="@+id/open_set"
|
||||
android:title="@string/open_set" />
|
||||
</menu>
|
|
@ -436,5 +436,6 @@
|
|||
<string name="name_type_page_icon">📄</string>
|
||||
<string name="name_type_page_subtitle">Proto type to start with</string>
|
||||
<string name="non_existent_object">Non-existent object</string>
|
||||
<string name="open_set">Open set</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -257,7 +257,7 @@ class BlockDataRepository(
|
|||
override suspend fun createSet(
|
||||
context: Id,
|
||||
target: Id?,
|
||||
position: Position,
|
||||
position: Position?,
|
||||
objectType: String?
|
||||
): CreateObjectSet.Response {
|
||||
val result = factory.remote.createSet(
|
||||
|
|
|
@ -63,7 +63,7 @@ interface BlockDataStore {
|
|||
suspend fun createSet(
|
||||
contextId: String,
|
||||
targetId: String?,
|
||||
position: Position,
|
||||
position: Position?,
|
||||
objectType: String?
|
||||
): Response.Set.Create
|
||||
|
||||
|
|
|
@ -64,7 +64,7 @@ interface BlockRemote {
|
|||
suspend fun createSet(
|
||||
contextId: String,
|
||||
targetId: String?,
|
||||
position: Position,
|
||||
position: Position?,
|
||||
objectType: String?
|
||||
): Response.Set.Create
|
||||
|
||||
|
|
|
@ -178,7 +178,7 @@ class BlockRemoteDataStore(private val remote: BlockRemote) : BlockDataStore {
|
|||
override suspend fun createSet(
|
||||
contextId: String,
|
||||
targetId: String?,
|
||||
position: Position,
|
||||
position: Position?,
|
||||
objectType: String?
|
||||
): Response.Set.Create = remote.createSet(
|
||||
contextId = contextId,
|
||||
|
|
|
@ -1,34 +1,39 @@
|
|||
package com.anytypeio.anytype.domain.block.interactor.sets
|
||||
|
||||
import com.anytypeio.anytype.domain.base.BaseUseCase
|
||||
import com.anytypeio.anytype.domain.block.repo.BlockRepository
|
||||
import com.anytypeio.anytype.core_models.Id
|
||||
import com.anytypeio.anytype.core_models.Payload
|
||||
import com.anytypeio.anytype.core_models.Position
|
||||
import com.anytypeio.anytype.core_models.Url
|
||||
import com.anytypeio.anytype.core_models.Payload
|
||||
import com.anytypeio.anytype.domain.base.BaseUseCase
|
||||
import com.anytypeio.anytype.domain.block.repo.BlockRepository
|
||||
|
||||
class CreateObjectSet(private val repo: BlockRepository) :
|
||||
BaseUseCase<CreateObjectSet.Response, CreateObjectSet.Params>() {
|
||||
class CreateObjectSet(
|
||||
private val repo: BlockRepository
|
||||
) : BaseUseCase<CreateObjectSet.Response, CreateObjectSet.Params>() {
|
||||
|
||||
override suspend fun run(params: Params) = safe {
|
||||
repo.createSet(
|
||||
context = params.context,
|
||||
context = params.ctx,
|
||||
target = params.target,
|
||||
position = params.position,
|
||||
objectType = params.objectType
|
||||
objectType = params.type
|
||||
)
|
||||
}
|
||||
|
||||
data class Params(
|
||||
val context: Id,
|
||||
val ctx: Id,
|
||||
val target: Id? = null,
|
||||
val position: Position = Position.BOTTOM,
|
||||
val objectType: Url? = null
|
||||
val position: Position? = null,
|
||||
val type: Id? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* @property [target] id of the new set
|
||||
* @property [block] optional id of the link block pointing to the new set.
|
||||
*/
|
||||
data class Response(
|
||||
val block: Id,
|
||||
val target: Id,
|
||||
val block: Id?,
|
||||
val payload: Payload
|
||||
)
|
||||
}
|
|
@ -116,7 +116,7 @@ interface BlockRepository {
|
|||
suspend fun createSet(
|
||||
context: Id,
|
||||
target: Id? = null,
|
||||
position: Position = Position.BOTTOM,
|
||||
position: Position? = null,
|
||||
objectType: String? = null
|
||||
): CreateObjectSet.Response
|
||||
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
package com.anytypeio.anytype.domain.sets
|
||||
|
||||
import com.anytypeio.anytype.core_models.*
|
||||
import com.anytypeio.anytype.domain.base.BaseUseCase
|
||||
import com.anytypeio.anytype.domain.block.repo.BlockRepository
|
||||
import com.anytypeio.anytype.domain.sets.FindObjectSetForType.Params
|
||||
import com.anytypeio.anytype.domain.sets.FindObjectSetForType.Response
|
||||
|
||||
/**
|
||||
* Use-case for finding an object set for a type.
|
||||
* @see Response for details.
|
||||
* @see Params for details.
|
||||
*/
|
||||
class FindObjectSetForType(
|
||||
private val repo: BlockRepository
|
||||
) : BaseUseCase<Response, Params>() {
|
||||
|
||||
override suspend fun run(params: Params) = safe {
|
||||
val results = repo.searchObjects(
|
||||
limit = 1,
|
||||
filters = listOf(
|
||||
DVFilter(
|
||||
relationKey = Relations.TYPE,
|
||||
condition = DVFilterCondition.EQUAL,
|
||||
value = ObjectType.SET_URL
|
||||
),
|
||||
DVFilter(
|
||||
relationKey = Relations.SET_OF,
|
||||
condition = DVFilterCondition.IN,
|
||||
value = listOf(params.type)
|
||||
)
|
||||
),
|
||||
sorts = emptyList(),
|
||||
fulltext = "",
|
||||
offset = 0
|
||||
)
|
||||
if (results.isNotEmpty()) {
|
||||
val obj = ObjectWrapper.Basic(results.first())
|
||||
check(obj.layout == ObjectType.Layout.SET) { "Unexpected layout for set" }
|
||||
Response.Success(
|
||||
type = params.type,
|
||||
obj = obj
|
||||
)
|
||||
} else {
|
||||
Response.NotFound(type = params.type)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @property [type] object type id
|
||||
*/
|
||||
data class Params(val type: Id)
|
||||
|
||||
sealed class Response {
|
||||
/**
|
||||
* Could not found a set for this [type].
|
||||
*/
|
||||
data class NotFound(val type: Id) : Response()
|
||||
|
||||
/**
|
||||
* Found a set for this [type].
|
||||
*/
|
||||
data class Success(val type: Id, val obj: ObjectWrapper.Basic) : Response()
|
||||
}
|
||||
}
|
|
@ -204,10 +204,13 @@ class BlockMiddleware(
|
|||
override suspend fun createSet(
|
||||
contextId: String,
|
||||
targetId: String?,
|
||||
position: Position,
|
||||
position: Position?,
|
||||
objectType: String?
|
||||
): Response.Set.Create = middleware.createSet(
|
||||
contextId, targetId, position, objectType
|
||||
contextId = contextId,
|
||||
targetId = targetId,
|
||||
position = position,
|
||||
objectType = objectType
|
||||
)
|
||||
|
||||
override suspend fun setActiveDataViewViewer(
|
||||
|
|
|
@ -970,7 +970,7 @@ class Middleware(
|
|||
fun createSet(
|
||||
contextId: String,
|
||||
targetId: String?,
|
||||
position: Position,
|
||||
position: Position?,
|
||||
objectType: String?
|
||||
): Response.Set.Create {
|
||||
|
||||
|
@ -984,7 +984,7 @@ class Middleware(
|
|||
contextId = contextId,
|
||||
targetId = targetId.orEmpty(),
|
||||
source = source,
|
||||
position = position.toMiddlewareModel()
|
||||
position = position?.toMiddlewareModel() ?: Block.Position.Bottom
|
||||
)
|
||||
|
||||
if (BuildConfig.DEBUG) logRequest(request)
|
||||
|
@ -994,7 +994,7 @@ class Middleware(
|
|||
if (BuildConfig.DEBUG) logResponse(response)
|
||||
|
||||
return Response.Set.Create(
|
||||
blockId = response.blockId,
|
||||
blockId = response.blockId.ifEmpty { null },
|
||||
targetId = response.targetId,
|
||||
payload = response.event.toPayload()
|
||||
)
|
||||
|
|
|
@ -38,6 +38,7 @@ import com.anytypeio.anytype.domain.base.Result
|
|||
import com.anytypeio.anytype.domain.block.interactor.RemoveLinkMark
|
||||
import com.anytypeio.anytype.domain.block.interactor.UpdateLinkMarks
|
||||
import com.anytypeio.anytype.domain.block.interactor.UpdateText
|
||||
import com.anytypeio.anytype.domain.block.interactor.sets.CreateObjectSet
|
||||
import com.anytypeio.anytype.domain.dataview.interactor.GetCompatibleObjectTypes
|
||||
import com.anytypeio.anytype.domain.dataview.interactor.SearchObjects
|
||||
import com.anytypeio.anytype.domain.editor.Editor
|
||||
|
@ -47,6 +48,7 @@ import com.anytypeio.anytype.domain.launch.GetDefaultEditorType
|
|||
import com.anytypeio.anytype.domain.misc.UrlBuilder
|
||||
import com.anytypeio.anytype.domain.objects.SetObjectIsArchived
|
||||
import com.anytypeio.anytype.domain.page.*
|
||||
import com.anytypeio.anytype.domain.sets.FindObjectSetForType
|
||||
import com.anytypeio.anytype.domain.status.InterceptThreadStatus
|
||||
import com.anytypeio.anytype.presentation.BuildConfig
|
||||
import com.anytypeio.anytype.presentation.common.StateReducer
|
||||
|
@ -137,7 +139,9 @@ class EditorViewModel(
|
|||
private val getCompatibleObjectTypes: GetCompatibleObjectTypes,
|
||||
private val objectTypesProvider: ObjectTypesProvider,
|
||||
private val searchObjects: SearchObjects,
|
||||
private val getDefaultEditorType: GetDefaultEditorType
|
||||
private val getDefaultEditorType: GetDefaultEditorType,
|
||||
private val findObjectSetForType: FindObjectSetForType,
|
||||
private val createObjectSet: CreateObjectSet
|
||||
) : ViewStateViewModel<ViewState>(),
|
||||
SupportNavigation<EventWrapper<AppNavigation.Command>>,
|
||||
SupportCommand<Command>,
|
||||
|
@ -198,6 +202,8 @@ class EditorViewModel(
|
|||
private val _toasts: Channel<String> = Channel()
|
||||
val toasts: Flow<String> get() = _toasts.consumeAsFlow()
|
||||
|
||||
val snacks = MutableSharedFlow<Snack>(replay = 0)
|
||||
|
||||
/**
|
||||
* Open gallery and search media files for block with that id
|
||||
*/
|
||||
|
@ -2656,10 +2662,6 @@ class EditorViewModel(
|
|||
proceedWithClearingFocus()
|
||||
val details = orchestrator.stores.details.current()
|
||||
val wrapper = ObjectWrapper.Basic(map = details.details[target]?.map ?: emptyMap())
|
||||
if (wrapper.isDeleted == true) {
|
||||
proceedWithOpeningDeletedPage(target)
|
||||
return
|
||||
}
|
||||
when (wrapper.layout) {
|
||||
ObjectType.Layout.BASIC,
|
||||
ObjectType.Layout.PROFILE,
|
||||
|
@ -2679,10 +2681,6 @@ class EditorViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
private fun proceedWithOpeningDeletedPage(target: Id) {
|
||||
navigate(EventWrapper(AppNavigation.Command.OpenObject(target)))
|
||||
}
|
||||
|
||||
fun onAddNewObjectClicked(type: String, layout: ObjectType.Layout) {
|
||||
controlPanelInteractor.onEvent(ControlPanelMachine.Event.OnAddBlockToolbarOptionSelected)
|
||||
|
||||
|
@ -3530,6 +3528,23 @@ class EditorViewModel(
|
|||
eventName = EventsDictionary.POPUP_OBJECT_TYPE_CHANGE
|
||||
)
|
||||
}
|
||||
is ListenerType.Relation.ObjectTypeOpenSet -> {
|
||||
viewModelScope.launch {
|
||||
findObjectSetForType(FindObjectSetForType.Params(clicked.type)).process(
|
||||
failure = { Timber.e(it, "Error while search for a set for this type") },
|
||||
success = { response ->
|
||||
when(response) {
|
||||
is FindObjectSetForType.Response.NotFound -> {
|
||||
snacks.emit(Snack.ObjectSetNotFound(clicked.type))
|
||||
}
|
||||
is FindObjectSetForType.Response.Success -> {
|
||||
proceedWithOpeningSet(response.obj.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3680,7 +3695,17 @@ class EditorViewModel(
|
|||
analytics = analytics,
|
||||
eventName = EventsDictionary.SCREEN_DOCUMENT
|
||||
)
|
||||
navigate(EventWrapper(AppNavigation.Command.OpenObject(target)))
|
||||
viewModelScope.launch {
|
||||
closePage(CloseBlock.Params(context)).process(
|
||||
failure = {
|
||||
Timber.e(it, "Error while closing object")
|
||||
navigate(EventWrapper(AppNavigation.Command.OpenObject(target)))
|
||||
},
|
||||
success = {
|
||||
navigate(EventWrapper(AppNavigation.Command.OpenObject(target)))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun proceedWithOpeningSet(target: Id) {
|
||||
|
@ -3688,7 +3713,17 @@ class EditorViewModel(
|
|||
analytics = analytics,
|
||||
eventName = EventsDictionary.SCREEN_SET
|
||||
)
|
||||
navigate(EventWrapper(AppNavigation.Command.OpenObjectSet(target)))
|
||||
viewModelScope.launch {
|
||||
closePage(CloseBlock.Params(context)).process(
|
||||
failure = {
|
||||
Timber.e(it, "Error while closing object")
|
||||
navigate(EventWrapper(AppNavigation.Command.OpenObjectSet(target)))
|
||||
},
|
||||
success = {
|
||||
navigate(EventWrapper(AppNavigation.Command.OpenObjectSet(target)))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -3790,7 +3825,6 @@ class EditorViewModel(
|
|||
const val CANNOT_OPEN_STYLE_PANEL_FOR_DESCRIPTION = "Description block is text primitive and therefore no styling can be applied."
|
||||
const val CANNOT_OPEN_STYLE_PANEL_FOR_CODE_BLOCK_ERROR =
|
||||
"Opening style panel for code block currently not supported"
|
||||
const val FLAVOUR_EXPERIMENTAL = "experimental"
|
||||
|
||||
const val ERROR_UNSUPPORTED_BEHAVIOR = "Currently unsupported behavior."
|
||||
const val NOT_ALLOWED_FOR_OBJECT = "Not allowed for this object"
|
||||
|
@ -5324,4 +5358,18 @@ class EditorViewModel(
|
|||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
fun onCreateNewSetForType(type: Id) {
|
||||
viewModelScope.launch {
|
||||
createObjectSet(
|
||||
CreateObjectSet.Params(
|
||||
ctx = context,
|
||||
type = type
|
||||
)
|
||||
).process(
|
||||
failure = { Timber.e(it, "Error while creating a set of type: $type") },
|
||||
success = { response -> proceedWithOpeningSet(response.target) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,6 +10,7 @@ import com.anytypeio.anytype.domain.`object`.ObjectTypesProvider
|
|||
import com.anytypeio.anytype.domain.`object`.UpdateDetail
|
||||
import com.anytypeio.anytype.domain.block.interactor.RemoveLinkMark
|
||||
import com.anytypeio.anytype.domain.block.interactor.UpdateLinkMarks
|
||||
import com.anytypeio.anytype.domain.block.interactor.sets.CreateObjectSet
|
||||
import com.anytypeio.anytype.domain.dataview.interactor.GetCompatibleObjectTypes
|
||||
import com.anytypeio.anytype.domain.dataview.interactor.SearchObjects
|
||||
import com.anytypeio.anytype.domain.event.interactor.InterceptEvents
|
||||
|
@ -17,6 +18,7 @@ import com.anytypeio.anytype.domain.launch.GetDefaultEditorType
|
|||
import com.anytypeio.anytype.domain.misc.UrlBuilder
|
||||
import com.anytypeio.anytype.domain.objects.SetObjectIsArchived
|
||||
import com.anytypeio.anytype.domain.page.*
|
||||
import com.anytypeio.anytype.domain.sets.FindObjectSetForType
|
||||
import com.anytypeio.anytype.domain.status.InterceptThreadStatus
|
||||
import com.anytypeio.anytype.presentation.common.StateReducer
|
||||
import com.anytypeio.anytype.presentation.editor.editor.DetailModificationManager
|
||||
|
@ -29,6 +31,7 @@ open class EditorViewModelFactory(
|
|||
private val closeObject: CloseBlock,
|
||||
private val createPage: CreatePage,
|
||||
private val createDocument: CreateDocument,
|
||||
private val createObjectSet: CreateObjectSet,
|
||||
private val createObject: CreateObject,
|
||||
private val createNewDocument: CreateNewDocument,
|
||||
private val setObjectIsArchived: SetObjectIsArchived,
|
||||
|
@ -47,7 +50,8 @@ open class EditorViewModelFactory(
|
|||
private val getCompatibleObjectTypes: GetCompatibleObjectTypes,
|
||||
private val objectTypesProvider: ObjectTypesProvider,
|
||||
private val searchObjects: SearchObjects,
|
||||
private val getDefaultEditorType: GetDefaultEditorType
|
||||
private val getDefaultEditorType: GetDefaultEditorType,
|
||||
private val findObjectSetForType: FindObjectSetForType
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
|
@ -75,7 +79,9 @@ open class EditorViewModelFactory(
|
|||
getCompatibleObjectTypes = getCompatibleObjectTypes,
|
||||
objectTypesProvider = objectTypesProvider,
|
||||
searchObjects = searchObjects,
|
||||
getDefaultEditorType = getDefaultEditorType
|
||||
getDefaultEditorType = getDefaultEditorType,
|
||||
findObjectSetForType = findObjectSetForType,
|
||||
createObjectSet = createObjectSet
|
||||
) as T
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package com.anytypeio.anytype.presentation.editor
|
||||
|
||||
import com.anytypeio.anytype.core_models.Id
|
||||
|
||||
sealed class Snack {
|
||||
data class ObjectSetNotFound(val type: Id) : Snack()
|
||||
}
|
|
@ -58,6 +58,7 @@ sealed class ListenerType {
|
|||
data class Placeholder(val target: Id) : Relation()
|
||||
data class Related(val value: BlockView.Relation) : Relation()
|
||||
data class ObjectType(val type: String) : Relation()
|
||||
data class ObjectTypeOpenSet(val type: String) : Relation()
|
||||
data class Featured(val relation: DocumentRelationView) : Relation()
|
||||
}
|
||||
}
|
|
@ -16,6 +16,7 @@ sealed class CreateSetViewState {
|
|||
data class AddObjectType(val data: ArrayList<CreateObjectTypeView>) : CreateSetViewState()
|
||||
}
|
||||
|
||||
@Deprecated("LEGACY SUSPECT")
|
||||
class CreateObjectSetViewModel(
|
||||
private val createObjectSet: CreateObjectSet,
|
||||
private val getObjectTypes: GetObjectTypes,
|
||||
|
@ -98,8 +99,8 @@ class CreateObjectSetViewModel(
|
|||
viewModelScope.launch {
|
||||
createObjectSet(
|
||||
CreateObjectSet.Params(
|
||||
context = context,
|
||||
objectType = type
|
||||
ctx = context,
|
||||
type = type
|
||||
)
|
||||
).process(
|
||||
failure = { Timber.e(it, "Error while creating new set for type: $type") },
|
||||
|
|
|
@ -12,6 +12,7 @@ import com.anytypeio.anytype.domain.base.Either
|
|||
import com.anytypeio.anytype.domain.base.Result
|
||||
import com.anytypeio.anytype.domain.block.UpdateDivider
|
||||
import com.anytypeio.anytype.domain.block.interactor.*
|
||||
import com.anytypeio.anytype.domain.block.interactor.sets.CreateObjectSet
|
||||
import com.anytypeio.anytype.domain.block.repo.BlockRepository
|
||||
import com.anytypeio.anytype.domain.clipboard.Copy
|
||||
import com.anytypeio.anytype.domain.clipboard.Paste
|
||||
|
@ -26,6 +27,7 @@ import com.anytypeio.anytype.domain.misc.UrlBuilder
|
|||
import com.anytypeio.anytype.domain.objects.SetObjectIsArchived
|
||||
import com.anytypeio.anytype.domain.page.*
|
||||
import com.anytypeio.anytype.domain.page.bookmark.SetupBookmark
|
||||
import com.anytypeio.anytype.domain.sets.FindObjectSetForType
|
||||
import com.anytypeio.anytype.domain.status.InterceptThreadStatus
|
||||
import com.anytypeio.anytype.presentation.MockBlockFactory
|
||||
import com.anytypeio.anytype.presentation.editor.cover.CoverImageHashProvider
|
||||
|
@ -218,6 +220,12 @@ open class EditorViewModelTest {
|
|||
@Mock
|
||||
lateinit var getDefaultEditorType: GetDefaultEditorType
|
||||
|
||||
@Mock
|
||||
lateinit var findObjectSetForType: FindObjectSetForType
|
||||
|
||||
@Mock
|
||||
lateinit var createObjectSet: CreateObjectSet
|
||||
|
||||
private lateinit var updateDetail: UpdateDetail
|
||||
|
||||
lateinit var vm: EditorViewModel
|
||||
|
@ -3982,7 +3990,9 @@ open class EditorViewModelTest {
|
|||
updateDetail = updateDetail,
|
||||
getCompatibleObjectTypes = getCompatibleObjectTypes,
|
||||
objectTypesProvider = objectTypesProvider,
|
||||
searchObjects = searchObjects
|
||||
searchObjects = searchObjects,
|
||||
findObjectSetForType = findObjectSetForType,
|
||||
createObjectSet = createObjectSet
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import com.anytypeio.anytype.domain.base.Either
|
|||
import com.anytypeio.anytype.domain.base.Result
|
||||
import com.anytypeio.anytype.domain.block.UpdateDivider
|
||||
import com.anytypeio.anytype.domain.block.interactor.*
|
||||
import com.anytypeio.anytype.domain.block.interactor.sets.CreateObjectSet
|
||||
import com.anytypeio.anytype.domain.block.repo.BlockRepository
|
||||
import com.anytypeio.anytype.domain.clipboard.Copy
|
||||
import com.anytypeio.anytype.domain.clipboard.Paste
|
||||
|
@ -25,6 +26,7 @@ import com.anytypeio.anytype.domain.misc.UrlBuilder
|
|||
import com.anytypeio.anytype.domain.objects.SetObjectIsArchived
|
||||
import com.anytypeio.anytype.domain.page.*
|
||||
import com.anytypeio.anytype.domain.page.bookmark.SetupBookmark
|
||||
import com.anytypeio.anytype.domain.sets.FindObjectSetForType
|
||||
import com.anytypeio.anytype.domain.status.InterceptThreadStatus
|
||||
import com.anytypeio.anytype.presentation.editor.DocumentExternalEventReducer
|
||||
import com.anytypeio.anytype.presentation.editor.Editor
|
||||
|
@ -188,6 +190,12 @@ open class EditorPresentationTestSetup {
|
|||
@Mock
|
||||
lateinit var getDefaultEditorType: GetDefaultEditorType
|
||||
|
||||
@Mock
|
||||
lateinit var findObjectSetForType: FindObjectSetForType
|
||||
|
||||
@Mock
|
||||
lateinit var createObjectSet: CreateObjectSet
|
||||
|
||||
private val builder: UrlBuilder get() = UrlBuilder(gateway)
|
||||
|
||||
private lateinit var updateDetail: UpdateDetail
|
||||
|
@ -271,7 +279,9 @@ open class EditorPresentationTestSetup {
|
|||
getCompatibleObjectTypes = getCompatibleObjectTypes,
|
||||
objectTypesProvider = objectTypesProvider,
|
||||
searchObjects = searchObjects,
|
||||
getDefaultEditorType = getDefaultEditorType
|
||||
getDefaultEditorType = getDefaultEditorType,
|
||||
findObjectSetForType = findObjectSetForType,
|
||||
createObjectSet = createObjectSet
|
||||
)
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue