Merge branch 'main' into alex/sRGB-vertex-colors

This commit is contained in:
AlexandruPopovici 2024-07-16 16:47:01 +03:00
Родитель 45c356eda9 f1d968d50d
Коммит 6d4f5abc57
279 изменённых файлов: 6758 добавлений и 2298 удалений

Просмотреть файл

@ -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

1
.gitignore поставляемый
Просмотреть файл

@ -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

4
packages/objectloader/types/index.d.ts поставляемый
Просмотреть файл

@ -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

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше