Merge branch 'main' into alex/sRGB-vertex-colors
This commit is contained in:
Коммит
6d4f5abc57
|
@ -2,7 +2,7 @@ version: 2.1
|
|||
|
||||
orbs:
|
||||
snyk: snyk/snyk@2.0.3
|
||||
codecov: codecov/codecov@4.1.0
|
||||
# codecov: codecov/codecov@4.1.0
|
||||
|
||||
workflows:
|
||||
test-build:
|
||||
|
@ -488,8 +488,8 @@ jobs:
|
|||
command: yarn test:report
|
||||
working_directory: 'packages/server'
|
||||
|
||||
- codecov/upload:
|
||||
file: packages/server/coverage/lcov.info
|
||||
# - codecov/upload:
|
||||
# file: packages/server/coverage/lcov.info
|
||||
|
||||
- run:
|
||||
name: Introspect GQL schema for subsequent checks
|
||||
|
|
|
@ -15,7 +15,6 @@ packages/*/dist-cjs
|
|||
.nyc_output
|
||||
coverage/
|
||||
.idea
|
||||
test-queries
|
||||
|
||||
**/.DS_Store
|
||||
.nvmrc
|
||||
|
|
|
@ -41,7 +41,7 @@ repos:
|
|||
- id: helm-documentation
|
||||
name: Helm Json Schema
|
||||
language: system
|
||||
files: utils/helm/speckle-server/values.yaml
|
||||
files: utils\/helm\/speckle\-server\/values\.yaml
|
||||
entry: utils/helm/update-schema-json.sh
|
||||
description: If this fails it is because the values.yaml file was updated. Or has missing or incorrect documentation.
|
||||
|
||||
|
|
|
@ -12,7 +12,6 @@
|
|||
<script setup lang="ts">
|
||||
import { useTheme } from '~~/lib/core/composables/theme'
|
||||
import { useAuthManager } from '~~/lib/auth/composables/auth'
|
||||
import { useMixpanelInitialization } from '~~/lib/core/composables/mp'
|
||||
|
||||
const { isDarkTheme } = useTheme()
|
||||
|
||||
|
@ -31,9 +30,6 @@ useHead({
|
|||
|
||||
const { watchAuthQueryString } = useAuthManager()
|
||||
watchAuthQueryString()
|
||||
|
||||
// Awaiting to block the app from continuing until mixpanel tracking is fully initialized
|
||||
await useMixpanelInitialization()
|
||||
</script>
|
||||
<style>
|
||||
.page-enter-active,
|
||||
|
|
|
@ -16,7 +16,10 @@
|
|||
v-if="lastUpdated"
|
||||
class="bg-primary-muted text-primary rounded-full px-2 py-1 -ml-1"
|
||||
>
|
||||
updated {{ lastUpdated }}
|
||||
Updated
|
||||
<span v-tippy="lastUpdatedFormatted.full">
|
||||
{{ lastUpdatedFormatted.relative }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
|
@ -57,8 +60,6 @@
|
|||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { ShieldCheckIcon, GlobeEuropeAfricaIcon } from '@heroicons/vue/24/solid'
|
||||
import type { ConnectorTag } from '~~/lib/connectors'
|
||||
|
||||
|
@ -69,8 +70,15 @@ const props = defineProps<{
|
|||
const dialogOpen = ref(false)
|
||||
|
||||
const lastUpdated = computed(() =>
|
||||
props.tag.versions?.length > 0
|
||||
? dayjs(props.tag.versions[0].Date).from(dayjs())
|
||||
: undefined
|
||||
props.tag.versions?.length > 0 ? props.tag.versions[0].Date : undefined
|
||||
)
|
||||
|
||||
const lastUpdatedFormatted = computed(() => {
|
||||
return {
|
||||
full: lastUpdated.value ? formattedFullDate(lastUpdated.value) : '',
|
||||
relative: lastUpdated.value
|
||||
? formattedRelativeDate(lastUpdated.value, { prefix: true })
|
||||
: ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
name="scopes"
|
||||
label="Scopes"
|
||||
placeholder="Choose Scopes"
|
||||
help="It's good practice to limit the scopes of your token to the absolute minimum. For example, if your application or script will only read and write streams, select just those scopes."
|
||||
help="It's good practice to limit the scopes of your token to the absolute minimum. For example, if your application or script will only read and write projects/streams, select just those scopes."
|
||||
show-required
|
||||
:rules="[isItemSelected]"
|
||||
show-label
|
||||
|
|
|
@ -2,7 +2,9 @@
|
|||
<div class="flex flex-col space-y-8 mt-12">
|
||||
<div class="flex flex-col justify-center sm:flex-row sm:space-x-2 items-center">
|
||||
<LockClosedIcon class="w-12 h-12 text-primary shrink-0" />
|
||||
<h1 class="h3 font-bold">You are not authorized to access this project.</h1>
|
||||
<h1 class="h3 font-bold">
|
||||
You are not authorized to access this {{ resourceType }}.
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:space-x-2 items-center"
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
v-if="invite"
|
||||
:invite="invite"
|
||||
:show-stream-name="false"
|
||||
:auto-accept="shouldAutoAcceptInvite"
|
||||
block
|
||||
@processed="onProcessed"
|
||||
/>
|
||||
|
@ -12,7 +11,7 @@
|
|||
</NuxtErrorBoundary>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import { waitForever, type Optional } from '@speckle/shared'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { projectRoute, useNavigateToHome } from '~/lib/common/helpers/route'
|
||||
import { projectInviteQuery } from '~~/lib/projects/graphql/queries'
|
||||
|
@ -23,7 +22,6 @@ const goHome = useNavigateToHome()
|
|||
|
||||
const token = computed(() => route.query.token as Optional<string>)
|
||||
const projectId = computed(() => route.params.id as Optional<string>)
|
||||
const shouldAutoAcceptInvite = computed(() => route.query.accept === 'true')
|
||||
|
||||
const { result } = useQuery(
|
||||
projectInviteQuery,
|
||||
|
@ -48,6 +46,7 @@ const onProcessed = async (val: { accepted: boolean }) => {
|
|||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
await waitForever() // to prevent UI changes while reload is happening
|
||||
} else {
|
||||
await goHome()
|
||||
}
|
||||
|
|
|
@ -3,8 +3,7 @@
|
|||
<ProjectsInviteBanner
|
||||
v-if="invite"
|
||||
:invite="invite"
|
||||
:show-stream-name="false"
|
||||
:auto-accept="shouldAutoAcceptInvite"
|
||||
:show-project-name="false"
|
||||
@processed="onProcessed"
|
||||
/>
|
||||
</NuxtErrorBoundary>
|
||||
|
@ -20,7 +19,6 @@ const logger = useLogger()
|
|||
|
||||
const token = computed(() => route.query.token as Optional<string>)
|
||||
const projectId = computed(() => route.params.id as Optional<string>)
|
||||
const shouldAutoAcceptInvite = computed(() => route.query.accept === 'true')
|
||||
|
||||
const { result } = useQuery(
|
||||
projectInviteQuery,
|
||||
|
|
|
@ -120,7 +120,7 @@ const props = defineProps({
|
|||
default: undefined
|
||||
},
|
||||
/**
|
||||
* Whether to only return owned streams from server
|
||||
* Whether to only return owned projects from server
|
||||
*/
|
||||
ownedOnly: {
|
||||
type: Boolean,
|
||||
|
|
|
@ -6,6 +6,13 @@
|
|||
class="relative w-full h-full"
|
||||
@mouseenter="hovered = true"
|
||||
@mouseleave="hovered = false"
|
||||
@mousemove="(e: MouseEvent) => calculatePanoramaStyle(e)"
|
||||
@touchmove="(e: TouchEvent) =>
|
||||
calculatePanoramaStyle({
|
||||
target: e.target,
|
||||
clientX: e.touches[0].clientX,
|
||||
clientY: e.touches[0].clientY
|
||||
})"
|
||||
>
|
||||
<Transition
|
||||
enter-from-class="opacity-0"
|
||||
|
@ -58,13 +65,6 @@
|
|||
width: '100%',
|
||||
height: '100%'
|
||||
}"
|
||||
@mousemove="(e: MouseEvent) => calculatePanoramaStyle(e)"
|
||||
@touchmove="(e:TouchEvent) =>
|
||||
calculatePanoramaStyle({
|
||||
target: e.target,
|
||||
clientX: e.touches[0].clientX,
|
||||
clientY: e.touches[0].clientY
|
||||
} as MouseEvent)"
|
||||
/>
|
||||
</Transition>
|
||||
<Transition
|
||||
|
@ -85,6 +85,8 @@ import { type Nullable } from '@speckle/shared'
|
|||
import { useElementVisibility, useResizeObserver } from '@vueuse/core'
|
||||
import { usePreviewImageBlob } from '~~/lib/projects/composables/previewImage'
|
||||
|
||||
type PanoramaStyleMouseOrTouchEvent = Pick<MouseEvent, 'target' | 'clientX' | 'clientY'>
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
previewUrl: string
|
||||
|
@ -117,17 +119,22 @@ const mainPreviewClasses = computed(
|
|||
() => 'w-full h-full bg-contain bg-no-repeat bg-center'
|
||||
)
|
||||
|
||||
let parentWidth = 1
|
||||
let parentHeight = 1
|
||||
const parentWidth = ref(0)
|
||||
const parentHeight = ref(0)
|
||||
const setParentDimensions = () => {
|
||||
parentWidth = parent.value?.getBoundingClientRect().width as number
|
||||
parentHeight = parent.value?.getBoundingClientRect().height as number
|
||||
const { width = 0, height = 0 } = parent.value?.getBoundingClientRect() || {}
|
||||
parentWidth.value = width
|
||||
parentHeight.value = height
|
||||
}
|
||||
|
||||
if (import.meta.client) useResizeObserver(document.body, () => setParentDimensions())
|
||||
|
||||
const positionMagic = ref(0)
|
||||
const calculatePanoramaStyle = (e: MouseEvent) => {
|
||||
const latestMouseEvent = ref<PanoramaStyleMouseOrTouchEvent>()
|
||||
const calculatePanoramaStyle = (e: PanoramaStyleMouseOrTouchEvent) => {
|
||||
latestMouseEvent.value = e
|
||||
const rect = panorama.value?.getBoundingClientRect()
|
||||
if (parentHeight.value === 0) setParentDimensions()
|
||||
if (!rect) return
|
||||
|
||||
const x = e.clientX - rect.left
|
||||
|
@ -135,9 +142,9 @@ const calculatePanoramaStyle = (e: MouseEvent) => {
|
|||
let index = Math.abs(24 - Math.round(x / step))
|
||||
if (index >= 24) index = 24 - 1
|
||||
|
||||
const scaleFactor = parentHeight / 400
|
||||
const scaleFactor = parentHeight.value / 400
|
||||
const actualWidth = scaleFactor * 700
|
||||
const widthDiff = (parentWidth - actualWidth) * 0.5
|
||||
const widthDiff = (parentWidth.value - actualWidth) * 0.5
|
||||
positionMagic.value = -(actualWidth * (2 * index + 1) - widthDiff)
|
||||
}
|
||||
|
||||
|
@ -158,6 +165,15 @@ watch(hovered, (newVal) => {
|
|||
shouldLoadPanorama.value = true
|
||||
})
|
||||
|
||||
watch(
|
||||
() => unref(panoramaPreviewUrl),
|
||||
() => {
|
||||
if (latestMouseEvent.value) {
|
||||
calculatePanoramaStyle(latestMouseEvent.value)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (import.meta.client) {
|
||||
// Trigger transitions when preview image changes
|
||||
watch(finalPreviewUrl, (newVal, oldVal) => {
|
||||
|
|
|
@ -48,8 +48,10 @@
|
|||
<div
|
||||
class="text-xs text-foreground-2 mr-1 opacity-0 truncate transition group-hover:opacity-100"
|
||||
>
|
||||
created
|
||||
<b>{{ createdAt }}</b>
|
||||
Created
|
||||
<span v-tippy="createdAt.full">
|
||||
{{ createdAt.relative }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full flex" @click.stop>
|
||||
<FormCheckbox
|
||||
|
@ -86,7 +88,6 @@
|
|||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import dayjs from 'dayjs'
|
||||
import type {
|
||||
PendingFileUploadFragment,
|
||||
ProjectModelPageVersionsCardVersionFragment
|
||||
|
@ -141,12 +142,18 @@ const showActionsMenu = ref(false)
|
|||
const hasAutomationStatus = computed(
|
||||
() => !isPendingVersionFragment(props.version) && props.version.automationsStatus
|
||||
)
|
||||
|
||||
const createdAt = computed(() => {
|
||||
const date = isPendingVersionFragment(props.version)
|
||||
? props.version.convertedLastUpdate || props.version.uploadDate
|
||||
: props.version.createdAt
|
||||
return dayjs(date).from(dayjs())
|
||||
|
||||
return {
|
||||
full: formattedFullDate(date),
|
||||
relative: formattedRelativeDate(date, { prefix: true })
|
||||
}
|
||||
})
|
||||
|
||||
const viewerRoute = computed(() => {
|
||||
if (isPendingVersionFragment(props.version)) return undefined
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
</FormTextInput>
|
||||
<div
|
||||
v-if="searchUsers.length || selectedEmails?.length"
|
||||
class="flex flex-col border bg-foundation border-primary-muted mt-2"
|
||||
class="flex flex-col border bg-foundation border-primary-muted mt-2 rounded-md"
|
||||
>
|
||||
<template v-if="searchUsers.length">
|
||||
<ProjectPageTeamDialogInviteUserServerUserRow
|
||||
|
@ -43,7 +43,7 @@
|
|||
:stream-role="role"
|
||||
:disabled="loading"
|
||||
:is-guest-mode="isGuestMode"
|
||||
class="mx-1 my-2"
|
||||
class="p-2"
|
||||
@invite-emails="($event) => onInviteUser($event.emails, $event.serverRole)"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<ProjectPageMoreActionsCard :class="cardWidthClasses">
|
||||
<template #title>Webhooks</template>
|
||||
<template #description>
|
||||
If you need to use webhooks, ask the stream's owner to grant you ownership.
|
||||
If you need to use webhooks, ask the project's owner to grant you ownership.
|
||||
</template>
|
||||
</ProjectPageMoreActionsCard>
|
||||
</div>
|
||||
|
|
|
@ -38,8 +38,8 @@
|
|||
{{ thread.repliesCount.totalCount }}
|
||||
{{ thread.repliesCount.totalCount === 1 ? 'reply' : 'replies' }}
|
||||
</span>
|
||||
<span class="text-xs">
|
||||
{{ updatedAt }}
|
||||
<span v-tippy="updatedAt.full" class="text-xs">
|
||||
{{ updatedAt.relative }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -48,7 +48,6 @@
|
|||
</NuxtLink>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import type { ProjectPageLatestItemsCommentItemFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { useCommentScreenshotImage } from '~~/lib/projects/composables/previewImage'
|
||||
import { times } from 'lodash-es'
|
||||
|
@ -65,12 +64,17 @@ const { screenshot } = useCommentScreenshotImage(
|
|||
computed(() => props.thread.screenshot)
|
||||
)
|
||||
|
||||
const updatedAt = computed(() => dayjs(props.thread.updatedAt).from(dayjs()))
|
||||
|
||||
const hiddenReplyAuthorCount = computed(
|
||||
() => props.thread.replyAuthors.totalCount - props.thread.replyAuthors.items.length
|
||||
)
|
||||
|
||||
const updatedAt = computed(() => {
|
||||
return {
|
||||
full: formattedFullDate(props.thread.updatedAt),
|
||||
relative: formattedRelativeDate(props.thread.updatedAt, { capitalize: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Combined thread authors set of (original author + any respondents)
|
||||
const threadAuthors = computed(() => {
|
||||
const authors = [props.thread.author]
|
||||
|
|
|
@ -25,7 +25,9 @@
|
|||
<div class="flex space-x-4 items-center flex-none pb-8 sm:pb-0">
|
||||
<div class="absolute sm:relative w-full bottom-2 sm:bottom-0 left-0 px-2 gap-8">
|
||||
<div class="w-full px-2 flex justify-between text-xs">
|
||||
{{ updatedAt }}
|
||||
<span v-tippy="updatedAt.full" class="text-foreground-2 text-xs">
|
||||
{{ updatedAt.relative }}
|
||||
</span>
|
||||
<span class="ml-4 text-xs font-bold text-primary">
|
||||
{{ thread.repliesCount.totalCount }}
|
||||
{{ thread.repliesCount.totalCount === 1 ? 'reply' : 'replies' }}
|
||||
|
@ -40,7 +42,6 @@
|
|||
</NuxtLink>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { times } from 'lodash-es'
|
||||
import type { ProjectPageLatestItemsCommentItemFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { useCommentScreenshotImage } from '~~/lib/projects/composables/previewImage'
|
||||
|
@ -57,7 +58,13 @@ const { backgroundImage } = useCommentScreenshotImage(
|
|||
computed(() => props.thread.screenshot)
|
||||
)
|
||||
|
||||
const updatedAt = computed(() => dayjs(props.thread.updatedAt).from(dayjs()))
|
||||
const updatedAt = computed(() => {
|
||||
return {
|
||||
full: formattedFullDate(props.thread.updatedAt),
|
||||
relative: formattedRelativeDate(props.thread.updatedAt, { capitalize: true })
|
||||
}
|
||||
})
|
||||
|
||||
const hiddenReplyAuthorCount = computed(
|
||||
() => props.thread.replyAuthors.totalCount - props.thread.replyAuthors.items.length
|
||||
)
|
||||
|
|
|
@ -60,7 +60,7 @@
|
|||
<div class="hidden sm:flex grow" />
|
||||
<div class="flex items-center">
|
||||
<ProjectPageModelsCardUpdatedTime
|
||||
:updated-at="updatedAt"
|
||||
:updated-at="updatedAtFullDate"
|
||||
:class="`text-xs w-full text-foreground-2 sm:mr-1 truncate transition ${
|
||||
hovered ? 'sm:w-auto' : 'sm:w-0'
|
||||
}`"
|
||||
|
@ -117,7 +117,6 @@
|
|||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import dayjs from 'dayjs'
|
||||
import type {
|
||||
PendingFileUploadFragment,
|
||||
ProjectPageLatestItemsModelItemFragment,
|
||||
|
@ -200,11 +199,10 @@ const defaultLinkDisabled = computed(
|
|||
() => props.disableDefaultLink || versionCount.value < 1
|
||||
)
|
||||
|
||||
const updatedAt = computed(() => {
|
||||
const date = isPendingModelFragment(props.model)
|
||||
const updatedAtFullDate = computed(() => {
|
||||
return isPendingModelFragment(props.model)
|
||||
? props.model.convertedLastUpdate || props.model.uploadDate
|
||||
: props.model.updatedAt
|
||||
return dayjs(date).from(dayjs())
|
||||
})
|
||||
const finalShowVersions = computed(
|
||||
() => props.showVersions && !isPendingModelFragment(props.model)
|
||||
|
|
|
@ -89,7 +89,9 @@
|
|||
class="text-xs text-foreground-2 absolute top-2 right-2 z-10 sm:relative sm:top-auto sm:right-auto"
|
||||
>
|
||||
Updated
|
||||
<b>{{ updatedAt }}</b>
|
||||
<span v-tippy="updatedAt.full">
|
||||
{{ updatedAt.relative }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-foreground-2 flex items-center space-x-1">
|
||||
<span>{{ model?.commentThreadCount.totalCount }}</span>
|
||||
|
@ -173,7 +175,9 @@
|
|||
</div> -->
|
||||
<div class="text-xs text-foreground-2">
|
||||
Updated
|
||||
<b>{{ updatedAt }}</b>
|
||||
<span v-tippy="updatedAt.full">
|
||||
{{ updatedAt.relative }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-foreground-2">
|
||||
<FormButton
|
||||
|
@ -232,7 +236,6 @@
|
|||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import dayjs from 'dayjs'
|
||||
import { modelVersionsRoute, modelRoute } from '~~/lib/common/helpers/route'
|
||||
import { ChevronDownIcon, PlusIcon } from '@heroicons/vue/20/solid'
|
||||
import {
|
||||
|
@ -385,7 +388,10 @@ const updatedAt = computed(() => {
|
|||
? props.item.convertedLastUpdate || props.item.uploadDate
|
||||
: props.item.updatedAt
|
||||
|
||||
return dayjs(date).from(dayjs())
|
||||
return {
|
||||
full: formattedFullDate(date),
|
||||
relative: formattedRelativeDate(date, { prefix: true })
|
||||
}
|
||||
})
|
||||
|
||||
const modelLink = computed(() => {
|
||||
|
|
|
@ -1,16 +1,26 @@
|
|||
<template>
|
||||
<div>
|
||||
updated
|
||||
<b>{{ updatedAt }}</b>
|
||||
Updated
|
||||
<span v-tippy="updatedAtFormatted.full">
|
||||
{{ updatedAtFormatted.relative }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { formattedFullDate } from '~/utils/dateFormatter'
|
||||
/**
|
||||
* Separate component so that hydration mismatches only cause this component to re-render, not the entire model card.
|
||||
* Hydration mismatches can happen here when the server resolves the update as X minutes ago, but the client resolves it as X minutes and 1 second ago.
|
||||
*/
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
updatedAt: string
|
||||
}>()
|
||||
|
||||
const updatedAtFormatted = computed(() => {
|
||||
return {
|
||||
full: formattedFullDate(props.updatedAt),
|
||||
relative: formattedRelativeDate(props.updatedAt, { prefix: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
:key="collaborator.id"
|
||||
class="bg-foundation flex items-center gap-2 py-3 px-4 border-t border-x last:border-b border-outline-3 first:rounded-t-lg last:rounded-b-lg"
|
||||
>
|
||||
<UserAvatar :user="collaborator.user" size="sm" />
|
||||
<UserAvatar :user="collaborator.user" />
|
||||
<span class="grow truncate text-sm">{{ collaborator.title }}</span>
|
||||
|
||||
<template v-if="!collaborator.inviteId">
|
||||
|
@ -40,7 +40,7 @@
|
|||
<FormButton
|
||||
class="shrink-0"
|
||||
color="danger"
|
||||
size="xs"
|
||||
size="sm"
|
||||
:disabled="loading"
|
||||
@click="
|
||||
cancelInvite({
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<UserAvatar />
|
||||
<span class="grow truncate">{{ selectedEmails.join(', ') }}</span>
|
||||
<span class="grow truncate text-sm">{{ selectedEmails.join(', ') }}</span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormSelectServerRoles
|
||||
v-if="showServerRoleSelect"
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<div
|
||||
class="flex even:bg-primary-muted odd:bg-foundation-2 py-1 px-2 items-center space-x-2"
|
||||
class="flex even:bg-primary-muted odd:bg-foundation-2 p-2 items-center space-x-2"
|
||||
>
|
||||
<UserAvatar :user="user" size="sm" />
|
||||
<span class="grow truncate text-xs">{{ user.name }}</span>
|
||||
<UserAvatar :user="user" />
|
||||
<span class="grow truncate text-sm">{{ user.name }}</span>
|
||||
<span
|
||||
v-tippy="
|
||||
isTryingToSetGuestOwner ? `Server guests can't be project owners` : undefined
|
||||
|
@ -11,7 +11,6 @@
|
|||
>
|
||||
<FormButton
|
||||
:disabled="isButtonDisabled"
|
||||
size="xs"
|
||||
@click="() => $emit('invite-user', { user, streamRole })"
|
||||
>
|
||||
Invite
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
<div class="flex-grow"></div>
|
||||
<div class="text-xs text-foreground-2 flex items-center">
|
||||
<UserCircleIcon class="w-4 h-4 mr-1" />
|
||||
<span class="-mt-px">
|
||||
<span class="-mt-px capitalize">
|
||||
{{ project.role?.split(':').reverse()[0] }}
|
||||
</span>
|
||||
</div>
|
||||
|
@ -29,9 +29,9 @@
|
|||
</div> -->
|
||||
<div class="text-xs text-foreground-2 flex items-center">
|
||||
<ClockIcon class="w-4 h-4 mr-1" />
|
||||
<span class="-mt-px">
|
||||
updated
|
||||
<b>{{ updatedAt }}</b>
|
||||
<span v-tippy="updatedAt.full" class="-mt-px">
|
||||
Updated
|
||||
{{ updatedAt.relative }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -76,7 +76,6 @@
|
|||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import dayjs from 'dayjs'
|
||||
import type { ProjectDashboardItemFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { UserCircleIcon, ClockIcon } from '@heroicons/vue/24/outline'
|
||||
import { projectRoute, allProjectModelsRoute } from '~~/lib/common/helpers/route'
|
||||
|
@ -88,6 +87,13 @@ const props = defineProps<{
|
|||
|
||||
const projectId = computed(() => props.project.id)
|
||||
|
||||
const updatedAt = computed(() => {
|
||||
return {
|
||||
full: formattedFullDate(props.project.updatedAt),
|
||||
relative: formattedRelativeDate(props.project.updatedAt, { prefix: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Tracking updates to project, its models and versions
|
||||
useGeneralProjectPageUpdateTracking(
|
||||
{ projectId },
|
||||
|
@ -100,7 +106,6 @@ const models = computed(() => {
|
|||
const items = props.project.models?.items || []
|
||||
return items.slice(0, Math.max(0, 4 - pendingModels.value.length))
|
||||
})
|
||||
const updatedAt = computed(() => dayjs(props.project.updatedAt).from(dayjs()))
|
||||
|
||||
const hasNoModels = computed(() => !models.value.length && !pendingModels.value.length)
|
||||
const modelItemTotalCount = computed(
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
<div class="text-foreground">
|
||||
<span class="font-bold">{{ invite.invitedBy.name }}</span>
|
||||
has invited you to be part of the team from
|
||||
<template v-if="showStreamName">the project {{ invite.projectName }}.</template>
|
||||
<template v-if="showProjectName">
|
||||
the project {{ invite.projectName }}.
|
||||
</template>
|
||||
<template v-else>this project.</template>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -16,7 +18,7 @@
|
|||
color="danger"
|
||||
text
|
||||
:full-width="block"
|
||||
@click="useInvite(false)"
|
||||
@click="processInvite(false)"
|
||||
>
|
||||
Decline
|
||||
</FormButton>
|
||||
|
@ -25,7 +27,7 @@
|
|||
:size="buttonSize"
|
||||
class="px-4"
|
||||
:icon-left="CheckIcon"
|
||||
@click="useInvite(true)"
|
||||
@click="processInvite(true)"
|
||||
>
|
||||
Accept
|
||||
</FormButton>
|
||||
|
@ -51,12 +53,10 @@ import {
|
|||
useNavigateToLogin,
|
||||
useNavigateToRegistration
|
||||
} from '~~/lib/common/helpers/route'
|
||||
import { useProcessProjectInvite } from '~~/lib/projects/composables/projectManagement'
|
||||
import { usePostAuthRedirect } from '~~/lib/auth/composables/postAuthRedirect'
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import { CheckIcon } from '@heroicons/vue/24/solid'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { ToastNotificationType, useGlobalToast } from '~/lib/common/composables/toast'
|
||||
import { useProjectInviteManager } from '~/lib/projects/composables/invites'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectsInviteBanner on PendingStreamCollaborator {
|
||||
|
@ -80,26 +80,23 @@ const emit = defineEmits<{
|
|||
const props = withDefaults(
|
||||
defineProps<{
|
||||
invite?: ProjectsInviteBannerFragment
|
||||
showStreamName?: boolean
|
||||
autoAccept?: boolean
|
||||
showProjectName?: boolean
|
||||
/**
|
||||
* Render this as a big block, instead of a small row. Used in full-page project access error pages.
|
||||
*/
|
||||
block?: boolean
|
||||
}>(),
|
||||
{ showStreamName: true }
|
||||
{ showProjectName: true }
|
||||
)
|
||||
|
||||
const route = useRoute()
|
||||
const { isLoggedIn } = useActiveUser()
|
||||
const processInvite = useProcessProjectInvite()
|
||||
const { useInvite } = useProjectInviteManager()
|
||||
const postAuthRedirect = usePostAuthRedirect()
|
||||
const goToLogin = useNavigateToLogin()
|
||||
const goToSignUp = useNavigateToRegistration()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
|
||||
const loading = ref(false)
|
||||
const mp = useMixpanel()
|
||||
const token = computed(
|
||||
() => props.invite?.token || (route.query.token as Optional<string>)
|
||||
)
|
||||
|
@ -131,34 +128,19 @@ const mainInfoBlockClasses = computed(() => {
|
|||
const buttonSize = computed(() => (props.block ? 'lg' : 'sm'))
|
||||
const avatarSize = computed(() => (props.block ? 'xxl' : 'base'))
|
||||
|
||||
const useInvite = async (accept: boolean) => {
|
||||
const processInvite = async (accept: boolean) => {
|
||||
if (!token.value || !props.invite) return
|
||||
|
||||
loading.value = true
|
||||
const success = await processInvite(
|
||||
{
|
||||
projectId: props.invite.projectId,
|
||||
accept,
|
||||
token: token.value
|
||||
},
|
||||
{ inviteId: props.invite.id }
|
||||
)
|
||||
loading.value = false
|
||||
|
||||
if (!success) return
|
||||
|
||||
emit('processed', { accepted: accept })
|
||||
if (accept) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: "You've joined the project!"
|
||||
})
|
||||
}
|
||||
|
||||
mp.track('Invite Action', {
|
||||
type: 'project invite',
|
||||
accepted: accept
|
||||
const success = await useInvite({
|
||||
projectId: props.invite.projectId,
|
||||
accept,
|
||||
token: token.value,
|
||||
inviteId: props.invite.id
|
||||
})
|
||||
loading.value = false
|
||||
if (!success) return
|
||||
emit('processed', { accepted: accept })
|
||||
}
|
||||
|
||||
const onLoginSignupClick = async () => {
|
||||
|
@ -175,16 +157,4 @@ const onLoginSignupClick = async () => {
|
|||
await goToSignUp({ query })
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.client) {
|
||||
watch(
|
||||
() => props.autoAccept,
|
||||
async (newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
await useInvite(true)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -2,7 +2,12 @@
|
|||
<!-- Breakout div from main container -->
|
||||
|
||||
<div class="flex flex-col">
|
||||
<ProjectsInviteBanner v-for="item in items" :key="item.id" :invite="item" />
|
||||
<ProjectsInviteBanner
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:invite="item"
|
||||
@processed="$emit('processed', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
@ -17,6 +22,10 @@ graphql(`
|
|||
}
|
||||
`)
|
||||
|
||||
defineEmits<{
|
||||
(e: 'processed', val: { accepted: boolean }): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
invites: ProjectsInviteBannersFragment
|
||||
}>()
|
||||
|
|
|
@ -61,7 +61,7 @@ const deleteConfirmed = async () => {
|
|||
},
|
||||
{
|
||||
update: (cache, { data }) => {
|
||||
if (data?.streamsDelete) {
|
||||
if (data?.projectMutations.batchDelete) {
|
||||
// Remove project from cache
|
||||
const cacheId = getCacheId('Project', projectId)
|
||||
cache.evict({
|
||||
|
@ -103,7 +103,7 @@ const deleteConfirmed = async () => {
|
|||
}
|
||||
).catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (result?.data?.streamsDelete) {
|
||||
if (result?.data?.projectMutations.batchDelete) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Project deleted',
|
||||
|
|
|
@ -275,6 +275,16 @@ import { useFunctionRunsStatusSummary } from '~/lib/automate/composables/runStat
|
|||
|
||||
const isGendoEnabled = useIsGendoModuleEnabled()
|
||||
|
||||
enum ViewerKeyboardActions {
|
||||
ToggleModels = 'ToggleModels',
|
||||
ToggleExplorer = 'ToggleExplorer',
|
||||
ToggleDiscussions = 'ToggleDiscussions',
|
||||
ToggleMeasurements = 'ToggleMeasurements',
|
||||
ToggleProjection = 'ToggleProjection',
|
||||
ToggleSectionBox = 'ToggleSectionBox',
|
||||
ZoomExtentsOrSelection = 'ZoomExtentsOrSelection'
|
||||
}
|
||||
|
||||
const width = ref(360)
|
||||
const scrollableControlsContainer = ref(null as Nullable<HTMLDivElement>)
|
||||
|
||||
|
@ -364,28 +374,72 @@ const {
|
|||
diff: { enabled }
|
||||
} = useInjectedViewerInterfaceState()
|
||||
|
||||
const map: Record<ViewerKeyboardActions, [ModifierKeys[], string]> = {
|
||||
[ViewerKeyboardActions.ToggleModels]: [[ModifierKeys.Shift], 'm'],
|
||||
[ViewerKeyboardActions.ToggleExplorer]: [[ModifierKeys.Shift], 'e'],
|
||||
[ViewerKeyboardActions.ToggleDiscussions]: [[ModifierKeys.Shift], 't'],
|
||||
[ViewerKeyboardActions.ToggleMeasurements]: [[ModifierKeys.Shift], 'r'],
|
||||
[ViewerKeyboardActions.ToggleProjection]: [[ModifierKeys.Shift], 'p'],
|
||||
[ViewerKeyboardActions.ToggleSectionBox]: [[ModifierKeys.Shift], 'b'],
|
||||
[ViewerKeyboardActions.ZoomExtentsOrSelection]: [[ModifierKeys.Shift], 'space']
|
||||
}
|
||||
|
||||
const getShortcutTitle = (action: ViewerKeyboardActions) =>
|
||||
`(${getKeyboardShortcutTitle([...map[action][0], map[action][1]])})`
|
||||
|
||||
const modelsShortcut = ref(
|
||||
`Models (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 'm'])})`
|
||||
`Models ${getShortcutTitle(ViewerKeyboardActions.ToggleModels)}`
|
||||
)
|
||||
const explorerShortcut = ref(
|
||||
`Scene Explorer (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 'e'])})`
|
||||
`Scene Explorer ${getShortcutTitle(ViewerKeyboardActions.ToggleExplorer)}`
|
||||
)
|
||||
const discussionsShortcut = ref(
|
||||
`Discussions (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 't'])})`
|
||||
`Discussions ${getShortcutTitle(ViewerKeyboardActions.ToggleDiscussions)}`
|
||||
)
|
||||
const zoomExtentsShortcut = ref(
|
||||
`Fit to screen (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 'Space'])})`
|
||||
`Fit to screen ${getShortcutTitle(ViewerKeyboardActions.ZoomExtentsOrSelection)}`
|
||||
)
|
||||
const projectionShortcut = ref(
|
||||
`Projection (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 'p'])})`
|
||||
`Projection ${getShortcutTitle(ViewerKeyboardActions.ToggleProjection)}`
|
||||
)
|
||||
const sectionBoxShortcut = ref(
|
||||
`Section Box (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 'b'])})`
|
||||
`Section Box ${getShortcutTitle(ViewerKeyboardActions.ToggleSectionBox)}`
|
||||
)
|
||||
const measureShortcut = ref(
|
||||
`Measure Mode (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 'd'])})`
|
||||
`Measure Mode ${getShortcutTitle(ViewerKeyboardActions.ToggleMeasurements)}`
|
||||
)
|
||||
|
||||
const handleKeyboardAction = (action: ViewerKeyboardActions) => {
|
||||
switch (action) {
|
||||
case ViewerKeyboardActions.ToggleModels:
|
||||
toggleActiveControl('models')
|
||||
break
|
||||
case ViewerKeyboardActions.ToggleExplorer:
|
||||
toggleActiveControl('explorer')
|
||||
break
|
||||
case ViewerKeyboardActions.ToggleDiscussions:
|
||||
toggleActiveControl('discussions')
|
||||
break
|
||||
case ViewerKeyboardActions.ToggleMeasurements:
|
||||
toggleMeasurements()
|
||||
break
|
||||
case ViewerKeyboardActions.ToggleProjection:
|
||||
trackAndtoggleProjection()
|
||||
break
|
||||
case ViewerKeyboardActions.ToggleSectionBox:
|
||||
toggleSectionBox()
|
||||
break
|
||||
case ViewerKeyboardActions.ZoomExtentsOrSelection:
|
||||
trackAndzoomExtentsOrSelection()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
Object.entries(map).forEach(([actionKey, [modifiers, key]]) => {
|
||||
const action = actionKey as ViewerKeyboardActions
|
||||
onKeyboardShortcut(modifiers, key, () => handleKeyboardAction(action))
|
||||
})
|
||||
|
||||
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
|
||||
|
||||
const toggleActiveControl = (control: ActiveControl) => {
|
||||
|
@ -396,33 +450,6 @@ const toggleActiveControl = (control: ActiveControl) => {
|
|||
activeControl.value = activeControl.value === control ? 'none' : control
|
||||
}
|
||||
|
||||
onKeyboardShortcut([ModifierKeys.AltOrOpt], 'm', () => {
|
||||
toggleActiveControl('models')
|
||||
})
|
||||
onKeyboardShortcut([ModifierKeys.AltOrOpt], 'e', () => {
|
||||
toggleActiveControl('explorer')
|
||||
})
|
||||
onKeyboardShortcut([ModifierKeys.AltOrOpt], 'f', () => {
|
||||
toggleActiveControl('filters')
|
||||
})
|
||||
onKeyboardShortcut([ModifierKeys.AltOrOpt], ['t'], () => {
|
||||
toggleActiveControl('discussions')
|
||||
})
|
||||
onKeyboardShortcut([ModifierKeys.AltOrOpt], 'd', () => {
|
||||
toggleActiveControl('measurements')
|
||||
})
|
||||
|
||||
// Viewer actions kbd shortcuts
|
||||
onKeyboardShortcut([ModifierKeys.AltOrOpt], ' ', () => {
|
||||
trackAndzoomExtentsOrSelection()
|
||||
})
|
||||
onKeyboardShortcut([ModifierKeys.AltOrOpt], 'p', () => {
|
||||
toggleProjection()
|
||||
})
|
||||
onKeyboardShortcut([ModifierKeys.AltOrOpt], 'b', () => {
|
||||
toggleSectionBox()
|
||||
})
|
||||
|
||||
const mp = useMixpanel()
|
||||
watch(activeControl, (newVal) => {
|
||||
mp.track('Viewer Action', { type: 'action', name: 'controls-toggle', action: newVal })
|
||||
|
|
|
@ -10,8 +10,11 @@
|
|||
<span class="grow truncate text-xs sm:text-sm font-bold">
|
||||
{{ comment.author.name }}
|
||||
</span>
|
||||
<span class="text-xs truncate text-foreground-2 font-medium sm:font-bold">
|
||||
{{ timeFromNow }}
|
||||
<span
|
||||
v-tippy="createdAt.full"
|
||||
class="text-xs truncate text-foreground-2 font-medium"
|
||||
>
|
||||
{{ createdAt.relative }}
|
||||
</span>
|
||||
<!-- Note: disabled as archiving comments is now equivalent to "resolving" them. -->
|
||||
<!-- <div class="pl-2">
|
||||
|
@ -43,7 +46,6 @@
|
|||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
// import { Roles } from '@speckle/shared'
|
||||
// import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import type { ViewerCommentsReplyItemFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
|
@ -59,6 +61,13 @@ const emit = defineEmits<{
|
|||
(e: 'mounted'): void
|
||||
}>()
|
||||
|
||||
const createdAt = computed(() => {
|
||||
return {
|
||||
full: formattedFullDate(props.comment.createdAt),
|
||||
relative: formattedRelativeDate(props.comment.createdAt, { capitalize: true })
|
||||
}
|
||||
})
|
||||
|
||||
// const archiveComment = useArchiveComment()
|
||||
// const { activeUser } = useActiveUser()
|
||||
// const {
|
||||
|
@ -77,5 +86,4 @@ const emit = defineEmits<{
|
|||
// const absoluteDate = computed(() =>
|
||||
// dayjs(props.comment.createdAt).toDate().toLocaleString()
|
||||
// )
|
||||
const timeFromNow = computed(() => dayjs(props.comment.createdAt).fromNow())
|
||||
</script>
|
||||
|
|
|
@ -46,8 +46,8 @@
|
|||
{{ thread.replies.totalCount }}
|
||||
{{ thread.replies.totalCount === 1 ? 'reply' : 'replies' }}
|
||||
</span>
|
||||
<span class="text-foreground-2 text-xs">
|
||||
{{ formattedDate }}
|
||||
<span v-tippy="createdAt.full" class="text-foreground-2 text-xs">
|
||||
{{ createdAt.relative }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -62,7 +62,6 @@ import {
|
|||
useInjectedViewerLoadedResources,
|
||||
useInjectedViewerState
|
||||
} from '~~/lib/viewer/composables/setup'
|
||||
import dayjs from 'dayjs'
|
||||
import { ResourceType } from '~~/lib/common/generated/gql/graphql'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import { useArchiveComment } from '~~/lib/viewer/composables/commentManagement'
|
||||
|
@ -92,7 +91,12 @@ const open = (id: string) => {
|
|||
})
|
||||
}
|
||||
|
||||
const formattedDate = computed(() => dayjs(props.thread.createdAt).from(dayjs()))
|
||||
const createdAt = computed(() => {
|
||||
return {
|
||||
full: formattedFullDate(props.thread.createdAt),
|
||||
relative: formattedRelativeDate(props.thread.createdAt, { capitalize: true })
|
||||
}
|
||||
})
|
||||
|
||||
const isThreadResourceLoaded = computed(() => {
|
||||
const thread = props.thread
|
||||
|
|
|
@ -4,17 +4,18 @@
|
|||
<PreviewImage :preview-url="version.previewUrl" />
|
||||
</div>
|
||||
<div
|
||||
v-tippy="createdAt"
|
||||
v-tippy="createdAt.full"
|
||||
class="bg-foundation-focus inline-block rounded-md px-2 text-xs font-bold truncate text-center py-1"
|
||||
>
|
||||
{{ timeAgoCreatedAt }}
|
||||
<span>
|
||||
{{ createdAt.relative }}
|
||||
</span>
|
||||
<br />
|
||||
{{ isNewest ? 'New' : 'Old' }} Version
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import type { ViewerModelVersionCardItemFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
|
||||
const props = defineProps<{
|
||||
|
@ -22,9 +23,10 @@ const props = defineProps<{
|
|||
isNewest: boolean
|
||||
}>()
|
||||
|
||||
const timeAgoCreatedAt = computed(() => dayjs(props.version.createdAt).from(dayjs()))
|
||||
|
||||
const createdAt = computed(() => {
|
||||
return dayjs(props.version.createdAt).format('LLL')
|
||||
return {
|
||||
full: formattedFullDate(props.version.createdAt),
|
||||
relative: formattedRelativeDate(props.version.createdAt, { capitalize: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -26,7 +26,7 @@ const props = defineProps<{
|
|||
}>()
|
||||
|
||||
const { result, loading, load } = useLazyQuery(viewerRawObjectQuery, () => ({
|
||||
streamId: projectId.value,
|
||||
projectId: projectId.value,
|
||||
objectId: props.object['referencedId'] as string
|
||||
}))
|
||||
|
||||
|
@ -35,7 +35,7 @@ if (props.object['referencedId']) {
|
|||
}
|
||||
|
||||
const kvps = computed(() => {
|
||||
const obj = (result.value?.stream?.object?.data || props.object) as Record<
|
||||
const obj = (result.value?.project?.object?.data || props.object) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
|
|
@ -307,8 +307,18 @@ const isNonEmptyObjectArray = (x: unknown) => isNonEmptyArray(x) && isObject(x[0
|
|||
const isObject = (x: unknown) =>
|
||||
typeof x === 'object' && !Array.isArray(x) && x !== null
|
||||
|
||||
const isAllowedType = (node: ExplorerNode) =>
|
||||
!['Objects.Other.DisplayStyle'].includes(node.raw?.speckle_type || '')
|
||||
const hiddenSpeckleTypes = [
|
||||
'Objects.Other.DisplayStyle',
|
||||
'Objects.Other.Revit.RevitMaterial',
|
||||
'Objects.BuiltElements.Revit.ProjectInfo',
|
||||
'Objects.BuiltElements.View',
|
||||
'Objects.BuiltElements.View3D'
|
||||
]
|
||||
|
||||
const isAllowedType = (node: ExplorerNode) => {
|
||||
const speckleType = node.raw?.speckle_type || ''
|
||||
return !hiddenSpeckleTypes.some((substring) => speckleType.includes(substring))
|
||||
}
|
||||
|
||||
const unfold = ref(false)
|
||||
|
||||
|
|
|
@ -11,17 +11,18 @@
|
|||
<div class="truncate text-foreground opacity-80">
|
||||
{{ version.message || 'no message' }}
|
||||
</div>
|
||||
<div class="italic text-foreground opacity-60">
|
||||
{{ timeAgo }}
|
||||
<div
|
||||
v-tippy="createdAt.full"
|
||||
class="italic text-foreground opacity-60 inline-block"
|
||||
>
|
||||
{{ createdAt.relative }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { EyeIcon } from '@heroicons/vue/24/solid'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Version {
|
||||
sourceApplication?: string | null
|
||||
|
@ -33,7 +34,10 @@ const props = defineProps<{
|
|||
version: Version
|
||||
}>()
|
||||
|
||||
const timeAgo = computed(() => {
|
||||
return dayjs(props.version.createdAt).from(dayjs())
|
||||
const createdAt = computed(() => {
|
||||
return {
|
||||
full: formattedFullDate(props.version.createdAt),
|
||||
relative: formattedRelativeDate(props.version.createdAt, { capitalize: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -34,12 +34,12 @@
|
|||
</div>
|
||||
<div class="truncate text-xs">
|
||||
<span
|
||||
v-tippy="createdAt"
|
||||
v-tippy="createdAtFormatted.full"
|
||||
:class="`${
|
||||
showVersions ? 'text-foundation font-semibold' : ''
|
||||
} text-xs opacity-70`"
|
||||
>
|
||||
{{ isLatest ? 'latest version' : timeAgoCreatedAt }}
|
||||
{{ isLatest ? 'Latest version' : createdAtFormatted.relative }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -176,6 +176,15 @@ const loadedVersion = computed(() =>
|
|||
versions.value.find((v) => v.id === props.versionId)
|
||||
)
|
||||
|
||||
const createdAt = computed(() => loadedVersion.value?.createdAt)
|
||||
|
||||
const createdAtFormatted = computed(() => {
|
||||
return {
|
||||
full: formattedFullDate(createdAt.value),
|
||||
relative: formattedRelativeDate(createdAt.value, { capitalize: true })
|
||||
}
|
||||
})
|
||||
|
||||
const latestVersion = computed(() => {
|
||||
return versions.value
|
||||
.slice()
|
||||
|
@ -184,14 +193,6 @@ const latestVersion = computed(() => {
|
|||
|
||||
const isLatest = computed(() => loadedVersion.value?.id === latestVersion.value.id)
|
||||
|
||||
const timeAgoCreatedAt = computed(() =>
|
||||
dayjs(loadedVersion.value?.createdAt).from(dayjs())
|
||||
)
|
||||
|
||||
const createdAt = computed(() => {
|
||||
return dayjs(loadedVersion.value?.createdAt).format('LLL')
|
||||
})
|
||||
|
||||
const latestVersionId = computed(() => latestVersion.value.id)
|
||||
|
||||
const modelName = computed(() => {
|
||||
|
|
|
@ -36,10 +36,12 @@
|
|||
</div>
|
||||
<div
|
||||
v-show="showTimeline"
|
||||
v-tippy="`${createdAt}`"
|
||||
v-tippy="createdAt.full"
|
||||
class="bg-foundation-focus inline-block rounded-full px-2 text-xs font-bold shrink-0"
|
||||
>
|
||||
<span>{{ isLatest ? 'Latest' : timeAgoCreatedAt }}</span>
|
||||
<span>
|
||||
{{ isLatest ? 'Latest' : createdAt.relative }}
|
||||
</span>
|
||||
</div>
|
||||
<FormButton
|
||||
v-if="!isLoaded"
|
||||
|
@ -107,13 +109,15 @@ const emit = defineEmits<{
|
|||
const isLoaded = computed(() => props.isLoadedVersion)
|
||||
const isLatest = computed(() => props.isLatestVersion)
|
||||
|
||||
const author = computed(() => props.version.authorUser)
|
||||
|
||||
const timeAgoCreatedAt = computed(() => dayjs(props.version.createdAt).from(dayjs()))
|
||||
const createdAt = computed(() => {
|
||||
return dayjs(props.version.createdAt).format('LLL')
|
||||
return {
|
||||
full: formattedFullDate(props.version.createdAt),
|
||||
relative: formattedRelativeDate(props.version.createdAt, { capitalize: true })
|
||||
}
|
||||
})
|
||||
|
||||
const author = computed(() => props.version.authorUser)
|
||||
|
||||
const mp = useMixpanel()
|
||||
|
||||
const handleClick = () => {
|
||||
|
|
|
@ -2,12 +2,16 @@
|
|||
* IMPORTANT: Don't use this directly in Vue templates that may render in SSR, cause this may cause the backend API origin to be rendered instead of the clientside one,
|
||||
* at least until the app finishes hydrating. If people click on links based on this too early, they may end up in the wrong place.
|
||||
*/
|
||||
export const useApiOrigin = () => {
|
||||
export const useApiOrigin = (
|
||||
options?: Partial<{
|
||||
forcePublic: boolean
|
||||
}>
|
||||
) => {
|
||||
const {
|
||||
public: { apiOrigin, backendApiOrigin }
|
||||
} = useRuntimeConfig()
|
||||
|
||||
if (import.meta.server && backendApiOrigin.length > 1) {
|
||||
if (import.meta.server && backendApiOrigin.length > 1 && !options?.forcePublic) {
|
||||
return backendApiOrigin
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
import { Roles, type MaybeNullOrUndefined, md5 } from '@speckle/shared'
|
||||
import {
|
||||
Roles,
|
||||
type MaybeNullOrUndefined,
|
||||
resolveMixpanelUserId
|
||||
} from '@speckle/shared'
|
||||
import { useApolloClient, useQuery } from '@vue/apollo-composable'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
|
||||
|
@ -38,7 +42,7 @@ export function useResolveUserDistinctId() {
|
|||
if (!user) return user // null or undefined
|
||||
if (!user.email) return null
|
||||
|
||||
return '@' + md5(user.email.toLowerCase()).toUpperCase()
|
||||
return resolveMixpanelUserId(user.email)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import { useScopedState } from '~/lib/common/composables/scopedState'
|
||||
import type { Ref } from 'vue'
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import type { ToastNotification } from '@speckle/ui-components'
|
||||
import { ToastNotificationType } from '@speckle/ui-components'
|
||||
import { useSynchronizedCookie } from '~/lib/common/composables/reactiveCookie'
|
||||
|
||||
/**
|
||||
* Persisting toast state between reqs and between CSR & SSR loads so that we can trigger
|
||||
* toasts anywhere and anytime
|
||||
*/
|
||||
const useGlobalToastState = () =>
|
||||
useScopedState<Ref<Nullable<ToastNotification>>>('global-toast-state', () =>
|
||||
ref(null)
|
||||
)
|
||||
useSynchronizedCookie<Optional<ToastNotification>>('global-toast-state')
|
||||
|
||||
/**
|
||||
* Set up a new global toast manager/renderer (don't use this in multiple components that live at the same time)
|
||||
|
@ -19,6 +20,11 @@ export function useGlobalToastManager() {
|
|||
const currentNotification = ref(stateNotification.value)
|
||||
const readOnlyNotification = computed(() => currentNotification.value)
|
||||
|
||||
const dismiss = () => {
|
||||
currentNotification.value = undefined
|
||||
stateNotification.value = undefined
|
||||
}
|
||||
|
||||
const { start, stop } = useTimeoutFn(() => {
|
||||
dismiss()
|
||||
}, 4000)
|
||||
|
@ -27,6 +33,10 @@ export function useGlobalToastManager() {
|
|||
stateNotification,
|
||||
(newVal) => {
|
||||
if (!newVal) return
|
||||
if (import.meta.server) {
|
||||
currentNotification.value = newVal
|
||||
return
|
||||
}
|
||||
|
||||
// First dismiss old notification, then set a new one on next tick
|
||||
// this is so that the old one actually disappears from the screen for the user,
|
||||
|
@ -41,14 +51,9 @@ export function useGlobalToastManager() {
|
|||
if (newVal.autoClose !== false) start()
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
const dismiss = () => {
|
||||
currentNotification.value = null
|
||||
stateNotification.value = null
|
||||
}
|
||||
|
||||
return { currentNotification: readOnlyNotification, dismiss }
|
||||
}
|
||||
|
||||
|
@ -57,12 +62,17 @@ export function useGlobalToastManager() {
|
|||
*/
|
||||
export function useGlobalToast() {
|
||||
const stateNotification = useGlobalToastState()
|
||||
const logger = useLogger()
|
||||
|
||||
/**
|
||||
* Trigger a new toast notification
|
||||
*/
|
||||
const triggerNotification = (notification: ToastNotification) => {
|
||||
stateNotification.value = notification
|
||||
|
||||
if (import.meta.server) {
|
||||
logger.info('Queued SSR toast notification', notification)
|
||||
}
|
||||
}
|
||||
|
||||
return { triggerNotification }
|
||||
|
|
|
@ -194,7 +194,7 @@ const documents = {
|
|||
"\n subscription OnProjectAutomationsUpdated($id: String!) {\n projectAutomationsUpdated(projectId: $id) {\n type\n automationId\n automation {\n id\n ...ProjectPageAutomationPage_Automation\n ...ProjectPageAutomationsRow_Automation\n }\n }\n }\n": types.OnProjectAutomationsUpdatedDocument,
|
||||
"\n mutation ServerInfoUpdate($info: ServerInfoUpdateInput!) {\n serverInfoUpdate(info: $info)\n }\n": types.ServerInfoUpdateDocument,
|
||||
"\n mutation AdminPanelDeleteUser($userConfirmation: UserDeleteInput!) {\n adminDeleteUser(userConfirmation: $userConfirmation)\n }\n": types.AdminPanelDeleteUserDocument,
|
||||
"\n mutation AdminPanelDeleteProject($ids: [String!]) {\n streamsDelete(ids: $ids)\n }\n": types.AdminPanelDeleteProjectDocument,
|
||||
"\n mutation AdminPanelDeleteProject($ids: [String!]!) {\n projectMutations {\n batchDelete(ids: $ids)\n }\n }\n": types.AdminPanelDeleteProjectDocument,
|
||||
"\n mutation AdminPanelResendInvite($inviteId: String!) {\n inviteResend(inviteId: $inviteId)\n }\n": types.AdminPanelResendInviteDocument,
|
||||
"\n mutation AdminPanelDeleteInvite($inviteId: String!) {\n inviteDelete(inviteId: $inviteId)\n }\n": types.AdminPanelDeleteInviteDocument,
|
||||
"\n mutation AdminChangeUseRole($userRoleInput: UserRoleInput!) {\n userRoleChange(userRoleInput: $userRoleInput)\n }\n": types.AdminChangeUseRoleDocument,
|
||||
|
@ -224,14 +224,14 @@ const documents = {
|
|||
"\n query ViewerModelVersions(\n $projectId: String!\n $modelId: String!\n $versionsCursor: String\n ) {\n project(id: $projectId) {\n id\n role\n model(id: $modelId) {\n id\n versions(cursor: $versionsCursor, limit: 5) {\n totalCount\n cursor\n items {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n }\n": types.ViewerModelVersionsDocument,
|
||||
"\n query ViewerDiffVersions(\n $projectId: String!\n $modelId: String!\n $versionAId: String!\n $versionBId: String!\n ) {\n project(id: $projectId) {\n id\n model(id: $modelId) {\n id\n versionA: version(id: $versionAId) {\n ...ViewerModelVersionCardItem\n }\n versionB: version(id: $versionBId) {\n ...ViewerModelVersionCardItem\n }\n }\n }\n }\n": types.ViewerDiffVersionsDocument,
|
||||
"\n query ViewerLoadedThreads(\n $projectId: String!\n $filter: ProjectCommentsFilter!\n $cursor: String\n $limit: Int = 25\n ) {\n project(id: $projectId) {\n id\n commentThreads(filter: $filter, cursor: $cursor, limit: $limit) {\n totalCount\n totalArchivedCount\n items {\n ...ViewerCommentThread\n ...LinkableComment\n }\n }\n }\n }\n": types.ViewerLoadedThreadsDocument,
|
||||
"\n query Stream($streamId: String!, $objectId: String!) {\n stream(id: $streamId) {\n id\n object(id: $objectId) {\n id\n data\n }\n }\n }\n": types.StreamDocument,
|
||||
"\n query ViewerRawProjectObject($projectId: String!, $objectId: String!) {\n project(id: $projectId) {\n id\n object(id: $objectId) {\n id\n data\n }\n }\n }\n": types.ViewerRawProjectObjectDocument,
|
||||
"\n subscription OnViewerUserActivityBroadcasted(\n $target: ViewerUpdateTrackingTarget!\n $sessionId: String!\n ) {\n viewerUserActivityBroadcasted(target: $target, sessionId: $sessionId) {\n userName\n userId\n user {\n ...LimitedUserAvatar\n }\n state\n status\n sessionId\n }\n }\n": types.OnViewerUserActivityBroadcastedDocument,
|
||||
"\n subscription OnViewerCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n id\n type\n comment {\n id\n parent {\n id\n }\n ...ViewerCommentThread\n }\n }\n }\n": types.OnViewerCommentsUpdatedDocument,
|
||||
"\n fragment LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\n }\n }\n": types.LinkableCommentFragmentDoc,
|
||||
"\n query LegacyBranchRedirectMetadata($streamId: String!, $branchName: String!) {\n stream(id: $streamId) {\n branch(name: $branchName) {\n id\n }\n }\n }\n": types.LegacyBranchRedirectMetadataDocument,
|
||||
"\n query LegacyViewerCommitRedirectMetadata($streamId: String!, $commitId: String!) {\n stream(id: $streamId) {\n commit(id: $commitId) {\n id\n branch {\n id\n }\n }\n }\n }\n": types.LegacyViewerCommitRedirectMetadataDocument,
|
||||
"\n query LegacyViewerStreamRedirectMetadata($streamId: String!) {\n stream(id: $streamId) {\n id\n commits(limit: 1) {\n totalCount\n items {\n id\n branch {\n id\n }\n }\n }\n }\n }\n": types.LegacyViewerStreamRedirectMetadataDocument,
|
||||
"\n query ResolveCommentLink($commentId: String!, $projectId: String!) {\n comment(id: $commentId, streamId: $projectId) {\n ...LinkableComment\n }\n }\n": types.ResolveCommentLinkDocument,
|
||||
"\n query LegacyBranchRedirectMetadata($streamId: String!, $branchName: String!) {\n project(id: $streamId) {\n modelByName(name: $branchName) {\n id\n }\n }\n }\n": types.LegacyBranchRedirectMetadataDocument,
|
||||
"\n query LegacyViewerCommitRedirectMetadata($streamId: String!, $commitId: String!) {\n project(id: $streamId) {\n version(id: $commitId) {\n id\n model {\n id\n }\n }\n }\n }\n": types.LegacyViewerCommitRedirectMetadataDocument,
|
||||
"\n query LegacyViewerStreamRedirectMetadata($streamId: String!) {\n project(id: $streamId) {\n id\n versions(limit: 1) {\n totalCount\n items {\n id\n model {\n id\n }\n }\n }\n }\n }\n": types.LegacyViewerStreamRedirectMetadataDocument,
|
||||
"\n query ResolveCommentLink($commentId: String!, $projectId: String!) {\n project(id: $projectId) {\n comment(id: $commentId) {\n id\n ...LinkableComment\n }\n }\n }\n": types.ResolveCommentLinkDocument,
|
||||
"\n fragment AutomateFunctionPage_AutomateFunction on AutomateFunction {\n id\n name\n description\n logo\n supportedSourceApps\n tags\n ...AutomateFunctionPageHeader_Function\n ...AutomateFunctionPageInfo_AutomateFunction\n ...AutomateAutomationCreateDialog_AutomateFunction\n creator {\n id\n }\n }\n": types.AutomateFunctionPage_AutomateFunctionFragmentDoc,
|
||||
"\n query AutomateFunctionPage($functionId: ID!) {\n automateFunction(id: $functionId) {\n ...AutomateFunctionPage_AutomateFunction\n }\n }\n": types.AutomateFunctionPageDocument,
|
||||
"\n query AutomateFunctionsPage($search: String, $cursor: String = null) {\n ...AutomateFunctionsPageItems_Query\n ...AutomateFunctionsPageHeader_Query\n }\n": types.AutomateFunctionsPageDocument,
|
||||
|
@ -982,7 +982,7 @@ export function graphql(source: "\n mutation AdminPanelDeleteUser($userConfirma
|
|||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation AdminPanelDeleteProject($ids: [String!]) {\n streamsDelete(ids: $ids)\n }\n"): (typeof documents)["\n mutation AdminPanelDeleteProject($ids: [String!]) {\n streamsDelete(ids: $ids)\n }\n"];
|
||||
export function graphql(source: "\n mutation AdminPanelDeleteProject($ids: [String!]!) {\n projectMutations {\n batchDelete(ids: $ids)\n }\n }\n"): (typeof documents)["\n mutation AdminPanelDeleteProject($ids: [String!]!) {\n projectMutations {\n batchDelete(ids: $ids)\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
@ -1102,7 +1102,7 @@ export function graphql(source: "\n query ViewerLoadedThreads(\n $projectId:
|
|||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query Stream($streamId: String!, $objectId: String!) {\n stream(id: $streamId) {\n id\n object(id: $objectId) {\n id\n data\n }\n }\n }\n"): (typeof documents)["\n query Stream($streamId: String!, $objectId: String!) {\n stream(id: $streamId) {\n id\n object(id: $objectId) {\n id\n data\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n query ViewerRawProjectObject($projectId: String!, $objectId: String!) {\n project(id: $projectId) {\n id\n object(id: $objectId) {\n id\n data\n }\n }\n }\n"): (typeof documents)["\n query ViewerRawProjectObject($projectId: String!, $objectId: String!) {\n project(id: $projectId) {\n id\n object(id: $objectId) {\n id\n data\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
@ -1118,19 +1118,19 @@ export function graphql(source: "\n fragment LinkableComment on Comment {\n
|
|||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query LegacyBranchRedirectMetadata($streamId: String!, $branchName: String!) {\n stream(id: $streamId) {\n branch(name: $branchName) {\n id\n }\n }\n }\n"): (typeof documents)["\n query LegacyBranchRedirectMetadata($streamId: String!, $branchName: String!) {\n stream(id: $streamId) {\n branch(name: $branchName) {\n id\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n query LegacyBranchRedirectMetadata($streamId: String!, $branchName: String!) {\n project(id: $streamId) {\n modelByName(name: $branchName) {\n id\n }\n }\n }\n"): (typeof documents)["\n query LegacyBranchRedirectMetadata($streamId: String!, $branchName: String!) {\n project(id: $streamId) {\n modelByName(name: $branchName) {\n id\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query LegacyViewerCommitRedirectMetadata($streamId: String!, $commitId: String!) {\n stream(id: $streamId) {\n commit(id: $commitId) {\n id\n branch {\n id\n }\n }\n }\n }\n"): (typeof documents)["\n query LegacyViewerCommitRedirectMetadata($streamId: String!, $commitId: String!) {\n stream(id: $streamId) {\n commit(id: $commitId) {\n id\n branch {\n id\n }\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n query LegacyViewerCommitRedirectMetadata($streamId: String!, $commitId: String!) {\n project(id: $streamId) {\n version(id: $commitId) {\n id\n model {\n id\n }\n }\n }\n }\n"): (typeof documents)["\n query LegacyViewerCommitRedirectMetadata($streamId: String!, $commitId: String!) {\n project(id: $streamId) {\n version(id: $commitId) {\n id\n model {\n id\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query LegacyViewerStreamRedirectMetadata($streamId: String!) {\n stream(id: $streamId) {\n id\n commits(limit: 1) {\n totalCount\n items {\n id\n branch {\n id\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query LegacyViewerStreamRedirectMetadata($streamId: String!) {\n stream(id: $streamId) {\n id\n commits(limit: 1) {\n totalCount\n items {\n id\n branch {\n id\n }\n }\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n query LegacyViewerStreamRedirectMetadata($streamId: String!) {\n project(id: $streamId) {\n id\n versions(limit: 1) {\n totalCount\n items {\n id\n model {\n id\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query LegacyViewerStreamRedirectMetadata($streamId: String!) {\n project(id: $streamId) {\n id\n versions(limit: 1) {\n totalCount\n items {\n id\n model {\n id\n }\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query ResolveCommentLink($commentId: String!, $projectId: String!) {\n comment(id: $commentId, streamId: $projectId) {\n ...LinkableComment\n }\n }\n"): (typeof documents)["\n query ResolveCommentLink($commentId: String!, $projectId: String!) {\n comment(id: $commentId, streamId: $projectId) {\n ...LinkableComment\n }\n }\n"];
|
||||
export function graphql(source: "\n query ResolveCommentLink($commentId: String!, $projectId: String!) {\n project(id: $projectId) {\n comment(id: $commentId) {\n id\n ...LinkableComment\n }\n }\n }\n"): (typeof documents)["\n query ResolveCommentLink($commentId: String!, $projectId: String!) {\n project(id: $projectId) {\n comment(id: $commentId) {\n id\n ...LinkableComment\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
|
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
|
@ -0,0 +1,29 @@
|
|||
/* eslint-disable camelcase */
|
||||
import type { OverridedMixpanel } from 'mixpanel-browser'
|
||||
import type { Merge } from 'type-fest'
|
||||
|
||||
export type MixpanelClient = Merge<
|
||||
Pick<
|
||||
OverridedMixpanel,
|
||||
'track' | 'init' | 'reset' | 'register' | 'identify' | 'people' | 'add_group'
|
||||
>,
|
||||
{
|
||||
people: Pick<OverridedMixpanel['people'], 'set' | 'set_once'>
|
||||
}
|
||||
>
|
||||
|
||||
export const HOST_APP = 'web-2'
|
||||
export const HOST_APP_DISPLAY_NAME = 'Web 2.0 App'
|
||||
|
||||
export const fakeMixpanelClient = (): MixpanelClient => ({
|
||||
init: noop as MixpanelClient['init'],
|
||||
track: noop,
|
||||
reset: noop,
|
||||
register: noop,
|
||||
identify: noop,
|
||||
people: {
|
||||
set: noop,
|
||||
set_once: noop
|
||||
},
|
||||
add_group: noop
|
||||
})
|
|
@ -0,0 +1,121 @@
|
|||
/* eslint-disable camelcase */
|
||||
import { type Nullable, resolveMixpanelServerId } from '@speckle/shared'
|
||||
import { isString, mapKeys } from 'lodash-es'
|
||||
import { useOnAuthStateChange } from '~/lib/auth/composables/auth'
|
||||
import {
|
||||
HOST_APP,
|
||||
HOST_APP_DISPLAY_NAME,
|
||||
type MixpanelClient
|
||||
} from '~/lib/common/helpers/mp'
|
||||
import { useTheme } from '~/lib/core/composables/theme'
|
||||
|
||||
/**
|
||||
* Get mixpanel server identifier
|
||||
*/
|
||||
function getMixpanelServerId(): string {
|
||||
return resolveMixpanelServerId(window.location.hostname)
|
||||
}
|
||||
|
||||
function useMixpanelUtmCollection() {
|
||||
const route = useRoute()
|
||||
return () => {
|
||||
const campaignKeywords = [
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_content',
|
||||
'utm_term'
|
||||
]
|
||||
|
||||
const result: Record<string, string> = {}
|
||||
for (const campaignKeyword of campaignKeywords) {
|
||||
const value = route.query[campaignKeyword]
|
||||
if (value && isString(value)) {
|
||||
result[campaignKeyword] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that builds the user (re-)identification function. Needs to be invoked on app
|
||||
* init and when the active user changes (e.g. after signing out/in)
|
||||
*/
|
||||
function useMixpanelUserIdentification() {
|
||||
if (import.meta.server) return { reidentify: () => void 0 }
|
||||
|
||||
const { distinctId } = useActiveUser()
|
||||
const { isDarkTheme } = useTheme()
|
||||
const serverId = getMixpanelServerId()
|
||||
const {
|
||||
public: { speckleServerVersion }
|
||||
} = useRuntimeConfig()
|
||||
|
||||
return {
|
||||
reidentify: (mp: MixpanelClient) => {
|
||||
// Reset previous user data, if any
|
||||
mp.reset()
|
||||
|
||||
// Register session
|
||||
mp.register({
|
||||
server_id: serverId,
|
||||
hostApp: HOST_APP,
|
||||
speckleVersion: speckleServerVersion
|
||||
})
|
||||
|
||||
// Identify user, if any
|
||||
if (distinctId.value) {
|
||||
mp.identify(distinctId.value)
|
||||
mp.people.set('Identified', true)
|
||||
mp.people.set('Theme Web', isDarkTheme.value ? 'dark' : 'light')
|
||||
mp.add_group('server_id', serverId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useClientsideMixpanelClientBuilder = () => {
|
||||
const {
|
||||
public: { mixpanelApiHost, mixpanelTokenId, logCsrEmitProps }
|
||||
} = useRuntimeConfig()
|
||||
const { reidentify } = useMixpanelUserIdentification()
|
||||
const onAuthStateChange = useOnAuthStateChange()
|
||||
const logger = useLogger()
|
||||
const collectUtmTags = useMixpanelUtmCollection()
|
||||
|
||||
return async (): Promise<Nullable<MixpanelClient>> => {
|
||||
// Dynamic import to be able to suppress loading errors that happen because of adblock
|
||||
const mixpanel = (await import('mixpanel-browser')).default
|
||||
if (!mixpanel || !mixpanelTokenId.length || !mixpanelApiHost.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Init
|
||||
mixpanel.init(mixpanelTokenId, {
|
||||
api_host: mixpanelApiHost,
|
||||
debug: !!import.meta.dev && logCsrEmitProps
|
||||
})
|
||||
const utmParams = collectUtmTags()
|
||||
|
||||
// Reidentify on auth change
|
||||
await onAuthStateChange(() => reidentify(mixpanel), { immediate: true })
|
||||
|
||||
// Track UTM (only on initial visit)
|
||||
if (Object.values(utmParams).length) {
|
||||
const firstTouch = mapKeys(utmParams, (_val, key) => `${key} [first touch]`)
|
||||
const lastTouch = mapKeys(utmParams, (_val, key) => `${key} [last touch]`)
|
||||
|
||||
mixpanel.people.set(lastTouch)
|
||||
mixpanel.people.set_once(firstTouch)
|
||||
mixpanel.register(lastTouch)
|
||||
}
|
||||
|
||||
// Track app visit
|
||||
mixpanel.track(`Visit ${HOST_APP_DISPLAY_NAME}`)
|
||||
logger.info('MP client initialized')
|
||||
|
||||
return mixpanel
|
||||
}
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
import {
|
||||
fakeMixpanelClient,
|
||||
HOST_APP,
|
||||
type MixpanelClient
|
||||
} from '~/lib/common/helpers/mp'
|
||||
import type Mixpanel from 'mixpanel'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import * as ServerMixpanelUtils from '@speckle/shared/dist/esm/observability/mixpanel.js'
|
||||
import { useApiOrigin } from '~/composables/env'
|
||||
import { useActiveUser } from '~/composables/globals'
|
||||
import { isFunction } from 'lodash-es'
|
||||
import { useWaitForActiveUser } from '~/lib/auth/composables/activeUser'
|
||||
|
||||
/**
|
||||
* IMPORTANT: Do not import this on client-side, the code is only supposed to run in SSR
|
||||
*/
|
||||
|
||||
let cachedInternalClient: Nullable<Mixpanel.Mixpanel> = null
|
||||
|
||||
/**
|
||||
* Composable for building the SSR mixpanel client
|
||||
*/
|
||||
export const useServersideMixpanelClientBuilder = () => {
|
||||
const {
|
||||
public: { mixpanelApiHost, mixpanelTokenId, logCsrEmitProps, speckleServerVersion }
|
||||
} = useRuntimeConfig()
|
||||
const nuxtApp = useNuxtApp()
|
||||
const route = useRoute()
|
||||
const apiOrigin = useApiOrigin({ forcePublic: true })
|
||||
const { distinctId } = useActiveUser()
|
||||
const logger = useLogger()
|
||||
const ssrContext = nuxtApp.ssrContext
|
||||
const waitForUser = useWaitForActiveUser()
|
||||
|
||||
const baseTrackingProperties = ServerMixpanelUtils.buildBasePropertiesPayload({
|
||||
hostApp: HOST_APP,
|
||||
serverOrigin: apiOrigin,
|
||||
speckleVersion: speckleServerVersion
|
||||
})
|
||||
|
||||
return async (): Promise<Nullable<MixpanelClient>> => {
|
||||
if (!mixpanelTokenId.length || !mixpanelApiHost.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Init or retrieve the cached client
|
||||
const internalClient =
|
||||
cachedInternalClient ||
|
||||
ServerMixpanelUtils.buildServerMixpanelClient({
|
||||
tokenId: mixpanelTokenId,
|
||||
apiHostname: new URL(mixpanelApiHost).hostname,
|
||||
debug: !!import.meta.dev && logCsrEmitProps
|
||||
})
|
||||
if (!cachedInternalClient) cachedInternalClient = internalClient
|
||||
|
||||
await waitForUser()
|
||||
|
||||
const coreTrackingProperties = () => {
|
||||
return {
|
||||
...baseTrackingProperties,
|
||||
...ServerMixpanelUtils.buildPropertiesPayload({
|
||||
distinctId: distinctId.value || undefined,
|
||||
headers: ssrContext?.event.node.req.headers,
|
||||
query: route.query,
|
||||
remoteAddress: ssrContext?.event.node.req.socket.remoteAddress
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const track: MixpanelClient['track'] = (eventName, properties, optsOrCallback) => {
|
||||
const payload = { ...coreTrackingProperties(), ...properties }
|
||||
internalClient.track(eventName, payload, (err) => {
|
||||
if (isFunction(optsOrCallback)) {
|
||||
optsOrCallback(
|
||||
err ? { error: err.message, status: 0 } : { error: null, status: 1 }
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
{
|
||||
eventName,
|
||||
payload,
|
||||
...(err ? { err } : {})
|
||||
},
|
||||
'SSR Mixpanel track() invoked'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...fakeMixpanelClient(),
|
||||
track,
|
||||
identify: () => {
|
||||
logger.info(
|
||||
'SSR Mixpanel identify() invoked, but skipped due to identification being automatic'
|
||||
)
|
||||
},
|
||||
register: () => {
|
||||
logger.info(
|
||||
'SSR Mixpanel register() invoked, but skipped due to registration being automatic'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,18 +1,3 @@
|
|||
import { useOnAuthStateChange } from '~/lib/auth/composables/auth'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import { md5 } from '~/lib/common/helpers/encodeDecode'
|
||||
import { useTheme } from '~~/lib/core/composables/theme'
|
||||
|
||||
const HOST_APP = 'web-2'
|
||||
const HOST_APP_DISPLAY_NAME = 'Web 2.0 App'
|
||||
|
||||
/**
|
||||
* Get mixpanel server identifier
|
||||
*/
|
||||
function getMixpanelServerId(): string {
|
||||
return md5(window.location.hostname.toLowerCase()).toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Mixpanel instance
|
||||
* Note: Mixpanel is not available during SSR because mixpanel-browser only works in the browser!
|
||||
|
@ -22,61 +7,3 @@ export function useMixpanel() {
|
|||
const $mixpanel = nuxt.$mixpanel
|
||||
return $mixpanel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that builds the user (re-)identification function. Needs to be invoked on app
|
||||
* init and when the active user changes (e.g. after signing out/in)
|
||||
* Note: The returned function will only work on the client-side
|
||||
*/
|
||||
export function useMixpanelUserIdentification() {
|
||||
if (import.meta.server) return { reidentify: () => void 0 }
|
||||
|
||||
const mp = useMixpanel()
|
||||
const { distinctId } = useActiveUser()
|
||||
const { isDarkTheme } = useTheme()
|
||||
const serverId = getMixpanelServerId()
|
||||
const {
|
||||
public: { speckleServerVersion }
|
||||
} = useRuntimeConfig()
|
||||
|
||||
return {
|
||||
reidentify: () => {
|
||||
// Reset previous user data, if any
|
||||
mp.reset()
|
||||
|
||||
// Register session
|
||||
mp.register({
|
||||
// eslint-disable-next-line camelcase
|
||||
server_id: serverId,
|
||||
hostApp: HOST_APP,
|
||||
speckleVersion: speckleServerVersion
|
||||
})
|
||||
|
||||
// Identify user, if any
|
||||
if (distinctId.value) {
|
||||
mp.identify(distinctId.value)
|
||||
mp.people.set('Identified', true)
|
||||
mp.people.set('Theme Web', isDarkTheme.value ? 'dark' : 'light')
|
||||
mp.add_group('server_id', serverId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that builds the mixpanel initialization function
|
||||
* Note: The returned function will only initialize mixpanel on the client-side
|
||||
*/
|
||||
export async function useMixpanelInitialization() {
|
||||
if (import.meta.server) return
|
||||
|
||||
const mp = useMixpanel()
|
||||
const { reidentify } = useMixpanelUserIdentification()
|
||||
const onAuthStateChange = useOnAuthStateChange()
|
||||
|
||||
// Reidentify on auth change
|
||||
await onAuthStateChange(() => reidentify(), { immediate: true })
|
||||
|
||||
// Track app visit
|
||||
mp.track(`Visit ${HOST_APP_DISPLAY_NAME}`)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import { useProcessProjectInvite } from '~/lib/projects/composables/projectManagement'
|
||||
|
||||
export const useProjectInviteManager = () => {
|
||||
const processInvite = useProcessProjectInvite()
|
||||
const mp = useMixpanel()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const useInvite = async (params: {
|
||||
accept: boolean
|
||||
token: string
|
||||
projectId: string
|
||||
inviteId?: string
|
||||
}) => {
|
||||
const { token, accept, projectId, inviteId } = params
|
||||
if (!token?.length || !projectId?.length) return false
|
||||
|
||||
loading.value = true
|
||||
const success = await processInvite(
|
||||
{
|
||||
projectId,
|
||||
accept,
|
||||
token
|
||||
},
|
||||
{ inviteId }
|
||||
)
|
||||
loading.value = false
|
||||
|
||||
if (!success) return false
|
||||
|
||||
mp.track('Invite Action', {
|
||||
type: 'project invite',
|
||||
accepted: accept
|
||||
})
|
||||
|
||||
return !!success
|
||||
}
|
||||
|
||||
return {
|
||||
useInvite,
|
||||
loading: computed(() => loading.value)
|
||||
}
|
||||
}
|
|
@ -356,7 +356,7 @@ export function useProcessProjectInvite() {
|
|||
|
||||
return async (
|
||||
input: ProjectInviteUseInput,
|
||||
options?: Partial<{ inviteId: string }>
|
||||
options?: Partial<{ inviteId: string; skipToast: boolean }>
|
||||
) => {
|
||||
if (!activeUser.value) return
|
||||
|
||||
|
@ -365,28 +365,34 @@ export function useProcessProjectInvite() {
|
|||
mutation: useProjectInviteMutation,
|
||||
variables: { input },
|
||||
update: (cache, { data }) => {
|
||||
if (!data?.projectMutations.invites.use || !options?.inviteId) return
|
||||
if (!data?.projectMutations.invites.use) return
|
||||
|
||||
// Evict PendingStreamCollaborator
|
||||
cache.evict({
|
||||
id: getCacheId('PendingStreamCollaborator', options.inviteId)
|
||||
})
|
||||
if (options?.inviteId) {
|
||||
// Evict PendingStreamCollaborator
|
||||
cache.evict({
|
||||
id: getCacheId('PendingStreamCollaborator', options.inviteId)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (data?.projectMutations.invites.use) {
|
||||
triggerNotification({
|
||||
type: input.accept ? ToastNotificationType.Success : ToastNotificationType.Info,
|
||||
title: input.accept ? 'Invite accepted' : 'Invite dismissed'
|
||||
})
|
||||
} else {
|
||||
const errMsg = getFirstErrorMessage(errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: "Couldn't process invite",
|
||||
description: errMsg
|
||||
})
|
||||
if (!options?.skipToast) {
|
||||
if (data?.projectMutations.invites.use) {
|
||||
triggerNotification({
|
||||
type: input.accept
|
||||
? ToastNotificationType.Success
|
||||
: ToastNotificationType.Info,
|
||||
title: input.accept ? 'Invite accepted' : 'Invite dismissed'
|
||||
})
|
||||
} else {
|
||||
const errMsg = getFirstErrorMessage(errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: "Couldn't process invite",
|
||||
description: errMsg
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return data?.projectMutations.invites.use
|
||||
|
|
|
@ -13,8 +13,10 @@ export const adminDeleteUserMutation = graphql(`
|
|||
`)
|
||||
|
||||
export const adminDeleteProjectMutation = graphql(`
|
||||
mutation AdminPanelDeleteProject($ids: [String!]) {
|
||||
streamsDelete(ids: $ids)
|
||||
mutation AdminPanelDeleteProject($ids: [String!]!) {
|
||||
projectMutations {
|
||||
batchDelete(ids: $ids)
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
|
|
|
@ -136,8 +136,8 @@ export const viewerLoadedThreadsQuery = graphql(`
|
|||
`)
|
||||
|
||||
export const viewerRawObjectQuery = graphql(`
|
||||
query Stream($streamId: String!, $objectId: String!) {
|
||||
stream(id: $streamId) {
|
||||
query ViewerRawProjectObject($projectId: String!, $objectId: String!) {
|
||||
project(id: $projectId) {
|
||||
id
|
||||
object(id: $objectId) {
|
||||
id
|
||||
|
|
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
|
@ -14,8 +14,8 @@ import { ViewerHashStateKeys } from '~/lib/viewer/composables/setup/urlHashState
|
|||
|
||||
const legacyBranchMetadataQuery = graphql(`
|
||||
query LegacyBranchRedirectMetadata($streamId: String!, $branchName: String!) {
|
||||
stream(id: $streamId) {
|
||||
branch(name: $branchName) {
|
||||
project(id: $streamId) {
|
||||
modelByName(name: $branchName) {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
@ -24,10 +24,10 @@ const legacyBranchMetadataQuery = graphql(`
|
|||
|
||||
const legacyViewerCommitMetadataQuery = graphql(`
|
||||
query LegacyViewerCommitRedirectMetadata($streamId: String!, $commitId: String!) {
|
||||
stream(id: $streamId) {
|
||||
commit(id: $commitId) {
|
||||
project(id: $streamId) {
|
||||
version(id: $commitId) {
|
||||
id
|
||||
branch {
|
||||
model {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
@ -37,13 +37,13 @@ const legacyViewerCommitMetadataQuery = graphql(`
|
|||
|
||||
const legacyViewerStreamMetadataQuery = graphql(`
|
||||
query LegacyViewerStreamRedirectMetadata($streamId: String!) {
|
||||
stream(id: $streamId) {
|
||||
project(id: $streamId) {
|
||||
id
|
||||
commits(limit: 1) {
|
||||
versions(limit: 1) {
|
||||
totalCount
|
||||
items {
|
||||
id
|
||||
branch {
|
||||
model {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
@ -68,6 +68,7 @@ const adminPageRgx = /^\/admin\/?/
|
|||
*/
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const logger = useLogger()
|
||||
const path = to.path
|
||||
const apollo = useApolloClientFromNuxt()
|
||||
const resourceBuilder = () => SpeckleViewer.ViewerRoute.resourceBuilder()
|
||||
|
@ -91,23 +92,33 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
const resourceIdString = resourceIdStringBuilder.addObject(viewerId).toString()
|
||||
return navigateTo(modelRoute(viewerStreamId, resourceIdString, hashState))
|
||||
} else {
|
||||
const { data } = await apollo
|
||||
const { data, errors } = await apollo
|
||||
.query({
|
||||
query: legacyViewerCommitMetadataQuery,
|
||||
variables: { streamId: viewerStreamId, commitId: viewerId }
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
const branchId = data?.stream?.commit?.branch?.id
|
||||
const branchId = data?.project?.version?.model?.id
|
||||
|
||||
return navigateTo(
|
||||
branchId
|
||||
? modelRoute(
|
||||
viewerStreamId,
|
||||
resourceIdStringBuilder.addModel(branchId, viewerId).toString(),
|
||||
hashState
|
||||
)
|
||||
: projectRoute(viewerStreamId)
|
||||
)
|
||||
if (branchId) {
|
||||
return navigateTo(
|
||||
modelRoute(
|
||||
viewerStreamId,
|
||||
resourceIdStringBuilder.addModel(branchId, viewerId).toString(),
|
||||
hashState
|
||||
)
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
{
|
||||
errors,
|
||||
streamId: viewerStreamId,
|
||||
commitId: viewerId
|
||||
},
|
||||
"Couldn't resolve legacy viewer redirect commit metadata"
|
||||
)
|
||||
return navigateTo(projectRoute(viewerStreamId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -131,6 +142,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
const branchName = to.query['branch'] as Optional<string> // get first branch commit
|
||||
|
||||
if (!streamId?.length) {
|
||||
logger.warn('No stream ID provided for embed viewer redirect')
|
||||
return navigateTo(homeRoute)
|
||||
}
|
||||
|
||||
|
@ -141,27 +153,37 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
})
|
||||
)
|
||||
} else if (commitId?.length) {
|
||||
const { data } = await apollo
|
||||
const { data, errors } = await apollo
|
||||
.query({
|
||||
query: legacyViewerCommitMetadataQuery,
|
||||
variables: { streamId, commitId }
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
const branchId = data?.stream?.commit?.branch?.id
|
||||
const branchId = data?.project?.version?.model?.id
|
||||
|
||||
return navigateTo(
|
||||
branchId
|
||||
? modelRoute(
|
||||
streamId,
|
||||
resourceBuilder().addModel(branchId, commitId).toString(),
|
||||
{
|
||||
[ViewerHashStateKeys.EmbedOptions]: JSON.stringify(embedOptions)
|
||||
}
|
||||
)
|
||||
: projectRoute(viewerStreamId)
|
||||
)
|
||||
if (branchId) {
|
||||
return navigateTo(
|
||||
modelRoute(
|
||||
streamId,
|
||||
resourceBuilder().addModel(branchId, commitId).toString(),
|
||||
{
|
||||
[ViewerHashStateKeys.EmbedOptions]: JSON.stringify(embedOptions)
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
{
|
||||
errors,
|
||||
streamId,
|
||||
commitId
|
||||
},
|
||||
"Couldn't resolve legacy commit embed redirect metadata"
|
||||
)
|
||||
return navigateTo(projectRoute(streamId))
|
||||
}
|
||||
} else if (branchName?.length) {
|
||||
const { data } = await apollo
|
||||
const { data, errors } = await apollo
|
||||
.query({
|
||||
query: legacyBranchMetadataQuery,
|
||||
variables: {
|
||||
|
@ -171,44 +193,63 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
return navigateTo(
|
||||
data?.stream?.branch?.id
|
||||
? modelRoute(
|
||||
streamId,
|
||||
resourceBuilder().addModel(data.stream.branch.id).toString(),
|
||||
{
|
||||
[ViewerHashStateKeys.EmbedOptions]: JSON.stringify(embedOptions)
|
||||
}
|
||||
)
|
||||
: projectRoute(streamId)
|
||||
)
|
||||
const branchId = data?.project?.modelByName?.id
|
||||
if (branchId) {
|
||||
return navigateTo(
|
||||
modelRoute(streamId, resourceBuilder().addModel(branchId).toString(), {
|
||||
[ViewerHashStateKeys.EmbedOptions]: JSON.stringify(embedOptions)
|
||||
})
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
{
|
||||
errors,
|
||||
streamId,
|
||||
branchName: decodeURIComponent(branchName)
|
||||
},
|
||||
"Couldn't resolve legacy branch embed redirect metadata"
|
||||
)
|
||||
return navigateTo(projectRoute(streamId))
|
||||
}
|
||||
} else {
|
||||
const { data } = await apollo
|
||||
const { data, errors } = await apollo
|
||||
.query({ query: legacyViewerStreamMetadataQuery, variables: { streamId } })
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
return navigateTo(
|
||||
data?.stream?.commits?.items?.length && data.stream.commits.items[0].branch
|
||||
? modelRoute(
|
||||
data.stream.id,
|
||||
SpeckleViewer.ViewerRoute.resourceBuilder()
|
||||
.addModel(
|
||||
data.stream.commits.items[0].branch.id,
|
||||
data.stream.commits.items[0].id
|
||||
)
|
||||
.toString(),
|
||||
{
|
||||
[ViewerHashStateKeys.EmbedOptions]: JSON.stringify(embedOptions)
|
||||
}
|
||||
)
|
||||
: projectRoute(streamId)
|
||||
)
|
||||
if (
|
||||
data?.project?.versions?.items?.length &&
|
||||
data.project.versions.items[0].model
|
||||
) {
|
||||
return navigateTo(
|
||||
modelRoute(
|
||||
data.project.id,
|
||||
SpeckleViewer.ViewerRoute.resourceBuilder()
|
||||
.addModel(
|
||||
data.project.versions.items[0].model.id,
|
||||
data.project.versions.items[0].id
|
||||
)
|
||||
.toString(),
|
||||
{
|
||||
[ViewerHashStateKeys.EmbedOptions]: JSON.stringify(embedOptions)
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
{
|
||||
errors,
|
||||
streamId
|
||||
},
|
||||
"Couldn't resolve legacy stream embed redirect metadata"
|
||||
)
|
||||
return navigateTo(projectRoute(streamId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [, branchStreamId, branchName] = path.match(streamBranchPageRgx) || []
|
||||
if (branchStreamId && branchName) {
|
||||
const { data } = await apollo
|
||||
const { data, errors } = await apollo
|
||||
.query({
|
||||
query: legacyBranchMetadataQuery,
|
||||
variables: {
|
||||
|
@ -217,13 +258,22 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
}
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
const branchId = data?.stream?.branch?.id
|
||||
const branchId = data?.project?.modelByName?.id
|
||||
|
||||
return navigateTo(
|
||||
branchId
|
||||
? modelVersionsRoute(branchStreamId, branchId)
|
||||
: projectRoute(branchStreamId)
|
||||
)
|
||||
if (branchId) {
|
||||
return navigateTo(modelVersionsRoute(branchStreamId, branchId))
|
||||
} else {
|
||||
logger.warn(
|
||||
{
|
||||
errors,
|
||||
streamId: branchStreamId,
|
||||
branchName: decodeURIComponent(branchName)
|
||||
},
|
||||
"Couldn't resolve legacy branch redirect metadata"
|
||||
)
|
||||
|
||||
return navigateTo(projectRoute(branchStreamId))
|
||||
}
|
||||
}
|
||||
|
||||
const [, streamId] = path.match(streamPageRgx) || []
|
|
@ -0,0 +1,40 @@
|
|||
import { type Optional } from '@speckle/shared'
|
||||
import { omit } from 'lodash-es'
|
||||
import { activeUserQuery } from '~/lib/auth/composables/activeUser'
|
||||
import { useApolloClientFromNuxt } from '~/lib/common/composables/graphql'
|
||||
import { useProjectInviteManager } from '~/lib/projects/composables/invites'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const { useInvite } = useProjectInviteManager()
|
||||
const client = useApolloClientFromNuxt()
|
||||
const { data } = await client
|
||||
.query({
|
||||
query: activeUserQuery
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
// Ignore if not logged in
|
||||
if (!data?.activeUser?.id) return
|
||||
|
||||
const token = to.query.token as Optional<string>
|
||||
const accept = to.query.accept === 'true'
|
||||
|
||||
if (!token || !accept) {
|
||||
return
|
||||
}
|
||||
if (!to.path.startsWith('/projects/')) return
|
||||
|
||||
const projectId = to.params.id as Optional<string>
|
||||
if (!projectId) return
|
||||
|
||||
const success = await useInvite({ token, accept, projectId })
|
||||
|
||||
if (success) {
|
||||
return navigateTo(
|
||||
{
|
||||
query: omit(to.query, ['token', 'accept'])
|
||||
},
|
||||
{ replace: true }
|
||||
)
|
||||
}
|
||||
})
|
|
@ -19,12 +19,12 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
|
||||
const isOnboardingFinished = data?.activeUser?.isOnboardingFinished
|
||||
const isGoingToOnboarding = to.path === onboardingRoute
|
||||
|
||||
if (
|
||||
const shouldRedirectToOnboarding =
|
||||
!isOnboardingFinished &&
|
||||
!isGoingToOnboarding &&
|
||||
to.query['skiponboarding'] !== 'true'
|
||||
) {
|
||||
|
||||
if (shouldRedirectToOnboarding) {
|
||||
return navigateTo(onboardingRoute)
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const { ssrContext } = useNuxtApp()
|
||||
|
||||
if (ssrContext) {
|
||||
// Add response header that shows this is a FE2 request
|
||||
ssrContext.event.node.res.setHeader('x-speckle-frontend-2', 'true')
|
||||
|
||||
// Check if the route is not an possible embedded route
|
||||
// If not add the header to prevent click highjacking
|
||||
if (to.name !== 'model-viewer') {
|
||||
ssrContext.event.node.res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
|
@ -1,12 +0,0 @@
|
|||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
if (import.meta.server) return
|
||||
const mp = useMixpanel()
|
||||
const pathDefinition = getRouteDefinition(to)
|
||||
const path = to.path
|
||||
mp.track('Route Visited', {
|
||||
path,
|
||||
pathDefinition
|
||||
})
|
||||
})
|
|
@ -5,8 +5,11 @@ import { getLinkToThread } from '~/lib/viewer/helpers/comments'
|
|||
|
||||
const resolveLinkQuery = graphql(`
|
||||
query ResolveCommentLink($commentId: String!, $projectId: String!) {
|
||||
comment(id: $commentId, streamId: $projectId) {
|
||||
...LinkableComment
|
||||
project(id: $projectId) {
|
||||
comment(id: $commentId) {
|
||||
id
|
||||
...LinkableComment
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
@ -26,7 +29,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
const comment = res.data?.comment
|
||||
const comment = res.data?.project?.comment
|
||||
if (!comment) {
|
||||
return abortNavigation(
|
||||
createError({
|
||||
|
|
|
@ -75,6 +75,7 @@
|
|||
"subscriptions-transport-ws": "^0.11.0",
|
||||
"tweetnacl-sealedbox-js": "^1.2.0",
|
||||
"tweetnacl-util": "^0.15.1",
|
||||
"ua-parser-js": "^1.0.38",
|
||||
"vee-validate": "^4.7.0",
|
||||
"vue-advanced-cropper": "^2.8.8",
|
||||
"vue-tippy": "^6.0.0",
|
||||
|
@ -108,6 +109,7 @@
|
|||
"@types/mixpanel-browser": "^2.38.0",
|
||||
"@types/node": "^18.17.5",
|
||||
"@types/pino-http": "^5.8.1",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@typescript-eslint/eslint-plugin": "^7.12.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"@vitejs/plugin-legacy": "^5.4.1",
|
||||
|
@ -118,6 +120,7 @@
|
|||
"eslint": "^9.4.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.3.0",
|
||||
"mixpanel": "^0.18.0",
|
||||
"nuxt": "^3.12.2",
|
||||
"pino-pretty": "^10.0.1",
|
||||
"postcss": "^8.4.31",
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<div class="w-full h-full bg-foundation flex items-center justify-center">
|
||||
<!--
|
||||
Note: You might be asking yourself why do we need this route: the answer is that cloning
|
||||
a stream is not instant, and it might take some time to get it done. We want to display
|
||||
a project is not instant, and it might take some time to get it done. We want to display
|
||||
some sort of progress to the user in the meantime. Moreover, it makes various composables
|
||||
more sane to use rather than in the router navigation guards.
|
||||
-->
|
||||
|
|
|
@ -3,8 +3,7 @@
|
|||
<div v-if="project">
|
||||
<ProjectsInviteBanner
|
||||
:invite="invite"
|
||||
:show-stream-name="false"
|
||||
:auto-accept="shouldAutoAcceptInvite"
|
||||
:show-project-name="false"
|
||||
@processed="onInviteAccepted"
|
||||
/>
|
||||
<div
|
||||
|
@ -71,7 +70,6 @@ definePageMeta({
|
|||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const projectId = computed(() => route.params.id as string)
|
||||
const shouldAutoAcceptInvite = computed(() => route.query.accept === 'true')
|
||||
const token = computed(() => route.query.token as Optional<string>)
|
||||
|
||||
const pageFetchPolicy = usePageQueryStandardFetchPolicy()
|
||||
|
|
|
@ -26,7 +26,8 @@ definePageMeta({
|
|||
middleware: ['require-valid-project'],
|
||||
pageTransition: false, // NOTE: transitions fuck viewer up
|
||||
layoutTransition: false,
|
||||
key: '/projects/:id/models/resources' // To prevent controls flickering on resource url param changes
|
||||
key: '/projects/:id/models/resources', // To prevent controls flickering on resource url param changes
|
||||
name: 'model-viewer'
|
||||
})
|
||||
|
||||
const ViewerScope = resolveComponent('ViewerScope')
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import { LogicError } from '@speckle/ui-components'
|
||||
import { fakeMixpanelClient, type MixpanelClient } from '~/lib/common/helpers/mp'
|
||||
import { useClientsideMixpanelClientBuilder } from '~/lib/core/clients/mp'
|
||||
|
||||
/**
|
||||
* mixpanel-browser only supports being ran on the client-side (hence the name)! So it's only going to be accessible
|
||||
* in client-side execution branches
|
||||
*/
|
||||
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const logger = useLogger()
|
||||
const build = useClientsideMixpanelClientBuilder()
|
||||
|
||||
let mixpanel: MixpanelClient | undefined = undefined
|
||||
|
||||
try {
|
||||
// Dynamic import to allow suppressing loading errors that happen because of adblock
|
||||
mixpanel = (await build()) || undefined
|
||||
} catch (e) {
|
||||
logger.warn(e, 'Failed to load mixpanel in CSR')
|
||||
}
|
||||
|
||||
if (!mixpanel) {
|
||||
// Implement mocked version
|
||||
mixpanel = fakeMixpanelClient()
|
||||
}
|
||||
|
||||
return {
|
||||
provide: {
|
||||
mixpanel: () => {
|
||||
if (!mixpanel) throw new LogicError('Mixpanel unexpectedly not defined')
|
||||
return mixpanel
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
|
@ -0,0 +1,39 @@
|
|||
import { LogicError } from '@speckle/ui-components'
|
||||
import { fakeMixpanelClient, type MixpanelClient } from '~/lib/common/helpers/mp'
|
||||
import { useServersideMixpanelClientBuilder } from '~/lib/core/clients/mpServer'
|
||||
|
||||
/**
|
||||
* mixpanel only supports being ran on the server-side! So it's only going to be accessible
|
||||
* in SSR execution branches
|
||||
*/
|
||||
|
||||
type LimitedMixpanel = MixpanelClient
|
||||
|
||||
const fakeLimitedMixpanel = fakeMixpanelClient
|
||||
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const logger = useLogger()
|
||||
|
||||
let mixpanel: LimitedMixpanel | undefined = undefined
|
||||
|
||||
try {
|
||||
const build = useServersideMixpanelClientBuilder()
|
||||
mixpanel = (await build()) || undefined
|
||||
} catch (e) {
|
||||
logger.warn(e, 'Failed to load mixpanel in SSR')
|
||||
}
|
||||
|
||||
if (!mixpanel) {
|
||||
// Implement mocked version
|
||||
mixpanel = fakeLimitedMixpanel()
|
||||
}
|
||||
|
||||
return {
|
||||
provide: {
|
||||
mixpanel: () => {
|
||||
if (!mixpanel) throw new LogicError('Mixpanel unexpectedly not defined')
|
||||
return mixpanel
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
|
@ -0,0 +1,30 @@
|
|||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
import type { Optional } from '@speckle/shared'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const mp = useMixpanel()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
let previousPath: Optional<string> = undefined
|
||||
const track = (to: RouteLocationNormalized) => {
|
||||
const path = to.path
|
||||
if (path === previousPath) return
|
||||
|
||||
const pathDefinition = getRouteDefinition(to)
|
||||
mp.track('Route Visited', {
|
||||
path,
|
||||
pathDefinition
|
||||
})
|
||||
previousPath = path
|
||||
}
|
||||
|
||||
// Track init page view
|
||||
track(route)
|
||||
|
||||
// Track page view after navigations
|
||||
router.afterEach((to) => {
|
||||
track(to)
|
||||
})
|
||||
})
|
|
@ -1,83 +0,0 @@
|
|||
/* eslint-disable camelcase */
|
||||
import { LogicError } from '@speckle/ui-components'
|
||||
import type { OverridedMixpanel } from 'mixpanel-browser'
|
||||
import type { Merge } from 'type-fest'
|
||||
|
||||
/**
|
||||
* mixpanel-browser only supports being ran on the client-side (hence the name)! So it's only going to be accessible
|
||||
* in client-side execution branches
|
||||
*/
|
||||
|
||||
type LimitedMixpanel = Merge<
|
||||
Pick<
|
||||
OverridedMixpanel,
|
||||
'track' | 'init' | 'reset' | 'register' | 'identify' | 'people' | 'add_group'
|
||||
>,
|
||||
{
|
||||
people: Pick<OverridedMixpanel['people'], 'set' | 'set_once'>
|
||||
}
|
||||
>
|
||||
|
||||
const fakeLimitedMixpanel = (): LimitedMixpanel => ({
|
||||
init: noop as LimitedMixpanel['init'],
|
||||
track: noop,
|
||||
reset: noop,
|
||||
register: noop,
|
||||
identify: noop,
|
||||
people: {
|
||||
set: noop,
|
||||
set_once: noop
|
||||
},
|
||||
add_group: noop
|
||||
})
|
||||
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const {
|
||||
public: { mixpanelApiHost, mixpanelTokenId, logCsrEmitProps }
|
||||
} = useRuntimeConfig()
|
||||
const logger = useLogger()
|
||||
|
||||
let mixpanel: LimitedMixpanel | undefined = undefined
|
||||
|
||||
try {
|
||||
mixpanel = import.meta.client
|
||||
? (await import('mixpanel-browser')).default
|
||||
: undefined
|
||||
if (import.meta.server) {
|
||||
mixpanel = {
|
||||
...fakeLimitedMixpanel(),
|
||||
track: () => {
|
||||
throw new Error('mixpanel is not available on the server-side')
|
||||
},
|
||||
identify: () => {
|
||||
throw new Error('mixpanel is not available on the server-side')
|
||||
},
|
||||
register: () => {
|
||||
throw new Error('mixpanel is not available on the server-side')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(e, 'Failed to load mixpanel')
|
||||
}
|
||||
|
||||
if (!mixpanel) {
|
||||
// Implement mocked version
|
||||
mixpanel = fakeLimitedMixpanel()
|
||||
}
|
||||
|
||||
// Init
|
||||
mixpanel.init(mixpanelTokenId, {
|
||||
api_host: mixpanelApiHost,
|
||||
debug: !!import.meta.dev && logCsrEmitProps
|
||||
})
|
||||
|
||||
return {
|
||||
provide: {
|
||||
mixpanel: () => {
|
||||
if (!mixpanel) throw new LogicError('Mixpanel unexpectedly not defined')
|
||||
return mixpanel
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
|
@ -0,0 +1,72 @@
|
|||
import type { ConfigType } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
/**
|
||||
* Converts a given date input into a relative time string
|
||||
* @example
|
||||
* customRelativeTime('2023-07-16') - returns "Jul 16" or "Jul 16, 2023" if the year is different from the current year
|
||||
* customRelativeTime(new Date()) - returns "just now"
|
||||
*/
|
||||
const customRelativeTime = (date: ConfigType, capitalize?: boolean): string => {
|
||||
const pastDate = dayjs(date)
|
||||
const now = dayjs()
|
||||
const diffInMinutes = now.diff(date, 'minute')
|
||||
const diffInHours = now.diff(date, 'hour')
|
||||
const diffInDays = now.diff(date, 'day')
|
||||
|
||||
if (diffInDays > 14) {
|
||||
return pastDate.year() === now.year()
|
||||
? pastDate.format('MMM D')
|
||||
: pastDate.format('MMM D, YYYY')
|
||||
} else if (diffInDays >= 1) {
|
||||
return diffInDays === 1 ? '1 day ago' : `${diffInDays} days ago`
|
||||
} else if (diffInHours <= 23 && diffInHours >= 1) {
|
||||
return diffInHours === 1 ? '1 hour ago' : `${diffInHours} hours ago`
|
||||
} else if (diffInMinutes <= 59 && diffInMinutes >= 1) {
|
||||
return diffInMinutes === 1 ? '1 minute ago' : `${diffInMinutes} minutes ago`
|
||||
}
|
||||
|
||||
return capitalize ? 'Just now' : 'just now'
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the given date input is formatting with a clock unit (seconds, minutes, or hours).
|
||||
* Only meant to be used by formattedRelativeDate() and formattedFullDate()
|
||||
* @example
|
||||
* isClockUnit('2023-07-16') - returns false
|
||||
* isClockUnit(new Date()) - returns true or false depending on the current time
|
||||
*/
|
||||
const isClockUnit = (date: ConfigType) => {
|
||||
const unit = customRelativeTime(date)
|
||||
return unit.includes('second') || unit.includes('minute') || unit.includes('hour')
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a given date input into a full date string with our default format
|
||||
* @example
|
||||
* formattedFullDate('2023-12-01') - returns "Dec 12, 2023"
|
||||
*/
|
||||
export const formattedFullDate = (date: ConfigType): string =>
|
||||
dayjs(date).format('MMM D, YYYY, H:mm')
|
||||
|
||||
/**
|
||||
* Formats a given date input into a relative time string with optional prefix
|
||||
* @example
|
||||
* Assuming today is January 1st 2024
|
||||
* formattedRelativeDate('2023-12-01') - returns "Dec 12, 2023"
|
||||
* formattedRelativeDate('2023-12-01', { prefix: true }) - returns "on Dec 12, 2023"
|
||||
* formattedRelativeDate('2023-12-31') - returns "1 day ago"
|
||||
* formattedRelativeDate('2023-12-31', { prefix: true }) - returns "1 day ago"
|
||||
*/
|
||||
export const formattedRelativeDate = (
|
||||
date: ConfigType,
|
||||
options?: Partial<{ prefix: boolean; capitalize: boolean }>
|
||||
): string => {
|
||||
if (options?.prefix) {
|
||||
return isClockUnit(date)
|
||||
? customRelativeTime(date, options?.capitalize)
|
||||
: `on ${customRelativeTime(date)}`
|
||||
} else {
|
||||
return customRelativeTime(date, options?.capitalize)
|
||||
}
|
||||
}
|
|
@ -1,10 +1,32 @@
|
|||
{
|
||||
"name": "@speckle/objectloader",
|
||||
"version": "2.5.4",
|
||||
"version": "2.20.0-alpha5",
|
||||
"description": "Simple API helper to stream in objects from the Speckle Server.",
|
||||
"main": "dist/objectloader.js",
|
||||
"main": "dist/objectloader.cjs",
|
||||
"module": "dist/objectloader.esm.js",
|
||||
"types": "types/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./types/index.d.ts",
|
||||
"default": "./dist/objectloader.esm.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./types/index.d.ts",
|
||||
"default": "./dist/objectloader.cjs"
|
||||
}
|
||||
},
|
||||
"./types": "./types/index.d.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#lodash": {
|
||||
"require": "lodash",
|
||||
"import": "lodash-es",
|
||||
"node": "lodash",
|
||||
"default": "lodash-es"
|
||||
}
|
||||
},
|
||||
"homepage": "https://speckle.systems",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -33,6 +55,8 @@
|
|||
"@babel/core": "^7.17.9",
|
||||
"@speckle/shared": "workspace:^",
|
||||
"core-js": "^3.21.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"regenerator-runtime": "^0.13.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -43,6 +67,8 @@
|
|||
"@rollup/plugin-commonjs": "^21.0.3",
|
||||
"@rollup/plugin-node-resolve": "^13.1.3",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"eslint": "^9.4.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
|
|
|
@ -32,7 +32,7 @@ function buildConfig(isWebBuild = false) {
|
|||
? []
|
||||
: [
|
||||
{
|
||||
file: 'dist/objectloader.js',
|
||||
file: 'dist/objectloader.cjs',
|
||||
format: 'cjs',
|
||||
sourcemap,
|
||||
exports: 'default'
|
||||
|
|
|
@ -7,13 +7,13 @@ import {
|
|||
ObjectLoaderRuntimeError
|
||||
} from './errors/index.js'
|
||||
import { polyfillReadableStreamForAsyncIterator } from './helpers/stream.js'
|
||||
import { chunk } from 'lodash'
|
||||
import { chunk } from '#lodash'
|
||||
/**
|
||||
* Simple client that streams object info from a Speckle Server.
|
||||
* TODO: Object construction progress reporting is weird.
|
||||
*/
|
||||
|
||||
export default class ObjectLoader {
|
||||
class ObjectLoader {
|
||||
/**
|
||||
* Creates a new object loader instance.
|
||||
* @param {*} param0
|
||||
|
@ -613,3 +613,5 @@ function safariFix() {
|
|||
tryIdb()
|
||||
}).finally(() => clearInterval(intervalId))
|
||||
}
|
||||
|
||||
export default ObjectLoader
|
||||
|
|
|
@ -13,7 +13,7 @@ export type ProgressStage = 'download' | 'construction'
|
|||
/**
|
||||
* ObjectLoader class
|
||||
*/
|
||||
export default class ObjectLoader {
|
||||
class ObjectLoader {
|
||||
constructor(params: {
|
||||
serverUrl: string
|
||||
streamId: string
|
||||
|
@ -49,3 +49,5 @@ export default class ObjectLoader {
|
|||
async getObject(id: string): Promise<Record<string, unknown>>
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export default ObjectLoader
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
coverage
|
||||
node_modules
|
||||
.eslintcache
|
|
@ -34,7 +34,8 @@
|
|||
"dependencies": {
|
||||
"@speckle/shared": "workspace:^",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.17.21",
|
||||
"reflect-metadata": "^0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.5",
|
||||
|
@ -65,7 +66,9 @@
|
|||
"imports": {
|
||||
"#lodash": {
|
||||
"require": "lodash",
|
||||
"import": "lodash-es"
|
||||
"import": "lodash-es",
|
||||
"node": "lodash",
|
||||
"default": "lodash-es"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { send, Base, type SendResult } from '../../index'
|
||||
import { send, Base, type SendResult, Detach, Chunkable } from '../../index'
|
||||
import { times } from '#lodash'
|
||||
import { createCommit } from './utils'
|
||||
|
||||
interface ExampleAppWindow extends Window {
|
||||
send: typeof import('../../index').send
|
||||
|
@ -70,6 +71,8 @@ appWindow.loadData = async () => {
|
|||
projectId,
|
||||
token: apiToken
|
||||
})
|
||||
|
||||
await createCommit(res, { serverUrl, projectId, token: apiToken })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : JSON.stringify(e)
|
||||
setInputValue('result', msg, { valueKey: 'textContent' })
|
||||
|
@ -108,7 +111,13 @@ function generateTestObject() {
|
|||
.fill(0)
|
||||
.map(() => new RandomFoo({ bar: 'baz baz baz' }))
|
||||
],
|
||||
'@(10)chunkedArr': times(100, () => 42)
|
||||
detachedWithDecorator: new Collection<RandomFoo>('Collection of Foo', 'Foo', [
|
||||
...Array(10)
|
||||
.fill(0)
|
||||
.map(() => new RandomFoo())
|
||||
]),
|
||||
'@(10)chunkedArr': times(100, () => 42),
|
||||
some: new RandomJoe()
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -118,3 +127,33 @@ class RandomFoo extends Base {
|
|||
this.noise = Math.random().toString(16)
|
||||
}
|
||||
}
|
||||
|
||||
class RandomJoe extends Base {
|
||||
@Detach()
|
||||
@Chunkable(10)
|
||||
numbers: number[]
|
||||
|
||||
constructor(props?: Record<string, unknown>) {
|
||||
super(props)
|
||||
this.numbers = times(100, () => 42)
|
||||
}
|
||||
}
|
||||
|
||||
export class Collection<T extends Base> extends Base {
|
||||
@Detach()
|
||||
elements: T[]
|
||||
// eslint-disable-next-line camelcase
|
||||
speckle_type = 'Speckle.Core.Models.Collection'
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
collectionType: string,
|
||||
elements: T[] = [],
|
||||
props?: Record<string, unknown>
|
||||
) {
|
||||
super(props)
|
||||
this.name = name
|
||||
this.collectionType = collectionType
|
||||
this.elements = elements
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import { SendResult } from '../..'
|
||||
|
||||
interface CreateCommitParams {
|
||||
serverUrl: string
|
||||
projectId: string
|
||||
token: string
|
||||
modelName?: string
|
||||
}
|
||||
|
||||
export async function createCommit(
|
||||
res: SendResult,
|
||||
{ serverUrl, projectId, token, modelName }: CreateCommitParams
|
||||
) {
|
||||
const response = await fetch(serverUrl + '/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `
|
||||
mutation CreateCommit($commit: CommitCreateInput!) {
|
||||
commitCreate(commit: $commit)
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
commit: {
|
||||
branchName: modelName || 'main',
|
||||
message: 'Good morning!',
|
||||
objectId: res.hash,
|
||||
streamId: projectId
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await response.json()
|
||||
}
|
|
@ -3,6 +3,8 @@ import { ServerTransport } from './transports/ServerTransport'
|
|||
import { Base } from './utils/Base'
|
||||
export { Base }
|
||||
|
||||
export { Detach, Chunkable } from './utils/Decorators'
|
||||
|
||||
export type SendParams = {
|
||||
serverUrl?: string
|
||||
projectId: string
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import 'reflect-metadata'
|
||||
|
||||
const detachMetadataKey = Symbol('detach')
|
||||
const chunkableMetadataKey = Symbol('chunkable')
|
||||
|
||||
export function Detach() {
|
||||
return Reflect.metadata(detachMetadataKey, true)
|
||||
}
|
||||
|
||||
export function Chunkable(size: number) {
|
||||
return Reflect.metadata(chunkableMetadataKey, size)
|
||||
}
|
||||
|
||||
export function isDetached(target: object, propertyKey: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const metadata = Reflect.getMetadata(detachMetadataKey, target, propertyKey)
|
||||
return metadata ? true : false
|
||||
}
|
||||
|
||||
export function isChunkable(target: object, propertyKey: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const metadata = Reflect.getMetadata(chunkableMetadataKey, target, propertyKey)
|
||||
return metadata ? true : false
|
||||
}
|
||||
|
||||
export function getChunkSize(target: object, propertyKey: string) {
|
||||
return Reflect.getMetadata(chunkableMetadataKey, target, propertyKey) as number
|
||||
}
|
|
@ -4,6 +4,7 @@ import { ITransport } from '../transports/ITransport'
|
|||
import { Base } from './Base'
|
||||
import { IDisposable } from './IDisposable'
|
||||
import { isObjectLike, get } from '#lodash'
|
||||
import { getChunkSize, isChunkable, isDetached } from './Decorators'
|
||||
|
||||
type BasicSpeckleObject = Record<string, unknown> & {
|
||||
speckle_type: string
|
||||
|
@ -61,13 +62,22 @@ export class Serializer implements IDisposable {
|
|||
continue
|
||||
}
|
||||
|
||||
const isDetachedProp = propKey.startsWith('@')
|
||||
const isDetachedProp = propKey.startsWith('@') || isDetached(obj, propKey)
|
||||
|
||||
// 2. chunked arrays
|
||||
const isArray = Array.isArray(value)
|
||||
const isChunked = isArray ? propKey.match(/^@\((\d*)\)/) : false // chunk syntax
|
||||
const isChunked = isArray
|
||||
? isChunkable(obj, propKey) || propKey.match(/^@\((\d*)\)/)
|
||||
: false // chunk syntax
|
||||
|
||||
if (isArray && isChunked && value.length !== 0 && typeof value[0] !== 'object') {
|
||||
const chunkSize = isChunked[1] !== '' ? parseInt(isChunked[1]) : this.chunkSize
|
||||
let chunkSize = this.chunkSize
|
||||
if (typeof isChunked === 'boolean') {
|
||||
chunkSize = getChunkSize(obj, propKey)
|
||||
} else {
|
||||
chunkSize = isChunked[1] !== '' ? parseInt(isChunked[1]) : this.chunkSize
|
||||
}
|
||||
|
||||
const chunkRefs = []
|
||||
|
||||
let chunk = new DataChunk()
|
||||
|
@ -83,7 +93,13 @@ export class Serializer implements IDisposable {
|
|||
}
|
||||
|
||||
if (chunk.data.length !== 0) chunkRefs.push(await this.#handleChunk(chunk))
|
||||
traversed[propKey.replace(isChunked[0], '')] = chunkRefs // strip chunk syntax
|
||||
|
||||
if (typeof isChunked === 'boolean') {
|
||||
traversed[propKey] = chunkRefs // no need to strip chunk syntax
|
||||
} else {
|
||||
traversed[propKey.replace(isChunked[0], '')] = chunkRefs // strip chunk syntax
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,10 @@
|
|||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Decorators */
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
|
|
|
@ -4,13 +4,52 @@ extend type Query {
|
|||
"""
|
||||
streamAccessRequest(streamId: String!): StreamAccessRequest
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use User.projectAccessRequest instead."
|
||||
)
|
||||
}
|
||||
|
||||
extend type User {
|
||||
"""
|
||||
Get pending project access request, that the user made
|
||||
"""
|
||||
projectAccessRequest(projectId: String!): ProjectAccessRequest
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@isOwner
|
||||
}
|
||||
|
||||
extend type Stream {
|
||||
"""
|
||||
Pending stream access requests
|
||||
"""
|
||||
pendingAccessRequests: [StreamAccessRequest!] @hasStreamRole(role: STREAM_OWNER)
|
||||
pendingAccessRequests: [StreamAccessRequest!]
|
||||
@hasStreamRole(role: STREAM_OWNER)
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use Project.pendingAccessRequests instead."
|
||||
)
|
||||
}
|
||||
|
||||
extend type Project {
|
||||
"""
|
||||
Pending project access requests
|
||||
"""
|
||||
pendingAccessRequests: [ProjectAccessRequest!] @hasStreamRole(role: STREAM_OWNER)
|
||||
}
|
||||
|
||||
type ProjectAccessRequestMutations {
|
||||
"""
|
||||
Request access to a specific project
|
||||
"""
|
||||
create(projectId: String!): ProjectAccessRequest!
|
||||
|
||||
"""
|
||||
Accept or decline a project access request. Must be a project owner to invoke this.
|
||||
"""
|
||||
use(
|
||||
requestId: String!
|
||||
accept: Boolean!
|
||||
role: StreamRole! = STREAM_CONTRIBUTOR
|
||||
): Project!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
|
@ -21,7 +60,12 @@ extend type Mutation {
|
|||
requestId: String!
|
||||
accept: Boolean!
|
||||
role: StreamRole! = STREAM_CONTRIBUTOR
|
||||
): Boolean! @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "users:invite")
|
||||
): Boolean!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "users:invite")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use ProjectAccessRequestMutations.use instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Request access to a specific stream
|
||||
|
@ -29,6 +73,18 @@ extend type Mutation {
|
|||
streamAccessRequestCreate(streamId: String!): StreamAccessRequest!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "users:invite")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use ProjectAccessRequestMutations.create instead."
|
||||
)
|
||||
}
|
||||
|
||||
extend type ProjectMutations {
|
||||
"""
|
||||
Access request related mutations
|
||||
"""
|
||||
accessRequestMutations: ProjectAccessRequestMutations!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "users:invite")
|
||||
}
|
||||
|
||||
"""
|
||||
|
@ -45,3 +101,18 @@ type StreamAccessRequest {
|
|||
stream: Stream!
|
||||
createdAt: DateTime!
|
||||
}
|
||||
|
||||
"""
|
||||
Created when a user requests to become a contributor on a project
|
||||
"""
|
||||
type ProjectAccessRequest {
|
||||
id: ID!
|
||||
requester: LimitedUser!
|
||||
requesterId: String!
|
||||
projectId: String!
|
||||
"""
|
||||
Can only be selected if authed user has proper access
|
||||
"""
|
||||
project: Project!
|
||||
createdAt: DateTime!
|
||||
}
|
||||
|
|
|
@ -11,6 +11,9 @@ extend type User {
|
|||
): ActivityCollection
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "users:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
|
||||
"""
|
||||
The user's timeline in chronological order
|
||||
|
@ -23,6 +26,9 @@ extend type User {
|
|||
): ActivityCollection
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScopes(scopes: ["users:read", "streams:read"])
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
}
|
||||
|
||||
extend type LimitedUser {
|
||||
|
@ -38,6 +44,9 @@ extend type LimitedUser {
|
|||
): ActivityCollection
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "users:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
|
||||
"""
|
||||
The user's timeline in chronological order
|
||||
|
@ -50,6 +59,9 @@ extend type LimitedUser {
|
|||
): ActivityCollection
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScopes(scopes: ["users:read", "streams:read"])
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
}
|
||||
|
||||
extend type Stream {
|
||||
|
@ -65,6 +77,9 @@ extend type Stream {
|
|||
): ActivityCollection
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
}
|
||||
|
||||
extend type Branch {
|
||||
|
@ -80,6 +95,9 @@ extend type Branch {
|
|||
): ActivityCollection
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
}
|
||||
|
||||
extend type Commit {
|
||||
|
@ -95,6 +113,9 @@ extend type Commit {
|
|||
): ActivityCollection
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
}
|
||||
|
||||
type ActivityCollection {
|
||||
|
|
|
@ -8,6 +8,9 @@ extend type Query {
|
|||
Returns all the publicly available apps on this server.
|
||||
"""
|
||||
apps: [ServerAppListItem]
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
}
|
||||
|
||||
type ServerApp {
|
||||
|
|
|
@ -11,8 +11,14 @@ extend type Stream {
|
|||
limit: Int = 25
|
||||
cursor: String = null
|
||||
): BlobMetadataCollection
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use Project.blobs instead."
|
||||
)
|
||||
|
||||
blob(id: String!): BlobMetadata
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use Project.blob instead."
|
||||
)
|
||||
}
|
||||
|
||||
extend type Project {
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
extend type Query {
|
||||
comment(id: String!, streamId: String!): Comment
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use Project.comment instead."
|
||||
)
|
||||
|
||||
"""
|
||||
This query can be used in the following ways:
|
||||
- get all the comments for a stream: **do not pass in any resource identifiers**.
|
||||
|
@ -11,7 +15,8 @@ extend type Query {
|
|||
limit: Int = 25
|
||||
cursor: String
|
||||
archived: Boolean! = false
|
||||
): CommentCollection @deprecated(reason: "Use 'commentThreads' fields instead")
|
||||
): CommentCollection
|
||||
@deprecated(reason: "Use Project/Version/Model 'commentThreads' fields instead")
|
||||
}
|
||||
|
||||
extend type Project {
|
||||
|
@ -23,6 +28,11 @@ extend type Project {
|
|||
limit: Int! = 25
|
||||
filter: ProjectCommentsFilter
|
||||
): ProjectCommentCollection!
|
||||
|
||||
"""
|
||||
Get specific project comment/thread by ID
|
||||
"""
|
||||
comment(id: String!): Comment
|
||||
}
|
||||
|
||||
extend type Version {
|
||||
|
@ -51,6 +61,9 @@ extend type Stream {
|
|||
```
|
||||
"""
|
||||
commentCount: Int!
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
}
|
||||
|
||||
extend type Commit {
|
||||
|
@ -65,6 +78,9 @@ extend type Commit {
|
|||
```
|
||||
"""
|
||||
commentCount: Int!
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
}
|
||||
|
||||
extend type Object {
|
||||
|
@ -79,6 +95,9 @@ extend type Object {
|
|||
```
|
||||
"""
|
||||
commentCount: Int!
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
}
|
||||
|
||||
type CommentDataFilters {
|
||||
|
|
|
@ -1,8 +1,20 @@
|
|||
extend type Stream {
|
||||
commits(limit: Int! = 25, cursor: String): CommitCollection
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use Project.versions instead."
|
||||
)
|
||||
commit(id: String): Commit
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use Project.version instead."
|
||||
)
|
||||
branches(limit: Int! = 25, cursor: String): BranchCollection
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use Project.models or Project.modelsTree instead."
|
||||
)
|
||||
branch(name: String = "main"): Branch
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use Project.model or Project.modelByName instead."
|
||||
)
|
||||
}
|
||||
|
||||
extend type User {
|
||||
|
@ -11,6 +23,9 @@ extend type User {
|
|||
from public streams will be returned.
|
||||
"""
|
||||
commits(limit: Int! = 25, cursor: String): CommitCollection
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use User.versions instead."
|
||||
)
|
||||
}
|
||||
|
||||
extend type LimitedUser {
|
||||
|
@ -18,6 +33,9 @@ extend type LimitedUser {
|
|||
Get public stream commits authored by the user
|
||||
"""
|
||||
commits(limit: Int! = 25, cursor: String): CommitCollection
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
}
|
||||
|
||||
type Branch {
|
||||
|
@ -67,25 +85,50 @@ extend type Mutation {
|
|||
branchCreate(branch: BranchCreateInput!): String!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:write")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use ModelMutations.create instead."
|
||||
)
|
||||
branchUpdate(branch: BranchUpdateInput!): Boolean!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:write")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use ModelMutations.update instead."
|
||||
)
|
||||
|
||||
branchDelete(branch: BranchDeleteInput!): Boolean!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:write")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use ModelMutations.delete instead."
|
||||
)
|
||||
|
||||
commitCreate(commit: CommitCreateInput!): String!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:write")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use VersionMutations.create instead."
|
||||
)
|
||||
|
||||
commitUpdate(commit: CommitUpdateInput!): Boolean!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:write")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use VersionMutations.update/moveToModel instead."
|
||||
)
|
||||
|
||||
commitReceive(input: CommitReceivedInput!): Boolean!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use VersionMutations.markReceived instead."
|
||||
)
|
||||
|
||||
commitDelete(commit: CommitDeleteInput!): Boolean!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:write")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use VersionMutations.delete instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Move a batch of commits to a new branch
|
||||
|
@ -93,6 +136,9 @@ extend type Mutation {
|
|||
commitsMove(input: CommitsMoveInput!): Boolean!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:write")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use VersionMutations.moveToModel instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Delete a batch of commits
|
||||
|
@ -100,6 +146,9 @@ extend type Mutation {
|
|||
commitsDelete(input: CommitsDeleteInput!): Boolean!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:write")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use VersionMutations.delete instead."
|
||||
)
|
||||
}
|
||||
|
||||
extend type Subscription {
|
||||
|
@ -110,18 +159,29 @@ extend type Subscription {
|
|||
branchCreated(streamId: String!): JSONObject
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use 'projectModelsUpdated' instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Subscribe to branch updated event.
|
||||
"""
|
||||
branchUpdated(streamId: String!, branchId: String): JSONObject
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use 'projectModelsUpdated' instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Subscribe to branch deleted event
|
||||
"""
|
||||
branchDeleted(streamId: String!): JSONObject
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use 'projectModelsUpdated' instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Subscribe to commit created event
|
||||
|
@ -129,18 +189,29 @@ extend type Subscription {
|
|||
commitCreated(streamId: String!): JSONObject
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use 'projectVersionsUpdated' instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Subscribe to commit updated event.
|
||||
"""
|
||||
commitUpdated(streamId: String!, commitId: String): JSONObject
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use 'projectVersionsUpdated' instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Subscribe to commit deleted event
|
||||
"""
|
||||
commitDeleted(streamId: String!): JSONObject
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use 'projectVersionsUpdated' instead."
|
||||
)
|
||||
}
|
||||
|
||||
input BranchCreateInput {
|
||||
|
|
|
@ -40,6 +40,11 @@ extend type Project {
|
|||
Retrieve a specific project version by its ID
|
||||
"""
|
||||
version(id: String!): Version
|
||||
|
||||
"""
|
||||
Retrieve a specific project model by its ID
|
||||
"""
|
||||
modelByName(name: String!): Model!
|
||||
}
|
||||
|
||||
extend type User {
|
||||
|
@ -174,10 +179,29 @@ input UpdateVersionInput {
|
|||
message: String
|
||||
}
|
||||
|
||||
input CreateVersionInput {
|
||||
projectId: String!
|
||||
modelId: String!
|
||||
objectId: String!
|
||||
message: String
|
||||
sourceApplication: String
|
||||
totalChildrenCount: Int
|
||||
parents: [String!]
|
||||
}
|
||||
|
||||
input MarkReceivedVersionInput {
|
||||
projectId: String!
|
||||
versionId: String!
|
||||
sourceApplication: String!
|
||||
message: String
|
||||
}
|
||||
|
||||
type VersionMutations {
|
||||
moveToModel(input: MoveVersionsInput!): Model!
|
||||
delete(input: DeleteVersionsInput!): Boolean!
|
||||
update(input: UpdateVersionInput!): Version!
|
||||
create(input: CreateVersionInput!): Version!
|
||||
markReceived(input: MarkReceivedVersionInput!): Boolean!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
extend type Stream {
|
||||
object(id: String!): Object
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use Project.object instead."
|
||||
)
|
||||
}
|
||||
|
||||
extend type Project {
|
||||
object(id: String!): Object
|
||||
}
|
||||
|
||||
type Object {
|
||||
id: String!
|
||||
speckleType: String
|
||||
applicationId: String
|
||||
applicationId: String @deprecated(reason: "Not implemented.")
|
||||
createdAt: DateTime
|
||||
totalChildrenCount: Int
|
||||
"""
|
||||
|
@ -29,11 +36,16 @@ type Object {
|
|||
type ObjectCollection {
|
||||
totalCount: Int!
|
||||
cursor: String
|
||||
objects: [Object]!
|
||||
objects: [Object!]!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
objectCreate(objectInput: ObjectCreateInput!): [String]!
|
||||
objectCreate(objectInput: ObjectCreateInput!): [String!]!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:write")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
}
|
||||
|
||||
input ObjectCreateInput {
|
||||
|
|
|
@ -30,6 +30,7 @@ input ProjectCreateInput {
|
|||
name: String
|
||||
description: String
|
||||
visibility: ProjectVisibility
|
||||
workspaceId: String
|
||||
}
|
||||
|
||||
input ProjectUpdateRoleInput {
|
||||
|
@ -99,6 +100,11 @@ type ProjectMutations {
|
|||
"""
|
||||
delete(id: String!): Boolean! @hasServerRole(role: SERVER_USER)
|
||||
|
||||
"""
|
||||
Batch delete projects
|
||||
"""
|
||||
batchDelete(ids: [String!]!): Boolean! @hasServerRole(role: SERVER_ADMIN)
|
||||
|
||||
"""
|
||||
Updates an existing project
|
||||
"""
|
||||
|
|
|
@ -4,6 +4,9 @@ extend type Query {
|
|||
to see it, for example, if a stream isn't public and the user doesn't have the appropriate rights.
|
||||
"""
|
||||
stream(id: String!): Stream
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use Query.project instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Returns all streams that the active user is a collaborator on.
|
||||
|
@ -12,6 +15,9 @@ extend type Query {
|
|||
streams(query: String, limit: Int = 25, cursor: String): StreamCollection
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use User.projects instead."
|
||||
)
|
||||
|
||||
"""
|
||||
All the streams of the server. Available to admins only.
|
||||
|
@ -37,6 +43,9 @@ extend type Query {
|
|||
"""
|
||||
sort: DiscoverableStreamsSortingInput
|
||||
): StreamCollection
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
}
|
||||
|
||||
type Stream {
|
||||
|
@ -81,6 +90,9 @@ extend type User {
|
|||
streams(limit: Int! = 25, cursor: String): StreamCollection!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use User.projects instead."
|
||||
)
|
||||
|
||||
"""
|
||||
All the streams that a active user has favorited.
|
||||
|
@ -89,11 +101,17 @@ extend type User {
|
|||
favoriteStreams(limit: Int! = 25, cursor: String): StreamCollection!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
|
||||
"""
|
||||
Total amount of favorites attached to streams owned by the user
|
||||
"""
|
||||
totalOwnedStreamsFavorites: Int!
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
}
|
||||
|
||||
extend type LimitedUser {
|
||||
|
@ -103,11 +121,17 @@ extend type LimitedUser {
|
|||
streams(limit: Int! = 25, cursor: String): StreamCollection!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
|
||||
"""
|
||||
Total amount of favorites attached to streams owned by the user
|
||||
"""
|
||||
totalOwnedStreamsFavorites: Int!
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
}
|
||||
|
||||
type StreamCollaborator {
|
||||
|
@ -124,8 +148,8 @@ type PendingStreamCollaborator {
|
|||
inviteId: String!
|
||||
projectId: String!
|
||||
projectName: String!
|
||||
streamId: String!
|
||||
streamName: String!
|
||||
streamId: String! @deprecated(reason: "Use projectId instead")
|
||||
streamName: String! @deprecated(reason: "Use projectName instead")
|
||||
"""
|
||||
E-mail address or name of the invited user
|
||||
"""
|
||||
|
@ -155,41 +179,71 @@ extend type Mutation {
|
|||
streamCreate(stream: StreamCreateInput!): String
|
||||
@hasServerRole(role: SERVER_USER)
|
||||
@hasScope(scope: "streams:write")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use ProjectMutations.create instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Updates an existing stream.
|
||||
"""
|
||||
streamUpdate(stream: StreamUpdateInput!): Boolean!
|
||||
@hasServerRole(role: SERVER_USER)
|
||||
@hasScope(scope: "streams:write")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use ProjectMutations.update instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Deletes an existing stream.
|
||||
"""
|
||||
streamDelete(id: String!): Boolean!
|
||||
@hasServerRole(role: SERVER_USER)
|
||||
@hasScope(scope: "streams:write")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use ProjectMutations.delete instead."
|
||||
)
|
||||
|
||||
streamsDelete(ids: [String!]): Boolean!
|
||||
@hasServerRole(role: SERVER_ADMIN)
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use ProjectMutations.batchDelete instead."
|
||||
)
|
||||
|
||||
streamsDelete(ids: [String!]): Boolean! @hasServerRole(role: SERVER_ADMIN)
|
||||
"""
|
||||
Update permissions of a user on a given stream.
|
||||
"""
|
||||
streamUpdatePermission(permissionParams: StreamUpdatePermissionInput!): Boolean
|
||||
@hasServerRole(role: SERVER_USER)
|
||||
@hasScope(scope: "streams:write")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use ProjectMutations.updateRole instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Revokes the permissions of a user on a given stream.
|
||||
"""
|
||||
streamRevokePermission(permissionParams: StreamRevokePermissionInput!): Boolean
|
||||
@hasServerRole(role: SERVER_USER)
|
||||
@hasScope(scope: "streams:write")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use ProjectMutations.updateRole instead."
|
||||
)
|
||||
|
||||
# Favorite/unfavorite the given stream
|
||||
streamFavorite(streamId: String!, favorited: Boolean!): Stream
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
|
||||
"""
|
||||
Remove yourself from stream collaborators (not possible for the owner)
|
||||
"""
|
||||
streamLeave(streamId: String!): Boolean! @hasServerRole(role: SERVER_GUEST)
|
||||
streamLeave(streamId: String!): Boolean!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use ProjectMutations.leave instead."
|
||||
)
|
||||
}
|
||||
|
||||
extend type Subscription {
|
||||
|
@ -205,6 +259,9 @@ extend type Subscription {
|
|||
userStreamAdded: JSONObject
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "profile:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use userProjectsUpdated instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Subscribes to stream removed event for your profile. Use this to display an up-to-date list of streams for your profile.
|
||||
|
@ -213,6 +270,9 @@ extend type Subscription {
|
|||
userStreamRemoved: JSONObject
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "profile:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use userProjectsUpdated instead."
|
||||
)
|
||||
|
||||
#
|
||||
# Stream bound subscriptions that operate on the stream itself.
|
||||
|
@ -225,6 +285,9 @@ extend type Subscription {
|
|||
streamUpdated(streamId: String): JSONObject
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use projectUpdated instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Subscribes to stream deleted event. Use this in clients/components that pertain only to this stream.
|
||||
|
@ -232,6 +295,9 @@ extend type Subscription {
|
|||
streamDeleted(streamId: String): JSONObject
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use projectUpdated instead."
|
||||
)
|
||||
}
|
||||
|
||||
input StreamCreateInput {
|
||||
|
@ -250,6 +316,7 @@ input StreamCreateInput {
|
|||
Optionally specify user IDs of users that you want to invite to be contributors to this stream
|
||||
"""
|
||||
withContributors: [String!]
|
||||
workspaceId: String
|
||||
}
|
||||
|
||||
input StreamUpdateInput {
|
||||
|
|
|
@ -48,6 +48,9 @@ extend type Query {
|
|||
Validate password strength
|
||||
"""
|
||||
userPwdStrength(pwd: String!): PasswordStrengthCheckResults!
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future."
|
||||
)
|
||||
}
|
||||
|
||||
type PasswordStrengthCheckResults {
|
||||
|
@ -78,6 +81,9 @@ when a user is reading/writing info about himself
|
|||
"""
|
||||
type User {
|
||||
id: ID!
|
||||
"""
|
||||
Only returned if API user is the user being requested or an admin
|
||||
"""
|
||||
email: String
|
||||
name: String!
|
||||
bio: String
|
||||
|
|
|
@ -3,10 +3,17 @@ extend type Stream {
|
|||
Returns a list of all the file uploads for this stream.
|
||||
"""
|
||||
fileUploads: [FileUpload!]!
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use Project.pendingImportedModels or Model.pendingImportedVersions instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Returns a specific file upload that belongs to this stream.
|
||||
"""
|
||||
fileUpload(id: String!): FileUpload
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use Project.pendingImportedModels or Model.pendingImportedVersions instead."
|
||||
)
|
||||
}
|
||||
|
||||
extend type Project {
|
||||
|
@ -123,4 +130,7 @@ extend type Subscription {
|
|||
Subscribe to changes to any of a project's file imports
|
||||
"""
|
||||
projectFileImportUpdated(id: String!): ProjectFileImportUpdatedMessage!
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use projectPendingModelsUpdated or projectPendingVersionsUpdated instead."
|
||||
)
|
||||
}
|
||||
|
|
|
@ -13,6 +13,9 @@ extend type Mutation {
|
|||
streamInviteCreate(input: StreamInviteCreateInput!): Boolean!
|
||||
@hasServerRole(role: SERVER_USER)
|
||||
@hasScope(scope: "users:invite")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use ProjectInviteMutations.create instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Note: The required scope to invoke this is not given out to app or personal access tokens
|
||||
|
@ -27,12 +30,18 @@ extend type Mutation {
|
|||
streamInviteBatchCreate(input: [StreamInviteCreateInput!]!): Boolean!
|
||||
@hasServerRole(role: SERVER_ADMIN)
|
||||
@hasScope(scope: "users:invite")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use ProjectInviteMutations.batchCreate instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Accept or decline a stream invite
|
||||
"""
|
||||
streamInviteUse(accept: Boolean!, streamId: String!, token: String!): Boolean!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use ProjectInviteMutations.use instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Cancel a pending stream invite. Can only be invoked by a stream owner.
|
||||
|
@ -41,6 +50,9 @@ extend type Mutation {
|
|||
streamInviteCancel(streamId: String!, inviteId: String!): Boolean!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "users:invite")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use ProjectInviteMutations.cancel instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Re-send a pending invite
|
||||
|
@ -65,6 +77,9 @@ extend type Query {
|
|||
isn't specified, the server will look for any valid invite.
|
||||
"""
|
||||
streamInvite(streamId: String!, token: String): PendingStreamCollaborator
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use Query.projectInvite instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Look for an invitation to a project, for the current user (authed or not). If token
|
||||
|
@ -78,6 +93,9 @@ extend type Query {
|
|||
streamInvites: [PendingStreamCollaborator!]!
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "streams:read")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use User.projectInvites instead."
|
||||
)
|
||||
|
||||
"""
|
||||
Receive metadata about an invite by the invite token
|
||||
|
|
|
@ -2,6 +2,9 @@ extend type Stream {
|
|||
webhooks(id: String): WebhookCollection!
|
||||
@hasServerRole(role: SERVER_USER)
|
||||
@hasScope(scope: "streams:write")
|
||||
@deprecated(
|
||||
reason: "Part of the old API surface and will be removed in the future. Use Project.webhooks instead."
|
||||
)
|
||||
}
|
||||
|
||||
extend type Project {
|
||||
|
|
|
@ -13,11 +13,14 @@ generates:
|
|||
Stream: '@/modules/core/helpers/graphTypes#StreamGraphQLReturn'
|
||||
Commit: '@/modules/core/helpers/graphTypes#CommitGraphQLReturn'
|
||||
Project: '@/modules/core/helpers/graphTypes#ProjectGraphQLReturn'
|
||||
Object: '@/modules/core/helpers/graphTypes#ObjectGraphQLReturn'
|
||||
Version: '@/modules/core/helpers/graphTypes#VersionGraphQLReturn'
|
||||
ServerInvite: '@/modules/core/helpers/graphTypes#ServerInviteGraphQLReturnType'
|
||||
Model: '@/modules/core/helpers/graphTypes#ModelGraphQLReturn'
|
||||
ModelsTreeItem: '@/modules/core/helpers/graphTypes#ModelsTreeItemGraphQLReturn'
|
||||
StreamAccessRequest: '@/modules/accessrequests/helpers/graphTypes#StreamAccessRequestGraphQLReturn'
|
||||
ProjectAccessRequest: '@/modules/accessrequests/helpers/graphTypes#ProjectAccessRequestGraphQLReturn'
|
||||
ProjectAccessRequestMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn'
|
||||
LimitedUser: '@/modules/core/helpers/graphTypes#LimitedUserGraphQLReturn'
|
||||
ActiveUserMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn'
|
||||
ProjectMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn'
|
||||
|
|
|
@ -37,6 +37,7 @@ export const LoggingExpressMiddleware = HttpLogger({
|
|||
}
|
||||
|
||||
if (req.url === '/readiness' || req.url === '/liveness') return 'debug'
|
||||
if (req.url === '/metrics') return 'debug'
|
||||
return 'info'
|
||||
},
|
||||
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
Adapted from prom-client: https://github.com/siimon/prom-client/tree/master/lib/metrics
|
||||
|
||||
Copyright 2015 Simon Nyberg
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Histogram, Registry } from 'prom-client'
|
||||
import type { Metric } from '@/logging/highFrequencyMetrics/highfrequencyMonitoring'
|
||||
|
||||
const NODEJS_HEAP_SIZE_TOTAL = 'nodejs_heap_size_total_bytes_high_frequency'
|
||||
const NODEJS_HEAP_SIZE_USED = 'nodejs_heap_size_used_bytes_high_frequency'
|
||||
const NODEJS_EXTERNAL_MEMORY = 'nodejs_external_memory_bytes_high_frequency'
|
||||
|
||||
type BucketName =
|
||||
| typeof NODEJS_HEAP_SIZE_TOTAL
|
||||
| typeof NODEJS_HEAP_SIZE_USED
|
||||
| typeof NODEJS_EXTERNAL_MEMORY
|
||||
|
||||
const DEFAULT_NODEJS_HEAP_SIZE_BUCKETS = {
|
||||
NODEJS_HEAP_SIZE_TOTAL: [0, 0.1e9, 0.25e9, 0.5e9, 0.75e9, 1e9, 2e9], //TODO: check if this is the right default
|
||||
NODEJS_HEAP_SIZE_USED: [0, 0.1e9, 0.25e9, 0.5e9, 0.75e9, 1e9, 2e9], //TODO: check if this is the right default
|
||||
NODEJS_EXTERNAL_MEMORY: [0, 0.1e9, 0.25e9, 0.5e9, 0.75e9, 1e9, 2e9] //TODO: check if this is the right default
|
||||
}
|
||||
|
||||
type MetricConfig = {
|
||||
prefix?: string
|
||||
labels?: Record<string, string>
|
||||
buckets?: Record<BucketName, number[]>
|
||||
}
|
||||
|
||||
export const heapSizeAndUsed = (
|
||||
registry: Registry,
|
||||
config: MetricConfig = {}
|
||||
): Metric => {
|
||||
const registers = registry ? [registry] : undefined
|
||||
const namePrefix = config.prefix ?? ''
|
||||
const labels = config.labels ?? {}
|
||||
const labelNames = Object.keys(labels)
|
||||
const buckets = { ...DEFAULT_NODEJS_HEAP_SIZE_BUCKETS, ...config.buckets }
|
||||
|
||||
const heapSizeTotal = new Histogram({
|
||||
name: namePrefix + NODEJS_HEAP_SIZE_TOTAL,
|
||||
help: 'Process heap size from Node.js in bytes. This data is collected at a higher frequency than Prometheus scrapes, and is presented as a Histogram.',
|
||||
registers,
|
||||
buckets: buckets.NODEJS_HEAP_SIZE_TOTAL,
|
||||
labelNames
|
||||
})
|
||||
const heapSizeUsed = new Histogram({
|
||||
name: namePrefix + NODEJS_HEAP_SIZE_USED,
|
||||
help: 'Process heap size used from Node.js in bytes. This data is collected at a higher frequency than Prometheus scrapes, and is presented as a Histogram.',
|
||||
registers,
|
||||
buckets: buckets.NODEJS_HEAP_SIZE_USED,
|
||||
labelNames
|
||||
})
|
||||
const externalMemUsed = new Histogram({
|
||||
name: namePrefix + NODEJS_EXTERNAL_MEMORY,
|
||||
help: 'Node.js external memory size in bytes. This data is collected at a higher frequency than Prometheus scrapes, and is presented as a Histogram.',
|
||||
registers,
|
||||
buckets: buckets.NODEJS_EXTERNAL_MEMORY,
|
||||
labelNames
|
||||
})
|
||||
|
||||
return {
|
||||
collect: () => {
|
||||
const memUsage = safeMemoryUsage()
|
||||
if (memUsage) {
|
||||
heapSizeTotal.observe(labels, memUsage.heapTotal)
|
||||
heapSizeUsed.observe(labels, memUsage.heapUsed)
|
||||
if (memUsage.external !== undefined) {
|
||||
externalMemUsed.observe(labels, memUsage.external)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeMemoryUsage() {
|
||||
try {
|
||||
return process.memoryUsage()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* High frequency monitoring, collects data related to CPU, memory, and network usage
|
||||
* at a higher frequency than the default prometheus monitoring. It makes the data
|
||||
* available to Prometheus via an histogram.
|
||||
*/
|
||||
|
||||
import { Histogram, Registry } from 'prom-client'
|
||||
import { processCpuTotal } from '@/logging/highFrequencyMetrics/processCPUTotal'
|
||||
import { heapSizeAndUsed } from '@/logging/highFrequencyMetrics/heapSizeAndUsed'
|
||||
|
||||
type MetricConfig = {
|
||||
prefix?: string
|
||||
labels?: Record<string, string>
|
||||
buckets?: Record<string, number[]>
|
||||
}
|
||||
|
||||
type HighFrequencyMonitor = {
|
||||
start: () => () => void
|
||||
}
|
||||
|
||||
export const initHighFrequencyMonitoring = (params: {
|
||||
register: Registry
|
||||
collectionPeriodMilliseconds: number
|
||||
config?: MetricConfig
|
||||
}): HighFrequencyMonitor => {
|
||||
const { register, collectionPeriodMilliseconds } = params
|
||||
const config = params.config ?? {}
|
||||
const registers = register ? [register] : undefined
|
||||
const namePrefix = config.prefix ?? ''
|
||||
const labels = config.labels ?? {}
|
||||
const labelNames = Object.keys(labels)
|
||||
|
||||
const metrics = [processCpuTotal(register, config), heapSizeAndUsed(register, config)]
|
||||
|
||||
const selfMonitor = new Histogram({
|
||||
name: namePrefix + 'self_monitor_time_high_frequency',
|
||||
help: 'The time taken to collect all of the high frequency metrics, seconds.',
|
||||
registers,
|
||||
buckets: [0, 0.001, 0.01, 0.025, 0.05, 0.1, 0.2],
|
||||
labelNames
|
||||
})
|
||||
|
||||
return {
|
||||
start: collectHighFrequencyMetrics({
|
||||
selfMonitor,
|
||||
metrics,
|
||||
collectionPeriodMilliseconds
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export interface Metric {
|
||||
collect: () => void
|
||||
}
|
||||
|
||||
const collectHighFrequencyMetrics = (params: {
|
||||
selfMonitor: Histogram<string>
|
||||
collectionPeriodMilliseconds: number
|
||||
metrics: Metric[]
|
||||
}) => {
|
||||
const { selfMonitor, metrics, collectionPeriodMilliseconds } = params
|
||||
return () => {
|
||||
const intervalId = setInterval(() => {
|
||||
const end = selfMonitor.startTimer()
|
||||
for (const metric of metrics) {
|
||||
metric.collect()
|
||||
}
|
||||
end()
|
||||
}, collectionPeriodMilliseconds)
|
||||
return () => clearInterval(intervalId)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Adapted from prom-client: https://github.com/siimon/prom-client/tree/master/lib/metrics
|
||||
*
|
||||
Copyright 2015 Simon Nyberg
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Histogram, Registry } from 'prom-client'
|
||||
import type { Metric } from '@/logging/highFrequencyMetrics/highfrequencyMonitoring'
|
||||
|
||||
const PROCESS_CPU_USER_SECONDS = 'process_cpu_user_seconds_total_high_frequency'
|
||||
const PROCESS_CPU_SYSTEM_SECONDS = 'process_cpu_system_seconds_total_high_frequency'
|
||||
const PROCESS_CPU_SECONDS = 'process_cpu_seconds_total_high_frequency'
|
||||
|
||||
type BucketName =
|
||||
| typeof PROCESS_CPU_USER_SECONDS
|
||||
| typeof PROCESS_CPU_SYSTEM_SECONDS
|
||||
| typeof PROCESS_CPU_SECONDS
|
||||
|
||||
const DEFAULT_CPU_TOTAL_BUCKETS = {
|
||||
PROCESS_CPU_SECONDS: [0, 0.1, 0.25, 0.5, 0.75, 1, 2], //TODO: check if this is the right default
|
||||
PROCESS_CPU_USER_SECONDS: [0, 0.1, 0.25, 0.5, 0.75, 1, 2], //TODO: check if this is the right default
|
||||
PROCESS_CPU_SYSTEM_SECONDS: [0, 0.1, 0.25, 0.5, 0.75, 1, 2] //TODO: check if this is the right default
|
||||
}
|
||||
|
||||
type MetricConfig = {
|
||||
prefix?: string
|
||||
labels?: Record<string, string>
|
||||
buckets?: Record<BucketName, number[]>
|
||||
}
|
||||
|
||||
export const processCpuTotal = (
|
||||
registry: Registry,
|
||||
config: MetricConfig = {}
|
||||
): Metric => {
|
||||
const registers = registry ? [registry] : undefined
|
||||
const namePrefix = config.prefix ?? ''
|
||||
const labels = config.labels ?? {}
|
||||
const labelNames = Object.keys(labels)
|
||||
const buckets = { ...DEFAULT_CPU_TOTAL_BUCKETS, ...config.buckets }
|
||||
|
||||
const cpuUserUsageHistogram = new Histogram({
|
||||
name: namePrefix + PROCESS_CPU_USER_SECONDS,
|
||||
help: 'Total user CPU time spent in seconds. This data is collected at a higher frequency than Prometheus scrapes, and is presented as a Histogram.',
|
||||
labelNames,
|
||||
buckets: buckets.PROCESS_CPU_USER_SECONDS,
|
||||
registers
|
||||
})
|
||||
const cpuSystemUsageHistogram = new Histogram({
|
||||
name: namePrefix + PROCESS_CPU_SYSTEM_SECONDS,
|
||||
help: 'Total system CPU time spent in seconds. This data is collected at a higher frequency than Prometheus scrapes, and is presented as a Histogram.',
|
||||
registers,
|
||||
buckets: buckets.PROCESS_CPU_SYSTEM_SECONDS,
|
||||
labelNames
|
||||
})
|
||||
const cpuUsageHistogram = new Histogram({
|
||||
name: namePrefix + PROCESS_CPU_SECONDS,
|
||||
help: 'Total user and system CPU time spent in seconds. This data is collected at a higher frequency than Prometheus scrapes, and is presented as a Histogram.',
|
||||
registers,
|
||||
buckets: buckets.PROCESS_CPU_USER_SECONDS,
|
||||
labelNames
|
||||
})
|
||||
|
||||
let lastCpuUsage = process.cpuUsage()
|
||||
|
||||
return {
|
||||
collect: () => {
|
||||
const cpuUsage = process.cpuUsage()
|
||||
|
||||
const userUsageMicros = cpuUsage.user - lastCpuUsage.user
|
||||
const systemUsageMicros = cpuUsage.system - lastCpuUsage.system
|
||||
|
||||
lastCpuUsage = cpuUsage
|
||||
|
||||
cpuUserUsageHistogram.observe(labels, userUsageMicros / 1e6)
|
||||
cpuSystemUsageHistogram.observe(labels, systemUsageMicros / 1e6)
|
||||
cpuUsageHistogram.observe(labels, (userUsageMicros + systemUsageMicros) / 1e6)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,7 +5,10 @@ const { getMachineId } = require('./machineId')
|
|||
const prometheusClient = require('prom-client')
|
||||
const promBundle = require('express-prom-bundle')
|
||||
|
||||
const { initKnexPrometheusMetrics } = require('./knexMonitoring')
|
||||
const { initKnexPrometheusMetrics } = require('@/logging/knexMonitoring')
|
||||
const {
|
||||
initHighFrequencyMonitoring
|
||||
} = require('@/logging/highFrequencyMetrics/highfrequencyMonitoring')
|
||||
|
||||
let prometheusInitialized = false
|
||||
|
||||
|
@ -20,6 +23,11 @@ module.exports = function (app) {
|
|||
app: 'server'
|
||||
})
|
||||
prometheusClient.collectDefaultMetrics()
|
||||
const highfrequencyMonitoring = initHighFrequencyMonitoring({
|
||||
register: prometheusClient.register,
|
||||
collectionPeriodMilliseconds: 100
|
||||
})
|
||||
highfrequencyMonitoring.start()
|
||||
|
||||
initKnexPrometheusMetrics()
|
||||
const expressMetricsMiddleware = promBundle({
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import { AccessRequestType } from '@/modules/accessrequests/repositories'
|
||||
import {
|
||||
getPendingProjectRequests,
|
||||
getPendingStreamRequests,
|
||||
getUserProjectAccessRequest,
|
||||
getUserStreamAccessRequest,
|
||||
processPendingProjectRequest,
|
||||
processPendingStreamRequest,
|
||||
requestProjectAccess,
|
||||
requestStreamAccess
|
||||
} from '@/modules/accessrequests/services/stream'
|
||||
import { Resolvers } from '@/modules/core/graph/generated/graphql'
|
||||
|
@ -35,6 +40,35 @@ const resolvers: Resolvers = {
|
|||
return await requestStreamAccess(userId, streamId)
|
||||
}
|
||||
},
|
||||
ProjectMutations: {
|
||||
accessRequestMutations: () => ({})
|
||||
},
|
||||
ProjectAccessRequestMutations: {
|
||||
async create(_parent, args, ctx) {
|
||||
const { userId } = ctx
|
||||
const { projectId } = args
|
||||
return await requestProjectAccess(userId!, projectId)
|
||||
},
|
||||
async use(_parent, args, ctx) {
|
||||
const { userId, resourceAccessRules } = ctx
|
||||
const { requestId, accept, role } = args
|
||||
|
||||
const usedReq = await processPendingProjectRequest(
|
||||
userId!,
|
||||
requestId,
|
||||
accept,
|
||||
mapStreamRoleToValue(role),
|
||||
resourceAccessRules
|
||||
)
|
||||
|
||||
const project = await ctx.loaders.streams.getStream.load(usedReq.resourceId)
|
||||
if (!project) {
|
||||
throw new LogicError('Unexpectedly unable to find request project')
|
||||
}
|
||||
|
||||
return project
|
||||
}
|
||||
},
|
||||
Query: {
|
||||
async streamAccessRequest(_, args, ctx) {
|
||||
const { streamId } = args
|
||||
|
@ -44,12 +78,26 @@ const resolvers: Resolvers = {
|
|||
return await getUserStreamAccessRequest(userId, streamId)
|
||||
}
|
||||
},
|
||||
User: {
|
||||
async projectAccessRequest(parent, args) {
|
||||
const { id: userId } = parent
|
||||
const { projectId } = args
|
||||
|
||||
return await getUserProjectAccessRequest(userId, projectId)
|
||||
}
|
||||
},
|
||||
Stream: {
|
||||
async pendingAccessRequests(parent) {
|
||||
const { id } = parent
|
||||
return await getPendingStreamRequests(id)
|
||||
}
|
||||
},
|
||||
Project: {
|
||||
async pendingAccessRequests(parent) {
|
||||
const { id } = parent
|
||||
return await getPendingProjectRequests(id)
|
||||
}
|
||||
},
|
||||
StreamAccessRequest: {
|
||||
async requester(parent, _args, ctx) {
|
||||
const { requesterId } = parent
|
||||
|
@ -77,6 +125,45 @@ const resolvers: Resolvers = {
|
|||
|
||||
return stream
|
||||
}
|
||||
},
|
||||
ProjectAccessRequest: {
|
||||
async requester(parent, _args, ctx) {
|
||||
const { requesterId } = parent
|
||||
const user = await ctx.loaders.users.getUser.load(requesterId)
|
||||
if (!user) {
|
||||
throw new LogicError('Unable to find requester')
|
||||
}
|
||||
|
||||
return user
|
||||
},
|
||||
async projectId(parent) {
|
||||
const { resourceId, resourceType } = parent
|
||||
if (resourceType !== AccessRequestType.Stream) {
|
||||
throw new LogicError('Unexpectedly returned invalid resource type')
|
||||
}
|
||||
|
||||
return resourceId
|
||||
},
|
||||
async project(parent, _args, ctx) {
|
||||
const { resourceId, resourceType } = parent
|
||||
if (resourceType !== AccessRequestType.Stream) {
|
||||
throw new LogicError('Unexpectedly returned invalid resource type')
|
||||
}
|
||||
|
||||
const project = await ctx.loaders.streams.getStream.load(resourceId)
|
||||
if (!project) {
|
||||
throw new LogicError('Unable to find request project')
|
||||
}
|
||||
|
||||
await validateStreamAccess(
|
||||
ctx.userId,
|
||||
project.id,
|
||||
Roles.Stream.Reviewer,
|
||||
ctx.resourceAccessRules
|
||||
)
|
||||
|
||||
return project
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import { StreamAccessRequestRecord } from '@/modules/accessrequests/repositories'
|
||||
import { StreamAccessRequest } from '@/modules/core/graph/generated/graphql'
|
||||
|
||||
export type StreamAccessRequestGraphQLReturn = Omit<
|
||||
StreamAccessRequest,
|
||||
'requester' | 'stream'
|
||||
>
|
||||
|
||||
export type ProjectAccessRequestGraphQLReturn = StreamAccessRequestRecord
|
||||
|
|
|
@ -12,7 +12,8 @@ import {
|
|||
getPendingAccessRequest,
|
||||
getPendingAccessRequests,
|
||||
getUsersPendingAccessRequest,
|
||||
ServerAccessRequestRecord
|
||||
ServerAccessRequestRecord,
|
||||
StreamAccessRequestRecord
|
||||
} from '@/modules/accessrequests/repositories'
|
||||
import { StreamInvalidAccessError } from '@/modules/core/errors/stream'
|
||||
import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types'
|
||||
|
@ -36,44 +37,52 @@ function buildStreamAccessRequestGraphQLReturn(
|
|||
}
|
||||
}
|
||||
|
||||
export async function getUserProjectAccessRequest(
|
||||
userId: string,
|
||||
projectId: string
|
||||
): Promise<Nullable<StreamAccessRequestRecord>> {
|
||||
const req = await getUsersPendingAccessRequest(
|
||||
userId,
|
||||
AccessRequestType.Stream,
|
||||
projectId
|
||||
)
|
||||
return req || null
|
||||
}
|
||||
|
||||
export async function getUserStreamAccessRequest(
|
||||
userId: string,
|
||||
streamId: string
|
||||
): Promise<Nullable<StreamAccessRequestGraphQLReturn>> {
|
||||
const req = await getUsersPendingAccessRequest(
|
||||
userId,
|
||||
AccessRequestType.Stream,
|
||||
streamId
|
||||
)
|
||||
const req = await getUserProjectAccessRequest(userId, streamId)
|
||||
if (!req) return null
|
||||
|
||||
return buildStreamAccessRequestGraphQLReturn(req)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new stream access request
|
||||
* Create new project access request
|
||||
*/
|
||||
export async function requestStreamAccess(userId: string, streamId: string) {
|
||||
export async function requestProjectAccess(userId: string, projectId: string) {
|
||||
const [stream, existingRequest] = await Promise.all([
|
||||
getStream({ userId, streamId }),
|
||||
getUserStreamAccessRequest(userId, streamId)
|
||||
getStream({ userId, streamId: projectId }),
|
||||
getUserStreamAccessRequest(userId, projectId)
|
||||
])
|
||||
|
||||
if (existingRequest) {
|
||||
throw new AccessRequestCreationError(
|
||||
'User already has a pending access request for this stream'
|
||||
'User already has a pending access request for this resource'
|
||||
)
|
||||
}
|
||||
|
||||
if (!stream) {
|
||||
throw new AccessRequestCreationError(
|
||||
"Can't request access to a non-existant stream"
|
||||
"Can't request access to a non-existant resource"
|
||||
)
|
||||
}
|
||||
|
||||
if (stream.role) {
|
||||
throw new AccessRequestCreationError(
|
||||
'User already has access to the specified stream'
|
||||
'User already has access to the specified resource'
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -81,23 +90,40 @@ export async function requestStreamAccess(userId: string, streamId: string) {
|
|||
id: generateId(),
|
||||
requesterId: userId,
|
||||
resourceType: AccessRequestType.Stream,
|
||||
resourceId: streamId
|
||||
resourceId: projectId
|
||||
})
|
||||
|
||||
await AccessRequestsEmitter.emit(AccessRequestsEmitter.events.Created, {
|
||||
request: req
|
||||
})
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new stream access request
|
||||
*/
|
||||
export async function requestStreamAccess(userId: string, streamId: string) {
|
||||
const req = await requestProjectAccess(userId, streamId)
|
||||
return buildStreamAccessRequestGraphQLReturn(req)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending project access requests
|
||||
*/
|
||||
export async function getPendingProjectRequests(
|
||||
projectId: string
|
||||
): Promise<StreamAccessRequestRecord[]> {
|
||||
return await getPendingAccessRequests(AccessRequestType.Stream, projectId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending stream access requests
|
||||
*/
|
||||
export async function getPendingStreamRequests(
|
||||
streamId: string
|
||||
): Promise<StreamAccessRequestGraphQLReturn[]> {
|
||||
const reqs = await getPendingAccessRequests(AccessRequestType.Stream, streamId)
|
||||
const reqs = await getPendingProjectRequests(streamId)
|
||||
return reqs.map(buildStreamAccessRequestGraphQLReturn)
|
||||
}
|
||||
|
||||
|
@ -152,4 +178,8 @@ export async function processPendingStreamRequest(
|
|||
approved: accept ? { role } : undefined,
|
||||
finalizedBy: userId
|
||||
})
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
export const processPendingProjectRequest = processPendingStreamRequest
|
||||
|
|
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Загрузка…
Ссылка в новой задаче