Merge branch 'main' into andrew/web-4364-applicationids-are-treated-as-numerical
This commit is contained in:
@@ -1,6 +1,18 @@
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
GITHUB_REGISTRY_URL:
|
||||
required: true
|
||||
type: string
|
||||
GITHUB_ORG:
|
||||
required: true
|
||||
type: string
|
||||
NPM_REGISTRY_URL:
|
||||
required: true
|
||||
type: string
|
||||
NPM_PUBLISH_ACCESS:
|
||||
required: true
|
||||
type: string
|
||||
IMAGE_VERSION_TAG:
|
||||
required: true
|
||||
type: string
|
||||
@@ -13,8 +25,12 @@ jobs:
|
||||
name: Publish to npm
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
GITHUB_REGISTRY_URL: ${{ inputs.GITHUB_REGISTRY_URL }}
|
||||
GITHUB_ORG: ${{ inputs.GITHUB_ORG }}
|
||||
NPM_REGISTRY_URL: ${{ inputs.NPM_REGISTRY_URL }}
|
||||
NPM_PUBLISH_ACCESS: ${{ inputs.NPM_PUBLISH_ACCESS }}
|
||||
IMAGE_VERSION_TAG: ${{ inputs.IMAGE_VERSION_TAG }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
@@ -25,15 +41,22 @@ jobs:
|
||||
cache: yarn
|
||||
- name: Install hardened (no HARD flag)
|
||||
run: PUPPETEER_SKIP_DOWNLOAD=true PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 yarn --immutable
|
||||
- name: Auth to npm as Speckle
|
||||
- name: Auth to npm
|
||||
run: |
|
||||
echo "npmRegistryServer: https://registry.npmjs.org/" >> .yarnrc.yml
|
||||
echo "npmAuthToken: $NPM_TOKEN" >> .yarnrc.yml
|
||||
echo "npmRegistryServer: ${NPM_REGISTRY_URL}" >> .yarnrc.yml
|
||||
echo "npmAuthToken: ${NPM_TOKEN}" >> .yarnrc.yml
|
||||
echo "npmPublishAccess: ${NPM_PUBLISH_ACCESS}" >> .yarnrc.yml
|
||||
- name: Try login to npm
|
||||
run: yarn npm whoami
|
||||
- name: Amend package name & git repository to match
|
||||
# required as per https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry#publishing-a-package-using-a-local-npmrc-file
|
||||
run: |
|
||||
if [ "${NPM_REGISTRY_URL}" != "https://registry.npmjs.org/" ]; then
|
||||
yarn workspaces foreach -tvW --no-private exec "jq '.name |= sub(\"@speckle\"; \"${GITHUB_ORG}\"), .repository.url = \"${GITHUB_REGISTRY_URL}\"' package.json > tmp.json && mv tmp.json package.json"
|
||||
fi
|
||||
- name: Build public packages
|
||||
run: yarn workspaces foreach -ptvW --no-private run build
|
||||
- name: Bump all versions
|
||||
run: yarn workspaces foreach -tvW version $IMAGE_VERSION_TAG
|
||||
run: yarn workspaces foreach -tvW version ${IMAGE_VERSION_TAG}
|
||||
- name: publish to npm
|
||||
run: 'yarn workspaces foreach -pvW --no-private npm publish --access public'
|
||||
run: 'yarn workspaces foreach -pvW --no-private npm publish --access ${NPM_PUBLISH_ACCESS}'
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
|
||||
REGISTRY_DOMAIN: 'ghcr.io'
|
||||
REGISTRY_USERNAME: ${{ github.actor }}
|
||||
# REGISTRY_DOMAIN, REGISTRY_USERNAME, REGISTRY_TOKEN must allow pushing to the below IMAGE_PREFIX
|
||||
# REGISTRY_DOMAIN, REGISTRY_USERNAME, REGISTRY_TOKEN must be configured to match the below IMAGE_PREFIX
|
||||
IMAGE_PREFIX: 'ghcr.io/specklesystems'
|
||||
PUBLISH: false # do not publish the sourcemaps or include the version in frontend-2 builds for pull requests
|
||||
secrets:
|
||||
|
||||
@@ -36,27 +36,27 @@ jobs:
|
||||
uses: ./.github/workflows/builds.yml
|
||||
with:
|
||||
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
|
||||
REGISTRY_DOMAIN: 'docker.io'
|
||||
REGISTRY_USERNAME: 'speckledevops'
|
||||
REGISTRY_DOMAIN: ${{ (github.repository == 'specklesystems/speckle-server') && 'docker.io' || 'ghcr.io' }}
|
||||
REGISTRY_USERNAME: ${{ (github.repository == 'specklesystems/speckle-server') && 'speckledevops' || github.actor }}
|
||||
# REGISTRY_DOMAIN, REGISTRY_USERNAME, REGISTRY_TOKEN must allow pushing to the below IMAGE_PREFIX
|
||||
IMAGE_PREFIX: 'speckle' # without an explicit host, Docker defaults to pushing Docker Hub
|
||||
IMAGE_PREFIX: ${{ (github.repository == 'specklesystems/speckle-server') && 'speckle' || 'ghcr.io/specklesystems' }}
|
||||
PUBLISH: true # publish the sourcemaps and include the version in frontend-2 builds
|
||||
PUBLISH_LATEST: ${{ startsWith(github.ref, 'refs/heads/main') }}
|
||||
secrets:
|
||||
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
|
||||
REGISTRY_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
REGISTRY_TOKEN: ${{ (github.repository == 'specklesystems/speckle-server') && secrets.DOCKERHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Temporary duplicate of builds job to push to ghcr.io
|
||||
#HACK temporary job to build and push to ghcr.io until we migrate everything
|
||||
builds-ghcr:
|
||||
needs: [get-version]
|
||||
uses: ./.github/workflows/builds.yml
|
||||
with:
|
||||
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
|
||||
REGISTRY_DOMAIN: 'ghcr.io'
|
||||
REGISTRY_DOMAIN: ${{ 'ghcr.io' }}
|
||||
REGISTRY_USERNAME: ${{ github.actor }}
|
||||
# REGISTRY_DOMAIN, REGISTRY_USERNAME, REGISTRY_TOKEN must allow pushing to the below IMAGE_PREFIX
|
||||
IMAGE_PREFIX: 'ghcr.io/specklesystems'
|
||||
PUBLISH: true # do not publish the sourcemaps or include the version in frontend-2 builds for pull requests
|
||||
IMAGE_PREFIX: ${{ 'ghcr.io/specklesystems' }}
|
||||
PUBLISH: true # publish the sourcemaps and include the version in frontend-2 builds
|
||||
PUBLISH_LATEST: ${{ startsWith(github.ref, 'refs/heads/main') }}
|
||||
secrets:
|
||||
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
|
||||
@@ -67,38 +67,65 @@ jobs:
|
||||
uses: ./.github/workflows/deployment-tests.yml
|
||||
with:
|
||||
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
|
||||
REGISTRY_DOMAIN: 'ghcr.io'
|
||||
REGISTRY_USERNAME: ${{ github.actor }}
|
||||
IMAGE_PREFIX: 'ghcr.io/specklesystems'
|
||||
REGISTRY_DOMAIN: ${{ (github.repository == 'specklesystems/speckle-server') && 'docker.io' || 'ghcr.io' }}
|
||||
REGISTRY_USERNAME: ${{ (github.repository == 'specklesystems/speckle-server') && 'speckledevops' || github.actor }}
|
||||
IMAGE_PREFIX: ${{ (github.repository == 'specklesystems/speckle-server') && 'speckle' || 'ghcr.io/specklesystems' }}
|
||||
secrets:
|
||||
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REGISTRY_TOKEN: ${{ (github.repository == 'specklesystems/speckle-server') && secrets.DOCKERHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
deploy:
|
||||
needs: [get-version, tests, builds, test-deployments, get-chart-name]
|
||||
needs: [get-version, tests, builds, builds-ghcr, test-deployments, get-chart-name]
|
||||
uses: ./.github/workflows/publish.yml
|
||||
with:
|
||||
IMAGE_PREFIX: 'ghcr.io/specklesystems'
|
||||
IMAGE_PREFIX: ${{ (github.repository == 'specklesystems/speckle-server') && 'speckle' || 'ghcr.io/specklesystems' }}
|
||||
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
|
||||
OCI_REGISTRY_DOMAIN: ghcr.io
|
||||
OCI_REGISTRY_PATH: specklesystems
|
||||
OCI_REGISTRY_USERNAME: ${{ github.actor }}
|
||||
OCI_REGISTRY_DOMAIN: ${{ (github.repository == 'specklesystems/speckle-server') && 'docker.io' || 'ghcr.io' }}
|
||||
OCI_REGISTRY_PATH: ${{ (github.repository == 'specklesystems/speckle-server') && 'speckle' || 'specklesystems' }}
|
||||
OCI_REGISTRY_USERNAME: ${{ (github.repository == 'specklesystems/speckle-server') && 'speckledevops' || github.actor }}
|
||||
CHART_NAME: ${{ needs.get-chart-name.outputs.CHART_NAME }}
|
||||
secrets:
|
||||
# we do not inherit here as we wish to configure secrets depending on the target registry
|
||||
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
OCI_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} # we are pushing helm chart to ghcr
|
||||
OCI_REGISTRY_PASSWORD: ${{ (github.repository == 'specklesystems/speckle-server') && secrets.DOCKERHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GH_DEVOPS_PAT: ${{ secrets.GH_DEVOPS_PAT }}
|
||||
|
||||
#HACK temporary job to publish helm charts to ghcr.io until we migrate everything
|
||||
ghcr-helm-chart-oci:
|
||||
needs: [get-version, tests, builds, builds-ghcr, test-deployments, get-chart-name]
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
name: Helm chart oci
|
||||
container:
|
||||
image: speckle/pre-commit-runner:latest
|
||||
env:
|
||||
IMAGE_PREFIX: 'ghcr.io/specklesystems'
|
||||
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
|
||||
HELM_REGISTRY_DOMAIN: 'ghcr.io'
|
||||
HELM_REPOSITORY_PATH: 'specklesystems'
|
||||
REGISTRY_USERNAME: ${{ github.actor }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
|
||||
CHART_NAME: ${{ needs.get-chart-name.outputs.CHART_NAME }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: git config --global --add safe.directory $PWD
|
||||
- name: Publish Helm Chart
|
||||
run: ./.github/workflows/scripts/publish_helm_chart_oci.sh
|
||||
|
||||
npm:
|
||||
needs: [get-version, tests, builds, builds-ghcr]
|
||||
uses: ./.github/workflows/npm.yml
|
||||
# only run if a tag triggered the workflow
|
||||
# only run if a tag triggered the workflow on specklesystems/speckle-server repository
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
GITHUB_REGISTRY_URL: ${{ format('%s%s.git', 'https://github.com/', github.repository) }}
|
||||
GITHUB_ORG: ${{ github.repository_owner }}
|
||||
NPM_REGISTRY_URL: ${{ github.repository == 'specklesystems/speckle-server' && 'https://registry.npmjs.org/' || 'https://npm.pkg.github.com/' }}
|
||||
NPM_PUBLISH_ACCESS: ${{ github.repository == 'specklesystems/speckle-server' && 'public' || 'restricted' }}
|
||||
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
|
||||
secrets: inherit
|
||||
secrets:
|
||||
NPM_TOKEN: ${{ github.repository == 'specklesystems/speckle-server' && secrets.NPM_TOKEN || github.token}}
|
||||
|
||||
snyk:
|
||||
needs: [tests]
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
@@ -74,7 +74,6 @@ import { SpeckleViewer } from '@speckle/shared'
|
||||
import { keyboardClick } from '@speckle/ui-components'
|
||||
import { graphql } from '~/lib/common/generated/gql/gql'
|
||||
import type { HeaderNavShare_ProjectFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { useCopyModelLink } from '~~/lib/projects/composables/modelManagement'
|
||||
|
||||
graphql(`
|
||||
fragment HeaderNavShare_Project on Project {
|
||||
@@ -90,7 +89,6 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { copy } = useClipboard()
|
||||
const copyModelLink = useCopyModelLink()
|
||||
const menuButtonId = useId()
|
||||
|
||||
const embedDialogOpen = ref(false)
|
||||
@@ -99,37 +97,16 @@ const parsedResourceIds = computed(() =>
|
||||
SpeckleViewer.ViewerRoute.parseUrlParameters(props.resourceIdString)
|
||||
)
|
||||
|
||||
const firstResource = computed(() => parsedResourceIds.value[0] || {})
|
||||
|
||||
const versionId = computed(() => {
|
||||
if (SpeckleViewer.ViewerRoute.isModelResource(firstResource.value)) {
|
||||
return firstResource.value.versionId
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const modelId = computed(() => {
|
||||
if (SpeckleViewer.ViewerRoute.isModelResource(firstResource.value)) {
|
||||
return firstResource.value.modelId // Assuming your firstResource object has a modelId property
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const isFederated = computed(() => parsedResourceIds.value.length > 1)
|
||||
|
||||
const handleCopyId = () => {
|
||||
copy(props.resourceIdString, { successMessage: 'ID copied to clipboard' })
|
||||
const handleCopyId = async () => {
|
||||
await copy(props.resourceIdString, { successMessage: 'ID copied to clipboard' })
|
||||
}
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const modelIdValue = modelId.value
|
||||
const versionIdValue = versionId.value ? versionId.value : undefined
|
||||
void copyModelLink({
|
||||
model: {
|
||||
projectId: props.project.id,
|
||||
id: modelIdValue
|
||||
},
|
||||
versionId: versionIdValue
|
||||
const handleCopyLink = async () => {
|
||||
if (import.meta.server) return
|
||||
await copy(window.location.href, {
|
||||
successMessage: 'Copied link to clipboard'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between border border-foreground-1 bg-foundation rounded-lg p-2"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<img
|
||||
:src="integration.logo"
|
||||
alt=""
|
||||
class="w-10 h-10 p-1 object-cover border border-foreground-1 rounded-lg"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{{ integration.name }}</span>
|
||||
<span class="text-sm text-foreground-2">
|
||||
{{ integration.description }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<CommonLoadingIcon v-if="loading" :loading="true" class="opacity-50 mr-2" />
|
||||
<div v-else-if="integration.enabled">
|
||||
<div class="flex items-center text-sm text-foreground-2 space-x-2">
|
||||
<span
|
||||
v-if="
|
||||
integration.status === 'connected' || integration.status === 'expired'
|
||||
"
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="{
|
||||
'bg-success': integration.status === 'connected',
|
||||
'bg-warning': integration.status === 'expired'
|
||||
}"
|
||||
></span>
|
||||
<div>{{ statusText() }}</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<FormButton size="sm" color="outline" @click="handleCTA()">
|
||||
<span v-if="integration.status === 'notConnected'">Log in</span>
|
||||
<span v-else-if="integration.status === 'expired'">Reconnect</span>
|
||||
<span v-else>Log out</span>
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<FormButton size="sm" @click="$emit('upgrade')">Upgrade</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAccAuthManager } from '~/lib/acc/composables/useAccAuthManager'
|
||||
import { useAccIntegration } from '~/lib/integrations/composables/useAccIntegration'
|
||||
|
||||
const props = defineProps<{ workspaceId: string; workspaceSlug: string }>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'handleCTA'): void
|
||||
(e: 'upgrade'): void
|
||||
}>()
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
const { integration, checkConnection } = useAccIntegration()
|
||||
const { tokens, authAcc, logOut, tryGetTokensFromCookies, fetchTokens } =
|
||||
useAccAuthManager() // later can be generalized
|
||||
|
||||
// await checkConnection(props.workspaceSlug, props.workspaceId || '')
|
||||
|
||||
const statusText = () => {
|
||||
switch (integration.value.status) {
|
||||
case 'connected':
|
||||
return 'Connected'
|
||||
case 'expired':
|
||||
return 'Expired'
|
||||
case 'notConnected':
|
||||
return ''
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleCTA = async () => {
|
||||
if (
|
||||
integration.value.status === 'notConnected' ||
|
||||
integration.value.status === 'expired'
|
||||
) {
|
||||
authAcc(`/settings/workspaces/${props.workspaceSlug}/integrations`)
|
||||
} else {
|
||||
logOut()
|
||||
await checkConnection(props.workspaceSlug, props.workspaceId || '')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await tryGetTokensFromCookies()
|
||||
if (!tokens.value) {
|
||||
await fetchTokens()
|
||||
}
|
||||
await checkConnection(props.workspaceSlug, props.workspaceId || '')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="flex flex-row h-full overflow-hidden border rounded-lg bg-foundation">
|
||||
<!-- Left Pane for tree -->
|
||||
<div class="w-1/4 p-2 overflow-y-auto border-r">
|
||||
<h3 class="font-semibold text-lg text-center">Folders</h3>
|
||||
<hr class="mb-1" />
|
||||
<div v-if="!rootFolder"></div>
|
||||
<ul
|
||||
v-else-if="rootFolder && rootFolder.children?.items.length"
|
||||
class="space-y-1 pt-1"
|
||||
>
|
||||
<IntegrationsAccFolderNode
|
||||
v-for="folder in rootFolderChildren"
|
||||
:key="folder.id"
|
||||
:project-id="projectId"
|
||||
:folder-id="folder.id"
|
||||
:tokens="tokens"
|
||||
:selected-folder-id="selectedFolderId"
|
||||
@select="onFolderClick"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Right Pane for content -->
|
||||
<div class="w-3/4 p-2 overflow-y-auto">
|
||||
<h3 class="font-semibold text-lg text-center">Files</h3>
|
||||
<hr class="mb-1" />
|
||||
<IntegrationsAccFolderContents
|
||||
v-if="!!selectedFolderId"
|
||||
:key="`contents-${selectedFolderId}`"
|
||||
:project-id="projectId"
|
||||
:folder-id="selectedFolderId"
|
||||
:tokens="tokens"
|
||||
:selected-file-id="selectedFileId"
|
||||
@select="onFileSelected"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AccTokens } from '@speckle/shared/acc'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useAcc, type AccItemVersion } from '~/lib/acc/composables/useAccFiles'
|
||||
import { useAccFolder } from '~/lib/acc/composables/useAccFolderData'
|
||||
|
||||
const props = defineProps<{
|
||||
hubId: string
|
||||
projectId: string
|
||||
tokens: AccTokens | undefined
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'file-selected': [fileId: string, fileVersion: AccItemVersion]
|
||||
}>()
|
||||
|
||||
const { init, rootProjectFolderId } = useAcc()
|
||||
|
||||
const rootFolder = useAccFolder(props.projectId, rootProjectFolderId, props.tokens)
|
||||
const rootFolderChildren = computed(
|
||||
() =>
|
||||
rootFolder.value?.children?.items?.filter(
|
||||
(child) => child.name === 'Project Files'
|
||||
) ?? []
|
||||
)
|
||||
|
||||
const selectedFolderId = ref<string | undefined>()
|
||||
const selectedFileId = ref<string | undefined>()
|
||||
|
||||
const onFolderClick = async (folderId: string) => {
|
||||
selectedFolderId.value = folderId
|
||||
selectedFileId.value = undefined
|
||||
}
|
||||
|
||||
const onFileSelected = (fileId: string, fileVersion: AccItemVersion) => {
|
||||
selectedFileId.value = fileId
|
||||
emit('file-selected', fileId, fileVersion)
|
||||
}
|
||||
|
||||
// Watch for changes in projectId to re-initialize the folder tree
|
||||
watch(
|
||||
() => props.projectId,
|
||||
async (newProjectId) => {
|
||||
selectedFolderId.value = undefined
|
||||
selectedFileId.value = undefined
|
||||
if (newProjectId && props.tokens) {
|
||||
await init(props.hubId, newProjectId, props.tokens.access_token)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(rootFolderChildren, (newValue) => {
|
||||
selectedFolderId.value = newValue.at(0)?.id
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div>
|
||||
<ul v-if="items?.length" class="space-y-1">
|
||||
<template v-for="item in items" :key="item.id">
|
||||
<li
|
||||
class="flex items-center space-x-1 px-2 rounded-md transition-colors w-full"
|
||||
:class="{
|
||||
'bg-foundation-focus font-semibold': selectedFileId === item.id,
|
||||
'hover:bg-primary-muted cursor-pointer': selectedFileId !== item.id
|
||||
}"
|
||||
>
|
||||
<button
|
||||
class="flex items-center space-x-1 p-1 rounded-md transition-colors w-full"
|
||||
@click="
|
||||
emit('select', item.id, removeNullOrUndefinedKeys(item.latestVersion))
|
||||
"
|
||||
>
|
||||
<span>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<hr />
|
||||
</template>
|
||||
</ul>
|
||||
<div v-else class="text-center text-foreground-2 py-2">
|
||||
<span>No files found.</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { removeNullOrUndefinedKeys } from '@speckle/shared'
|
||||
import type { AccTokens } from '@speckle/shared/acc'
|
||||
import type { AccItemVersion } from '~/lib/acc/composables/useAccFiles'
|
||||
import { useAccFolder } from '~/lib/acc/composables/useAccFolderData'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
folderId: string
|
||||
tokens: AccTokens | undefined
|
||||
selectedFileId: string | undefined
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [fileId: string, fileVersion: AccItemVersion]
|
||||
}>()
|
||||
|
||||
const folder = useAccFolder(props.projectId, props.folderId, props.tokens)
|
||||
|
||||
const items = computed(() => {
|
||||
return folder.value.contents?.items.filter(
|
||||
(item) => item.latestVersion.fileType?.toLowerCase() === 'rvt'
|
||||
)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<li>
|
||||
<button
|
||||
class="flex items-center space-x-1 p-1 rounded-md transition-colors w-full"
|
||||
:class="{
|
||||
'bg-foundation-focus font-semibold': selectedFolderId === folder.id,
|
||||
'hover:bg-primary-muted cursor-pointer': selectedFolderId !== folder.id
|
||||
}"
|
||||
@click="emit('select', folderId)"
|
||||
>
|
||||
<ChevronDownIcon
|
||||
:class="`h-4 w-5 transition ${!isExpanded ? '-rotate-90' : 'rotate-0'}`"
|
||||
@click.stop="isExpanded = !isExpanded"
|
||||
/>
|
||||
<span>{{ folder.name }}</span>
|
||||
</button>
|
||||
<ul
|
||||
v-if="isExpanded && folder.children && folder.children.items.length > 0"
|
||||
class="ml-4 mt-1 space-y-1"
|
||||
>
|
||||
<IntegrationsAccFolderNode
|
||||
v-for="child in folder.children.items"
|
||||
:key="child.id"
|
||||
:folder-id="child.id"
|
||||
:project-id="projectId"
|
||||
:tokens="tokens"
|
||||
:selected-folder-id="selectedFolderId"
|
||||
@select="(id) => emit('select', id)"
|
||||
/>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { useAccFolder } from '~/lib/acc/composables/useAccFolderData'
|
||||
import type { AccTokens } from '@speckle/shared/acc'
|
||||
|
||||
graphql(`
|
||||
fragment AccIntegrationFolderNode_AccFolder on AccFolder {
|
||||
id
|
||||
name
|
||||
contents {
|
||||
items {
|
||||
id
|
||||
name
|
||||
latestVersion {
|
||||
id
|
||||
name
|
||||
versionNumber
|
||||
fileType
|
||||
}
|
||||
}
|
||||
}
|
||||
children {
|
||||
items {
|
||||
id
|
||||
name
|
||||
children {
|
||||
items {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
contents {
|
||||
items {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
// TODO ACC Maybe inject from shared local state within file navigation
|
||||
projectId: string
|
||||
folderId: string
|
||||
tokens?: AccTokens
|
||||
// TODO ACC Maybe inject from shared local state within file navigation
|
||||
selectedFolderId: string | undefined
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [folderId: string]
|
||||
}>()
|
||||
|
||||
const folder = useAccFolder(props.projectId, props.folderId, props.tokens)
|
||||
|
||||
// watch(
|
||||
// folder,
|
||||
// (f) => {
|
||||
// console.log({ resultFolder: f })
|
||||
// },
|
||||
// {
|
||||
// immediate: true
|
||||
// }
|
||||
// )
|
||||
|
||||
const isExpanded = ref(false)
|
||||
</script>
|
||||
+2
-2
@@ -11,12 +11,12 @@
|
||||
/>
|
||||
|
||||
<CommonLoadingBar v-if="loading" loading />
|
||||
<ProjectPageAccModelItem
|
||||
<IntegrationsAccModelItem
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
:model="model"
|
||||
:selected="model.id === selectedModel?.id"
|
||||
:disabled="!!props.accSyncItems?.find((i) => i.modelId === model.id)"
|
||||
:disabled="!!props.accSyncItems?.find((i) => i.model?.id === model.id)"
|
||||
@click="onModelItemClicked(model)"
|
||||
/>
|
||||
<button
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div v-tippy="statusLabel">
|
||||
<CommonBadge
|
||||
:color-classes="
|
||||
[runStatusClasses(status), 'shrink-0 grow-0 text-foreground'].join(' ')
|
||||
"
|
||||
>
|
||||
ACC
|
||||
</CommonBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type {
|
||||
AccSyncItemStatus,
|
||||
SyncStatusModelItem_AccSyncItemFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
graphql(`
|
||||
fragment SyncStatusModelItem_AccSyncItem on AccSyncItem {
|
||||
id
|
||||
status
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
item: SyncStatusModelItem_AccSyncItemFragment
|
||||
}>()
|
||||
|
||||
const status = computed(() => props.item.status)
|
||||
const statusLabel = computed(
|
||||
() => status.value.charAt(0).toUpperCase() + status.value.slice(1)
|
||||
)
|
||||
|
||||
const runStatusClasses = (run: AccSyncItemStatus) => {
|
||||
const classParts = ['w-24 justify-center']
|
||||
|
||||
switch (run) {
|
||||
case 'syncing':
|
||||
classParts.push('bg-info-lighter')
|
||||
break
|
||||
case 'pending':
|
||||
classParts.push('bg-warning-lighter')
|
||||
break
|
||||
case 'paused':
|
||||
classParts.push('bg-warning-lighter')
|
||||
break
|
||||
case 'failed':
|
||||
classParts.push('bg-danger-lighter')
|
||||
break
|
||||
case 'succeeded':
|
||||
classParts.push('bg-success-lighter')
|
||||
break
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="flex text-body-xs text-foreground font-medium">Sync models</div>
|
||||
<LayoutTable
|
||||
class="bg-foundation"
|
||||
:columns="[
|
||||
{ id: 'status', header: 'Status', classes: 'col-span-2' },
|
||||
{ id: 'accFileName', header: 'File name', classes: 'col-span-2' },
|
||||
{ id: 'accFileViewName', header: 'View name', classes: 'col-span-2' },
|
||||
{ id: 'modelId', header: 'Model id', classes: 'col-span-2' },
|
||||
{ id: 'createdBy', header: 'Created by', classes: 'col-span-2' },
|
||||
{ id: 'actions', header: 'Actions', classes: 'col-span-2' }
|
||||
]"
|
||||
:items="accSyncItems"
|
||||
>
|
||||
<template #status="{ item }">
|
||||
<IntegrationsAccSyncStatus :status="item.status" />
|
||||
</template>
|
||||
<template #accFileName="{ item }">
|
||||
{{ item.accFileName }}
|
||||
</template>
|
||||
<template #accFileViewName="{ item }">
|
||||
{{ item.accFileViewName || '-' }}
|
||||
</template>
|
||||
<template #modelId="{ item }">
|
||||
<NuxtLink
|
||||
class="text-foreground-1 hover:text-blue-500 underline"
|
||||
:to="`/projects/${projectId}/models/${item.model?.id}`"
|
||||
>
|
||||
{{ item.model?.id }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template #createdBy="{ item }">
|
||||
{{ item.author?.name }}
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<div class="space-x-2">
|
||||
<FormButton
|
||||
hide-text
|
||||
color="outline"
|
||||
:icon-left="item.status === 'paused' ? PlayIcon : PauseIcon"
|
||||
@click="handleStatusSyncItem(item.id, item.status === 'paused')"
|
||||
/>
|
||||
<FormButton
|
||||
hide-text
|
||||
color="outline"
|
||||
:icon-left="TrashIcon"
|
||||
@click="handleDeleteSyncItem(item.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</LayoutTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AccTokens } from '@speckle/shared/acc'
|
||||
import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable'
|
||||
import {
|
||||
accSyncItemDeleteMutation,
|
||||
accSyncItemUpdateMutation
|
||||
} from '~/lib/acc/graphql/mutations'
|
||||
import { projectAccSyncItemsQuery } from '~/lib/acc/graphql/queries'
|
||||
import { onProjectAccSyncItemUpdatedSubscription } from '~/lib/acc/graphql/subscriptions'
|
||||
import { PauseIcon } from '@heroicons/vue/24/solid'
|
||||
import { TrashIcon, PlayIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
tokens: AccTokens | undefined
|
||||
isLoggedIn: boolean
|
||||
}>()
|
||||
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
|
||||
const { result: accSyncItemsResult, refetch: refetchAccSyncItems } = useQuery(
|
||||
projectAccSyncItemsQuery,
|
||||
() => ({
|
||||
id: props.projectId
|
||||
})
|
||||
)
|
||||
|
||||
const accSyncItems = computed(
|
||||
() => accSyncItemsResult.value?.project.accSyncItems.items || []
|
||||
)
|
||||
|
||||
const { onResult: onProjectAccSyncItemsUpdated } = useSubscription(
|
||||
onProjectAccSyncItemUpdatedSubscription,
|
||||
() => ({
|
||||
id: props.projectId
|
||||
})
|
||||
)
|
||||
|
||||
onProjectAccSyncItemsUpdated((res) => {
|
||||
// TODO ACC: Mutate local cache instead of refetch
|
||||
refetchAccSyncItems()
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Info,
|
||||
title: `ACC sync model ${res.data?.projectAccSyncItemsUpdated.type.toLowerCase()}`,
|
||||
description: res.data?.projectAccSyncItemsUpdated.accSyncItem?.accFileName
|
||||
})
|
||||
})
|
||||
|
||||
const { mutate: deleteAccSyncItem } = useMutation(accSyncItemDeleteMutation)
|
||||
|
||||
const handleDeleteSyncItem = async (id: string) => {
|
||||
try {
|
||||
await deleteAccSyncItem({
|
||||
input: {
|
||||
projectId: props.projectId,
|
||||
id
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Delete sync item failed',
|
||||
description: error instanceof Error ? error.message : 'Unexpected error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { mutate: updateAccSyncItem } = useMutation(accSyncItemUpdateMutation)
|
||||
|
||||
const handleStatusSyncItem = async (id: string, isPaused: boolean) => {
|
||||
try {
|
||||
await updateAccSyncItem({
|
||||
input: {
|
||||
projectId: props.projectId,
|
||||
id,
|
||||
status: isPaused ? 'pending' : 'paused'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Update sync item failed',
|
||||
description: error instanceof Error ? error.message : 'Unexpected error'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<LayoutDialog
|
||||
v-model:open="isOpen"
|
||||
:title="dialogTitle"
|
||||
:buttons="dialogButtons"
|
||||
max-width="lg"
|
||||
fullscreen="none"
|
||||
>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<IntegrationsAccHubs
|
||||
:hubs="hubs"
|
||||
:loading="loadingHubs"
|
||||
@hub-selected="onHubClick"
|
||||
/>
|
||||
|
||||
<IntegrationsAccProjects
|
||||
v-if="selectedHubId"
|
||||
:hub-id="selectedHubId"
|
||||
:projects="projects"
|
||||
:loading="loadingProjects"
|
||||
@project-selected="onProjectClick"
|
||||
/>
|
||||
|
||||
<IntegrationsAccFileSelector
|
||||
v-if="selectedProjectId && selectedHubId && tokens"
|
||||
:key="selectedProjectId"
|
||||
:hub-id="selectedHubId"
|
||||
:project-id="selectedProjectId"
|
||||
:tokens="tokens"
|
||||
@file-selected="onFileSelected"
|
||||
/>
|
||||
|
||||
<FormTextInput
|
||||
v-model="revitViewName"
|
||||
name="revitFileViewName"
|
||||
color="foundation"
|
||||
label="Revit view name (Optional)"
|
||||
show-label
|
||||
:disabled="!selectedFileVersion"
|
||||
></FormTextInput>
|
||||
</div>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AccHub } from '@speckle/shared/acc'
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { useAccAuthManager } from '~/lib/acc/composables/useAccAuthManager'
|
||||
import {
|
||||
useAcc,
|
||||
type AccFolder,
|
||||
type AccItemVersion
|
||||
} from '~/lib/acc/composables/useAccFiles'
|
||||
import { accSyncItemCreateMutation } from '~/lib/acc/graphql/mutations'
|
||||
import { useCreateNewModel } from '~/lib/projects/composables/modelManagement'
|
||||
|
||||
type FormValues = { feedback: string }
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string
|
||||
intro?: string
|
||||
hideSuppport?: boolean
|
||||
projectId?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const { handleSubmit } = useForm<FormValues>()
|
||||
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Create',
|
||||
props: { color: 'primary' },
|
||||
onClick: () => {
|
||||
onSubmit()
|
||||
},
|
||||
id: 'createAccSync'
|
||||
}
|
||||
])
|
||||
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const createModel = useCreateNewModel()
|
||||
|
||||
const dialogTitle = computed(() => props.title || 'Create sync from ACC')
|
||||
|
||||
const onSubmit = handleSubmit(async () => {
|
||||
await addSync()
|
||||
isOpen.value = false
|
||||
})
|
||||
|
||||
const selectedHub = ref<AccHub | null>(null)
|
||||
const selectedHubId = ref<string | null>(null)
|
||||
const selectedProjectId = ref<string | null>(null)
|
||||
|
||||
const revitViewName = ref<string>()
|
||||
|
||||
const selectedFolder = ref<AccFolder | undefined>()
|
||||
const selectedFileId = ref<string | undefined>()
|
||||
const selectedFileVersion = ref<AccItemVersion | undefined>()
|
||||
|
||||
const {
|
||||
hubs,
|
||||
fetchHubs,
|
||||
loadingHubs,
|
||||
projects,
|
||||
fetchProjects,
|
||||
loadingProjects,
|
||||
rootProjectFolderId,
|
||||
init
|
||||
} = useAcc()
|
||||
|
||||
const { tokens, tryGetTokensFromCookies } = useAccAuthManager()
|
||||
|
||||
const onHubClick = async (hub: AccHub) => {
|
||||
selectedHub.value = hub
|
||||
selectedHubId.value = hub.id
|
||||
await fetchProjects(hub.id, tokens.value!.access_token)
|
||||
}
|
||||
|
||||
const onProjectClick = async (hubId: string, projectId: string) => {
|
||||
selectedFolder.value = undefined
|
||||
selectedFileVersion.value = undefined
|
||||
selectedProjectId.value = projectId
|
||||
await init(hubId, projectId, tokens.value!.access_token)
|
||||
}
|
||||
|
||||
const onFileSelected = (fileId: string, fileVersion: AccItemVersion) => {
|
||||
selectedFileId.value = fileId
|
||||
selectedFileVersion.value = fileVersion
|
||||
}
|
||||
|
||||
const { mutate: createAccSyncItem } = useMutation(accSyncItemCreateMutation)
|
||||
|
||||
const addSync = async () => {
|
||||
try {
|
||||
if (!selectedFileVersion.value || !selectedFileVersion.value.fileType) {
|
||||
return
|
||||
}
|
||||
|
||||
const fileVersion = selectedFileVersion.value.versionNumber
|
||||
|
||||
const accFileViewName = revitViewName.value === '' ? undefined : revitViewName.value
|
||||
|
||||
const res = await createModel({
|
||||
name: selectedFileVersion.value.name,
|
||||
description: '',
|
||||
projectId: props.projectId as string
|
||||
})
|
||||
|
||||
await createAccSyncItem({
|
||||
input: {
|
||||
projectId: props.projectId as string,
|
||||
modelId: res?.id as string,
|
||||
accRegion: selectedHub.value?.attributes?.region as string,
|
||||
accFileExtension: selectedFileVersion.value.fileType,
|
||||
accHubId: selectedHubId.value!,
|
||||
accProjectId: selectedProjectId.value as string,
|
||||
accRootProjectFolderUrn: rootProjectFolderId.value!,
|
||||
accFileLineageUrn: selectedFileId.value as string,
|
||||
accFileName: selectedFileVersion.value.name,
|
||||
accFileVersionIndex: fileVersion,
|
||||
accFileVersionUrn: selectedFileVersion.value.id,
|
||||
accFileViewName
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Add sync item failed',
|
||||
description: error instanceof Error ? error.message : 'Unexpected error'
|
||||
})
|
||||
} finally {
|
||||
revitViewName.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
watch(tokens, (newTokens) => {
|
||||
if (newTokens?.access_token) {
|
||||
fetchHubs(newTokens?.access_token)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await tryGetTokensFromCookies()
|
||||
if (tokens.value) {
|
||||
fetchHubs(tokens.value.access_token)
|
||||
}
|
||||
})
|
||||
|
||||
watch(isOpen, (newVal) => {
|
||||
if (newVal) {
|
||||
selectedFileVersion.value = undefined
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -29,6 +29,7 @@ import { LucideChevronLeft, LucideChevronRight, LucideRotateCcw } from 'lucide-v
|
||||
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
|
||||
import { clamp } from 'lodash-es'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useResetViewUtils } from '~/lib/presentations/composables/utils'
|
||||
|
||||
defineProps<{
|
||||
hideUi?: boolean
|
||||
@@ -36,8 +37,9 @@ defineProps<{
|
||||
|
||||
const {
|
||||
ui: { slideIdx: currentVisibleIndex, slideCount },
|
||||
viewer: { resetView, hasViewChanged }
|
||||
viewer: { hasViewChanged }
|
||||
} = useInjectedPresentationState()
|
||||
const { resetView } = useResetViewUtils()
|
||||
|
||||
const disablePrevious = computed(() => currentVisibleIndex.value === 0)
|
||||
const disableNext = computed(() =>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { graphql } from '~~/lib/common/generated/gql'
|
||||
import type { PresentationSlideListSlide_SavedViewFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
|
||||
import { useAuthManager } from '~~/lib/auth/composables/auth'
|
||||
import { useResetViewUtils } from '~/lib/presentations/composables/utils'
|
||||
|
||||
graphql(`
|
||||
fragment PresentationSlideListSlide_SavedView on SavedView {
|
||||
@@ -42,10 +43,10 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const {
|
||||
ui: { slideIdx: currentSlideIdx, slide: currentSlide },
|
||||
viewer: { resetView }
|
||||
ui: { slideIdx: currentSlideIdx, slide: currentSlide }
|
||||
} = useInjectedPresentationState()
|
||||
const { presentationToken } = useAuthManager()
|
||||
const { resetView } = useResetViewUtils()
|
||||
|
||||
const isCurrentSlide = computed(() => currentSlide.value?.id === props.slide.id)
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const slideListRef = ref<HTMLUListElement>()
|
||||
|
||||
const containerRef = computed(() => slideListRef.value?.parentElement)
|
||||
|
||||
const scrollToActiveSlide = () => {
|
||||
const scrollToActiveSlide = (scrollBehavior: ScrollBehavior) => {
|
||||
if (!slideListRef.value || !containerRef.value) return
|
||||
|
||||
const activeSlideElement = slideListRef.value.children[slideIdx.value] as HTMLElement
|
||||
@@ -54,7 +54,7 @@ const scrollToActiveSlide = () => {
|
||||
|
||||
containerRef.value.scrollTo({
|
||||
top: Math.max(0, scrollTop),
|
||||
behavior: 'smooth'
|
||||
behavior: scrollBehavior
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,8 +63,12 @@ const throttledScrollToActiveSlide = useThrottleFn(scrollToActiveSlide, 100)
|
||||
watch(
|
||||
slideIdx,
|
||||
() => {
|
||||
throttledScrollToActiveSlide()
|
||||
throttledScrollToActiveSlide('smooth')
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
scrollToActiveSlide('instant')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="presentation-viewer-post-setup h-full"><slot /></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { usePresentationViewerPostSetup } from '~/lib/presentations/composables/viewerPostSetup'
|
||||
|
||||
/**
|
||||
* The only point of this component is to get around the stupid limitation where a component that injects() also can't provide() the same stuff back...
|
||||
*/
|
||||
|
||||
usePresentationViewerPostSetup()
|
||||
</script>
|
||||
@@ -1,9 +1,11 @@
|
||||
<template>
|
||||
<ViewerStateSetup :init-params="initParams">
|
||||
<PresentationViewerSetup
|
||||
@loading-change="onLoadingChange"
|
||||
@progress-change="onProgressChange"
|
||||
/>
|
||||
<PresentationViewerPostSetup>
|
||||
<PresentationViewerSetup
|
||||
@loading-change="onLoadingChange"
|
||||
@progress-change="onProgressChange"
|
||||
/>
|
||||
</PresentationViewerPostSetup>
|
||||
</ViewerStateSetup>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-row h-full overflow-hidden border rounded-lg bg-foundation">
|
||||
<!-- Left Pane for tree -->
|
||||
<div class="w-1/4 p-2 overflow-y-auto border-r">
|
||||
<h3 class="font-semibold text-lg text-center">Folders</h3>
|
||||
<hr class="mb-1" />
|
||||
<div v-if="loadingTree" class="text-center text-foreground-2 py-2">
|
||||
Loading folders...
|
||||
<InfiniteLoading />
|
||||
</div>
|
||||
<ul v-else-if="folderTree && folderTree.children" class="space-y-1 pt-1">
|
||||
<ProjectPageAccFolderNode
|
||||
v-for="folder in folderTree.children"
|
||||
:key="folder.id"
|
||||
:folder="folder"
|
||||
:on-select-folder="onFolderClick"
|
||||
:selected-folder-id="selectedFolder?.id"
|
||||
/>
|
||||
</ul>
|
||||
<div v-else class="text-center text-foreground-2 py-4">No folders found.</div>
|
||||
</div>
|
||||
<!-- Right Pane for content -->
|
||||
<div class="w-3/4 p-2 overflow-y-auto">
|
||||
<h3 class="font-semibold text-lg text-center">Files</h3>
|
||||
<hr class="mb-1" />
|
||||
<div
|
||||
v-if="loadingItems || loadingTree"
|
||||
class="text-center text-foreground-2 py-2"
|
||||
>
|
||||
<div v-if="selectedFolder">
|
||||
<span>Loading files in</span>
|
||||
<span class="font-bold">{{ ` ${selectedFolder.attributes.name} ` }}</span>
|
||||
<span>folder.</span>
|
||||
<InfiniteLoading />
|
||||
</div>
|
||||
<div v-else>Waiting for folder selection...</div>
|
||||
</div>
|
||||
<ul v-else-if="folderItems.length > 0" class="space-y-1">
|
||||
<template v-for="item in folderItems" :key="item.id">
|
||||
<li
|
||||
class="flex items-center space-x-1 px-2 rounded-md transition-colors w-full"
|
||||
:class="{
|
||||
'bg-foundation-focus font-semibold': selectedFile?.id === item.id,
|
||||
'hover:bg-primary-muted cursor-pointer': selectedFile?.id !== item.id
|
||||
}"
|
||||
>
|
||||
<button
|
||||
class="flex items-center space-x-1 p-1 rounded-md transition-colors w-full"
|
||||
@click="onFileSelected(item)"
|
||||
>
|
||||
<span>
|
||||
{{ item.attributes.name || item.attributes.displayName }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<hr />
|
||||
</template>
|
||||
</ul>
|
||||
<div v-else class="text-center text-foreground-2 py-2">
|
||||
<div v-if="selectedFolder">
|
||||
<span>No files found in</span>
|
||||
<span class="font-bold">{{ ` ${selectedFolder.attributes.name} ` }}</span>
|
||||
<span>folder.</span>
|
||||
</div>
|
||||
<span v-else>Select a folder to view files..</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AccTokens } from '@speckle/shared/acc'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useAcc } from '~/lib/acc/composables/useAcc'
|
||||
import type { AccFolder, AccItem } from '~/lib/acc/composables/useAcc'
|
||||
|
||||
const props = defineProps<{
|
||||
hubId: string
|
||||
projectId: string
|
||||
tokens: AccTokens | undefined
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['file-selected'])
|
||||
|
||||
const {
|
||||
loadingTree,
|
||||
loadingItems,
|
||||
folderTree,
|
||||
folderItems,
|
||||
fetchItemsForFolder,
|
||||
init
|
||||
} = useAcc()
|
||||
|
||||
const selectedFolder = ref<AccFolder | undefined>()
|
||||
const selectedFile = ref<AccItem | undefined>()
|
||||
|
||||
const onFolderClick = async (folder: AccFolder) => {
|
||||
selectedFolder.value = folder
|
||||
selectedFile.value = undefined
|
||||
await fetchItemsForFolder(folder.id, props.projectId, props.tokens!.access_token)
|
||||
}
|
||||
|
||||
const onFileSelected = (item: AccItem) => {
|
||||
selectedFile.value = item
|
||||
emit('file-selected', item)
|
||||
}
|
||||
|
||||
// Watch for changes in projectId to re-initialize the folder tree
|
||||
watch(
|
||||
() => props.projectId,
|
||||
async (newProjectId) => {
|
||||
selectedFolder.value = undefined
|
||||
selectedFile.value = undefined
|
||||
if (newProjectId && props.tokens) {
|
||||
await init(props.hubId, newProjectId, props.tokens.access_token)
|
||||
// Automatically select the first folder and fetch its files
|
||||
if (
|
||||
folderTree.value &&
|
||||
folderTree.value.children &&
|
||||
folderTree.value.children.length > 0
|
||||
) {
|
||||
await onFolderClick(folderTree.value.children[0])
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -1,49 +0,0 @@
|
||||
<template>
|
||||
<li>
|
||||
<button
|
||||
class="flex items-center space-x-1 p-1 rounded-md transition-colors w-full"
|
||||
:class="{
|
||||
'bg-foundation-focus font-semibold': selectedFolderId === folder.id,
|
||||
'hover:bg-primary-muted cursor-pointer': selectedFolderId !== folder.id
|
||||
}"
|
||||
@click="select(folder)"
|
||||
>
|
||||
<ChevronDownIcon
|
||||
:class="`h-4 w-5 transition ${!isExpanded ? '-rotate-90' : 'rotate-0'}`"
|
||||
@click.stop="isExpanded = !isExpanded"
|
||||
/>
|
||||
<span>{{ folder.attributes.name }}</span>
|
||||
</button>
|
||||
|
||||
<ul
|
||||
v-if="isExpanded && folder.children && folder.children.length > 0"
|
||||
class="ml-4 mt-1 space-y-1"
|
||||
>
|
||||
<ProjectPageAccFolderNode
|
||||
v-for="child in folder.children"
|
||||
:key="child.id"
|
||||
:folder="child"
|
||||
:on-select-folder="onSelectFolder"
|
||||
:selected-folder-id="selectedFolderId"
|
||||
/>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
|
||||
import type { AccFolder } from '~/lib/acc/composables/useAcc'
|
||||
|
||||
const props = defineProps<{
|
||||
folder: AccFolder
|
||||
onSelectFolder: (folder: AccFolder) => void
|
||||
selectedFolderId: string | undefined
|
||||
}>()
|
||||
|
||||
const isExpanded = ref(false)
|
||||
|
||||
const select = (folder: AccFolder) => {
|
||||
props.onSelectFolder(folder)
|
||||
}
|
||||
</script>
|
||||
@@ -1,339 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="flex text-body-xs text-foreground font-medium">Sync models</div>
|
||||
<LayoutTable
|
||||
class="bg-foundation"
|
||||
:columns="[
|
||||
{ id: 'status', header: 'Status', classes: 'col-span-2' },
|
||||
{ id: 'accFileName', header: 'File name', classes: 'col-span-2' },
|
||||
{ id: 'accFileViewName', header: 'View name', classes: 'col-span-2' },
|
||||
{ id: 'modelId', header: 'Model id', classes: 'col-span-2' },
|
||||
{ id: 'createdBy', header: 'Created by', classes: 'col-span-2' },
|
||||
{ id: 'actions', header: 'Actions', classes: 'col-span-2' }
|
||||
]"
|
||||
:items="accSyncItems"
|
||||
>
|
||||
<template #status="{ item }">
|
||||
<ProjectPageAccSyncStatus :status="item.status" />
|
||||
</template>
|
||||
<template #accFileName="{ item }">
|
||||
{{ item.accFileName }}
|
||||
</template>
|
||||
<template #accFileViewName="{ item }">
|
||||
{{ item.accFileViewName || '-' }}
|
||||
</template>
|
||||
<template #modelId="{ item }">
|
||||
<NuxtLink
|
||||
class="text-foreground-1 hover:text-blue-500 underline"
|
||||
:to="`/projects/${projectId}/models/${item.modelId}`"
|
||||
>
|
||||
{{ item.modelId }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template #createdBy="{ item }">
|
||||
{{ item.author?.name }}
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<div class="space-x-2">
|
||||
<FormButton
|
||||
hide-text
|
||||
color="outline"
|
||||
:icon-left="item.status === 'paused' ? PlayIcon : PauseIcon"
|
||||
@click="handleStatusSyncItem(item.id, item.status === 'paused')"
|
||||
/>
|
||||
<FormButton
|
||||
hide-text
|
||||
color="outline"
|
||||
:icon-left="TrashIcon"
|
||||
@click="handleDeleteSyncItem(item.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</LayoutTable>
|
||||
<FormButton
|
||||
color="outline"
|
||||
size="sm"
|
||||
:disabled="!isLoggedIn"
|
||||
:disabled-tooltip="'Log in required'"
|
||||
@click="showNewSyncDialog = true"
|
||||
>
|
||||
<template #default>
|
||||
<div v-tippy="isLoggedIn ? undefined : 'Log in required'">New sync</div>
|
||||
</template>
|
||||
</FormButton>
|
||||
<LayoutDialog
|
||||
v-model:open="showNewSyncDialog"
|
||||
title="Create new sync"
|
||||
@fully-closed="step = 0"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div v-if="step === 0" class="space-y-2">
|
||||
<ProjectPageAccHubs
|
||||
:hubs="hubs"
|
||||
:loading="loadingHubs"
|
||||
@hub-selected="onHubClick"
|
||||
/>
|
||||
|
||||
<ProjectPageAccProjects
|
||||
v-if="selectedHubId"
|
||||
:hub-id="selectedHubId"
|
||||
:projects="projects"
|
||||
:loading="loadingProjects"
|
||||
@project-selected="onProjectClick"
|
||||
/>
|
||||
|
||||
<ProjectPageAccFileSelector
|
||||
v-if="selectedProjectId && selectedHubId && tokens"
|
||||
:hub-id="selectedHubId"
|
||||
:project-id="selectedProjectId"
|
||||
:tokens="tokens"
|
||||
@file-selected="onFileSelected"
|
||||
/>
|
||||
|
||||
<FormTextInput
|
||||
v-model="revitViewName"
|
||||
name="revitFileViewName"
|
||||
color="foundation"
|
||||
label="Revit view name (Optional)"
|
||||
show-label
|
||||
:disabled="!selectedFile"
|
||||
></FormTextInput>
|
||||
|
||||
<div class="flex flex-row justify-center mt-4 space-x-2">
|
||||
<FormButton size="sm" color="outline" @click="showNewSyncDialog = false">
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton size="sm" :disabled="!selectedFile" @click="step++">
|
||||
Next
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="step === 1" class="flex flex-col space-y-2">
|
||||
<CommonAlert color="info" hide-icon>
|
||||
<template #title>
|
||||
Selected ACC file:
|
||||
{{
|
||||
selectedFile?.attributes.name || selectedFile?.attributes.displayName
|
||||
}}
|
||||
</template>
|
||||
</CommonAlert>
|
||||
<hr />
|
||||
<ProjectPageAccModelSelector
|
||||
:project-id="projectId"
|
||||
:acc-sync-items="accSyncItems"
|
||||
@model-selected="(model) => (selectedModel = model)"
|
||||
/>
|
||||
<hr />
|
||||
<div class="flex flex-row justify-center space-x-2">
|
||||
<FormButton size="sm" color="outline" @click="showNewSyncDialog = false">
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton size="sm" :disabled="!selectedModel" @click="addSync">
|
||||
Add
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AccTokens, AccHub, AccItem } from '@speckle/shared/acc'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { ProjectPageLatestItemsModelItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable'
|
||||
import {
|
||||
accSyncItemCreateMutation,
|
||||
accSyncItemDeleteMutation,
|
||||
accSyncItemUpdateMutation
|
||||
} from '~/lib/acc/graphql/mutations'
|
||||
import { projectAccSyncItemsQuery } from '~/lib/acc/graphql/queries'
|
||||
import { onProjectAccSyncItemUpdatedSubscription } from '~/lib/acc/graphql/subscriptions'
|
||||
import { PauseIcon } from '@heroicons/vue/24/solid'
|
||||
import { TrashIcon, PlayIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
import type { AccFolder } from '~/lib/acc/composables/useAcc'
|
||||
import { useAcc } from '~/lib/acc/composables/useAcc'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
tokens: AccTokens | undefined
|
||||
isLoggedIn: boolean
|
||||
}>()
|
||||
|
||||
const step = ref(0)
|
||||
const showNewSyncDialog = ref(false)
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
|
||||
const tokens = computed(() => props.tokens)
|
||||
|
||||
const selectedHub = ref<AccHub | null>(null)
|
||||
const selectedHubId = ref<string | null>(null)
|
||||
const selectedProjectId = ref<string | null>(null)
|
||||
|
||||
const revitViewName = ref<string>()
|
||||
const selectedModel = ref<ProjectPageLatestItemsModelItemFragment>()
|
||||
|
||||
const selectedFolder = ref<AccFolder | undefined>()
|
||||
const selectedFile = ref<AccItem | undefined>()
|
||||
|
||||
// Use the composable to get the state and functions
|
||||
const {
|
||||
hubs,
|
||||
fetchHubs,
|
||||
loadingHubs,
|
||||
projects,
|
||||
fetchProjects,
|
||||
loadingProjects,
|
||||
folderTree,
|
||||
fetchItemsForFolder,
|
||||
rootProjectFolderId,
|
||||
init
|
||||
} = useAcc()
|
||||
|
||||
const onFileSelected = (item: AccItem) => {
|
||||
selectedFile.value = item
|
||||
}
|
||||
|
||||
const { result: accSyncItemsResult, refetch: refetchAccSyncItems } = useQuery(
|
||||
projectAccSyncItemsQuery,
|
||||
() => ({
|
||||
id: props.projectId
|
||||
})
|
||||
)
|
||||
|
||||
const accSyncItems = computed(
|
||||
() => accSyncItemsResult.value?.project.accSyncItems.items || []
|
||||
)
|
||||
|
||||
const { onResult: onProjectAccSyncItemsUpdated } = useSubscription(
|
||||
onProjectAccSyncItemUpdatedSubscription,
|
||||
() => ({
|
||||
id: props.projectId
|
||||
})
|
||||
)
|
||||
|
||||
onProjectAccSyncItemsUpdated((res) => {
|
||||
// TODO ACC: Mutate local cache instead of refetch
|
||||
|
||||
refetchAccSyncItems()
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Info,
|
||||
title: `ACC sync model ${res.data?.projectAccSyncItemsUpdated.type.toLowerCase()}`,
|
||||
description: res.data?.projectAccSyncItemsUpdated.accSyncItem?.accFileName
|
||||
})
|
||||
})
|
||||
|
||||
const onHubClick = async (hub: AccHub) => {
|
||||
selectedHub.value = hub
|
||||
selectedHubId.value = hub.id
|
||||
await fetchProjects(hub.id, tokens.value!.access_token)
|
||||
}
|
||||
|
||||
// Refactored onProjectClick to use the composable's init function
|
||||
const onProjectClick = async (hubId: string, projectId: string) => {
|
||||
selectedFolder.value = undefined
|
||||
selectedFile.value = undefined
|
||||
selectedProjectId.value = projectId
|
||||
await init(hubId, projectId, tokens.value!.access_token)
|
||||
|
||||
// defaulting to first
|
||||
if (folderTree.value && folderTree.value.children) {
|
||||
selectedFolder.value = folderTree.value?.children[0]
|
||||
await fetchItemsForFolder(
|
||||
selectedFolder.value.id,
|
||||
selectedProjectId.value,
|
||||
tokens.value!.access_token
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { mutate: createAccSyncItem } = useMutation(accSyncItemCreateMutation)
|
||||
|
||||
const addSync = async () => {
|
||||
try {
|
||||
// annoying but looks like ACC does not give the exact version number directly
|
||||
const fileVersion = Number(
|
||||
new URLSearchParams(selectedFile.value?.latestVersionId?.split('?')[1]).get(
|
||||
'version'
|
||||
)
|
||||
)
|
||||
|
||||
const accFileViewName = revitViewName.value === '' ? undefined : revitViewName.value
|
||||
|
||||
await createAccSyncItem({
|
||||
input: {
|
||||
projectId: props.projectId,
|
||||
modelId: selectedModel.value?.id as string,
|
||||
accRegion: selectedHub.value?.attributes?.region as string,
|
||||
accFileExtension: selectedFile.value?.fileExtension as string,
|
||||
accHubId: selectedHubId.value!,
|
||||
accProjectId: selectedProjectId.value as string,
|
||||
accRootProjectFolderUrn: rootProjectFolderId.value!,
|
||||
accFileLineageUrn: selectedFile.value?.id as string,
|
||||
accFileName: (selectedFile.value?.attributes.displayName ||
|
||||
selectedFile.value?.attributes.name) as string,
|
||||
accFileVersionIndex: fileVersion,
|
||||
accFileVersionUrn: selectedFile.value?.latestVersionId as string,
|
||||
accFileViewName
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Add sync item failed',
|
||||
description: error instanceof Error ? error.message : 'Unexpected error'
|
||||
})
|
||||
} finally {
|
||||
revitViewName.value = undefined
|
||||
showNewSyncDialog.value = false
|
||||
step.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const { mutate: deleteAccSyncItem } = useMutation(accSyncItemDeleteMutation)
|
||||
|
||||
const handleDeleteSyncItem = async (id: string) => {
|
||||
try {
|
||||
await deleteAccSyncItem({
|
||||
input: {
|
||||
projectId: props.projectId,
|
||||
id
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Delete sync item failed',
|
||||
description: error instanceof Error ? error.message : 'Unexpected error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { mutate: updateAccSyncItem } = useMutation(accSyncItemUpdateMutation)
|
||||
|
||||
const handleStatusSyncItem = async (id: string, isPaused: boolean) => {
|
||||
try {
|
||||
await updateAccSyncItem({
|
||||
input: {
|
||||
projectId: props.projectId,
|
||||
id,
|
||||
status: isPaused ? 'pending' : 'paused'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Update sync item failed',
|
||||
description: error instanceof Error ? error.message : 'Unexpected error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(tokens, (newTokens) => {
|
||||
if (newTokens?.access_token) {
|
||||
fetchHubs(newTokens?.access_token)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,152 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col text-xs space-y-2">
|
||||
<ProjectPageAccSyncs
|
||||
:project-id="projectId"
|
||||
:is-logged-in="hasTokens"
|
||||
:tokens="tokens"
|
||||
/>
|
||||
|
||||
<ClientOnly>
|
||||
<div v-if="!hasTokens">
|
||||
<CommonLoadingBar v-if="loadingTokens" :loading="true" class="my-2" />
|
||||
<div v-else>
|
||||
<hr class="mb-2" />
|
||||
<FormButton size="sm" @click="authAcc()">Connect to ACC</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
|
||||
<!-- USER INFO -->
|
||||
<div v-if="userInfo" class="flex flex-col space-y-2">
|
||||
<hr class="my-2" />
|
||||
<div class="flex flex-col text ml-1 space-y-2 mb-2">
|
||||
<span>
|
||||
<strong>Name:</strong>
|
||||
{{ userInfo.firstName }} {{ userInfo.lastName }}
|
||||
</span>
|
||||
<span>
|
||||
<strong>Email:</strong>
|
||||
{{ userInfo.emailId }}
|
||||
</span>
|
||||
<span>
|
||||
<strong>User ID:</strong>
|
||||
{{ userInfo.userId }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- <div v-if="tokens?.access_token" class="flex flex-row items-center space-x-2">
|
||||
<FormButton
|
||||
class="mr-2"
|
||||
hide-text
|
||||
:icon-left="DocumentDuplicateIcon"
|
||||
color="outline"
|
||||
@click="copy(tokens?.access_token)"
|
||||
>
|
||||
Copy to clipboard
|
||||
</FormButton>
|
||||
{{ tokens?.access_token.slice(0, 32) }}...
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AccTokens, AccUserInfo } from '@speckle/shared/acc'
|
||||
// import { DocumentDuplicateIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
const props = defineProps<{ projectId: string }>()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
// const { copy } = useClipboard()
|
||||
|
||||
const apiOrigin = useApiOrigin()
|
||||
const tokens = ref<AccTokens>()
|
||||
const hasTokens = computed(() => !!tokens.value?.access_token)
|
||||
const loadingTokens = ref(true)
|
||||
const userInfo = ref<AccUserInfo>()
|
||||
const loadingUser = ref(false)
|
||||
|
||||
// AUTH + TOKEN FLOW
|
||||
const fetchTokens = async () => {
|
||||
try {
|
||||
const res = await fetch(`${apiOrigin}/api/v1/acc/auth/status`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
if (!res.ok) return
|
||||
tokens.value = await res.json()
|
||||
} finally {
|
||||
loadingTokens.value = false
|
||||
}
|
||||
}
|
||||
fetchTokens()
|
||||
|
||||
const authAcc = async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiOrigin}/api/v1/acc/auth/login`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectId: props.projectId })
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to initiate ACC login.')
|
||||
const { authorizeUrl } = await response.json()
|
||||
if (!authorizeUrl) throw new Error('No authorize URL returned by server.')
|
||||
window.location.href = authorizeUrl
|
||||
} catch (error) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Error starting ACC login',
|
||||
description: error instanceof Error ? error.message : 'Unexpected error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleRefresh = (expiresInSeconds: number) => {
|
||||
const refreshTime = (expiresInSeconds - 60) * 1000
|
||||
setTimeout(async () => {
|
||||
loadingTokens.value = true
|
||||
const res = await fetch(`${apiOrigin}/api/v1/acc/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
})
|
||||
if (res.ok) {
|
||||
const refreshed = await res.json()
|
||||
await fetchTokens()
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'ACC tokens refreshed',
|
||||
description: refreshed
|
||||
})
|
||||
scheduleRefresh(refreshed.expires_in)
|
||||
}
|
||||
loadingTokens.value = false
|
||||
}, refreshTime)
|
||||
}
|
||||
|
||||
watch(tokens, (newTokens) => {
|
||||
if (newTokens?.expires_in) scheduleRefresh(newTokens.expires_in)
|
||||
if (newTokens?.access_token) {
|
||||
fetchUserInfo()
|
||||
}
|
||||
})
|
||||
|
||||
// USER INFO
|
||||
const fetchUserInfo = async () => {
|
||||
loadingUser.value = true
|
||||
try {
|
||||
const res = await fetch(
|
||||
'https://developer.api.autodesk.com/userprofile/v1/users/@me',
|
||||
{ headers: { Authorization: `Bearer ${tokens.value!.access_token}` } }
|
||||
)
|
||||
if (!res.ok) throw new Error('Failed to get user info directly from ACC')
|
||||
userInfo.value = await res.json()
|
||||
} catch (error) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Error fetching user info directly',
|
||||
description: error instanceof Error ? error.message : 'Unexpected error'
|
||||
})
|
||||
} finally {
|
||||
loadingUser.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -26,6 +26,18 @@
|
||||
New model
|
||||
</FormButton>
|
||||
</div>
|
||||
<!-- I believe for now sync limits corralate with model limit since new sync creates new model, once we have limits for syncs, this should change -->
|
||||
<div
|
||||
v-tippy="canCreateModel.cantClickCreateReason.value"
|
||||
class="grow inline-flex sm:grow-0 lg:hidden"
|
||||
>
|
||||
<FormButton
|
||||
:disabled="!canCreateModel.canClickCreate.value"
|
||||
@click="showNewAccSync = true"
|
||||
>
|
||||
New sync
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -78,7 +90,23 @@
|
||||
>
|
||||
View all in 3D
|
||||
</FormButton>
|
||||
<div v-tippy="canCreateModel.cantClickCreateReason.value" class="test123">
|
||||
<LayoutMenu
|
||||
v-if="showAccIntegration"
|
||||
v-model:open="showMenu"
|
||||
:items="menuItems"
|
||||
:menu-position="HorizontalDirection.Left"
|
||||
:menu-id="menuId"
|
||||
@click.stop.prevent
|
||||
@chosen="onActionChosen"
|
||||
>
|
||||
<FormButton color="primary" @click="showMenu = !showMenu">
|
||||
<div class="flex items-center gap-1">
|
||||
Add model
|
||||
<ChevronDownIcon class="h-3 w-3" />
|
||||
</div>
|
||||
</FormButton>
|
||||
</LayoutMenu>
|
||||
<div v-else v-tippy="canCreateModel.cantClickCreateReason.value">
|
||||
<FormButton
|
||||
:disabled="!canCreateModel.canClickCreate.value"
|
||||
class="hidden lg:inline-flex shrink-0"
|
||||
@@ -91,9 +119,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<ProjectModelsAdd v-model:open="showNewDialog" :project="project" />
|
||||
<IntegrationsAccDialogCreateSync :open="showNewAccSync" :project-id="project?.id" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
|
||||
import { SourceApps, SpeckleViewer } from '@speckle/shared'
|
||||
import type { SourceAppDefinition } from '@speckle/shared'
|
||||
import { debounce } from 'lodash-es'
|
||||
@@ -103,9 +133,13 @@ import type {
|
||||
ProjectModelsPageHeader_ProjectFragment
|
||||
} from '~~/lib/common/generated/gql/graphql'
|
||||
import { modelRoute } from '~~/lib/common/helpers/route'
|
||||
import type { GridListToggleValue } from '~~/lib/layout/helpers/components'
|
||||
import type {
|
||||
GridListToggleValue,
|
||||
LayoutMenuItem
|
||||
} from '~~/lib/layout/helpers/components'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useCanCreateModel } from '~/lib/projects/composables/permissions'
|
||||
import { HorizontalDirection } from '@speckle/ui-components'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:selected-members', val: FormUsersSelectItemFragment[]): void
|
||||
@@ -147,6 +181,9 @@ graphql(`
|
||||
canCreateModel {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
canReadAccIntegrationSettings {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
}
|
||||
...ProjectModelsAdd_Project
|
||||
}
|
||||
@@ -168,6 +205,8 @@ const sourceAppsBtnId = useId()
|
||||
const router = useRouter()
|
||||
const mp = useMixpanel()
|
||||
|
||||
const menuId = useId()
|
||||
|
||||
const onViewAllClick = () => {
|
||||
router.push(allModelsRoute.value)
|
||||
|
||||
@@ -180,6 +219,11 @@ const onViewAllClick = () => {
|
||||
}
|
||||
|
||||
const showNewDialog = ref(false)
|
||||
const showNewAccSync = ref(false)
|
||||
|
||||
const showAccIntegration = computed(
|
||||
() => props.project?.permissions.canReadAccIntegrationSettings.authorized
|
||||
)
|
||||
|
||||
const canCreateModel = useCanCreateModel({
|
||||
project: computed(() => props.project)
|
||||
@@ -217,6 +261,46 @@ const allModelsRoute = computed(() => {
|
||||
return modelRoute(props.projectId, resourceIdString)
|
||||
})
|
||||
|
||||
const showMenu = ref(false)
|
||||
|
||||
enum AddNewModelActionTypes {
|
||||
NewModel = 'new-model',
|
||||
NewAccSyncItem = 'new-acc-sync-item'
|
||||
}
|
||||
|
||||
const menuItems = computed<LayoutMenuItem[][]>(() => [
|
||||
[
|
||||
{
|
||||
title: 'Create new model...',
|
||||
id: AddNewModelActionTypes.NewModel,
|
||||
disabled: !canCreateModel.canClickCreate.value,
|
||||
disabledTooltip: canCreateModel.cantClickCreateReason.value
|
||||
},
|
||||
// TODO ACC: Upload a file
|
||||
{
|
||||
// TODO: Do we show this disabled in all non-enterprise cases?
|
||||
title: 'Sync from ACC...',
|
||||
id: AddNewModelActionTypes.NewAccSyncItem,
|
||||
// I believe for now sync limits corralate with model limit since new sync creates new model, once we have limits for syncs, this should change
|
||||
disabled: !canCreateModel.canClickCreate.value,
|
||||
disabledTooltip: canCreateModel.cantClickCreateReason.value
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
const onActionChosen = (params: { item: LayoutMenuItem; event: MouseEvent }) => {
|
||||
const { item } = params
|
||||
|
||||
switch (item.id) {
|
||||
case AddNewModelActionTypes.NewModel:
|
||||
handleCreateModelClick()
|
||||
break
|
||||
case AddNewModelActionTypes.NewAccSyncItem:
|
||||
showNewAccSync.value = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const team = computed(() => props.project?.team.map((t) => t.user) || [])
|
||||
|
||||
const updateDebouncedSearch = debounce(() => {
|
||||
|
||||
@@ -55,8 +55,12 @@
|
||||
submodel
|
||||
</FormButton>
|
||||
</div>
|
||||
<div v-if="accSyncItem" class="flex items-center ml-2">
|
||||
<IntegrationsAccSyncStatusModelItem :item="accSyncItem" />
|
||||
</div>
|
||||
<!-- Spacer -->
|
||||
<div class="flex-grow"></div>
|
||||
|
||||
<template v-if="!isPendingFileUpload(item)">
|
||||
<div
|
||||
v-show="
|
||||
@@ -308,6 +312,9 @@ graphql(`
|
||||
...ProjectPageLatestItemsModelItem
|
||||
...ProjectCardImportFileArea_Model
|
||||
...ProjectPageModelsCard_Model
|
||||
accSyncItem {
|
||||
...SyncStatusModelItem_AccSyncItem
|
||||
}
|
||||
}
|
||||
hasChildren
|
||||
updatedAt
|
||||
@@ -332,6 +339,10 @@ const props = defineProps<{
|
||||
const router = useRouter()
|
||||
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
|
||||
|
||||
const accSyncItem = computed(() =>
|
||||
props.item.__typename === 'ModelsTreeItem' ? props.item.model?.accSyncItem : undefined
|
||||
)
|
||||
|
||||
const importArea = ref(
|
||||
null as Nullable<{
|
||||
triggerPicker: () => void
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
:disabled="loading"
|
||||
class="z-[1] relative"
|
||||
/>
|
||||
|
||||
<ProjectPageModelsResults
|
||||
v-model:grid-or-list="gridOrList"
|
||||
v-model:search="search"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="flex flex-col text-xs space-y-2">
|
||||
<IntegrationsAccSyncs
|
||||
:project-id="projectId"
|
||||
:is-logged-in="hasTokens"
|
||||
:tokens="tokens"
|
||||
/>
|
||||
<ClientOnly>
|
||||
<div v-if="!hasTokens">
|
||||
<CommonLoadingBar v-if="loadingTokens" :loading="true" class="my-2" />
|
||||
<div v-else>
|
||||
<hr class="mb-2" />
|
||||
<FormButton size="sm" @click="authAcc(`/projects/${projectId}/acc`)">
|
||||
Connect to ACC
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USER INFO -->
|
||||
<div v-if="userInfo" class="flex flex-col space-y-2">
|
||||
<hr class="my-2" />
|
||||
<div class="flex flex-col text ml-1 space-y-2 mb-2">
|
||||
<span>
|
||||
<strong>Name:</strong>
|
||||
{{ userInfo.firstName }} {{ userInfo.lastName }}
|
||||
</span>
|
||||
<span>
|
||||
<strong>Email:</strong>
|
||||
{{ userInfo.emailId }}
|
||||
</span>
|
||||
<span>
|
||||
<strong>User ID:</strong>
|
||||
{{ userInfo.userId }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAccAuthManager } from '~/lib/acc/composables/useAccAuthManager'
|
||||
import { useAccUser } from '~/lib/acc/composables/useAccUser'
|
||||
|
||||
defineProps<{ projectId: string }>()
|
||||
|
||||
const hasTokens = computed(() => !!tokens.value?.access_token)
|
||||
|
||||
const { tokens, loadingTokens, authAcc, tryGetTokensFromCookies } = useAccAuthManager()
|
||||
const { userInfo, fetchUserInfo } = useAccUser()
|
||||
|
||||
watch(tokens, async (newTokens) => {
|
||||
if (newTokens?.access_token) {
|
||||
await fetchUserInfo(newTokens?.access_token)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await tryGetTokensFromCookies()
|
||||
if (tokens.value) {
|
||||
await fetchUserInfo(tokens.value?.access_token)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -159,7 +159,7 @@ const parentEl = ref(null as Nullable<HTMLElement>)
|
||||
const { isLoggedIn } = useActiveUser()
|
||||
const viewerState = useInjectedViewerState()
|
||||
const { sessionId } = viewerState
|
||||
const { users } = useViewerUserActivityTracking({ parentEl })
|
||||
const { users } = useViewerUserActivityTracking({ anchoredPointsParentEl: parentEl })
|
||||
const { isOpenThread, open, closeAllThreads } = useThreadUtilities()
|
||||
const {
|
||||
filters: { hasAnyFiltersApplied },
|
||||
|
||||
@@ -40,7 +40,7 @@ const open = defineModel<boolean>('open', {
|
||||
const {
|
||||
projectId,
|
||||
resources: {
|
||||
request: { resourceIdString }
|
||||
response: { concreteResourceIdString }
|
||||
}
|
||||
} = useInjectedViewerState()
|
||||
const isLoading = useMutationLoading()
|
||||
@@ -70,7 +70,7 @@ const onSubmit = handleSubmit(async (values) => {
|
||||
|
||||
const group = await createGroup({
|
||||
projectId: projectId.value,
|
||||
resourceIdString: resourceIdString.value,
|
||||
resourceIdString: concreteResourceIdString.value,
|
||||
groupName: values.name
|
||||
})
|
||||
if (group) {
|
||||
|
||||
@@ -165,7 +165,7 @@ watch(open, (newVal, oldVal) => {
|
||||
name: props.view.name,
|
||||
description: props.view.description,
|
||||
visibility: props.view.visibility,
|
||||
group: props.view.group
|
||||
group: markRaw({ ...props.view.group }) // vue-validate doesnt like this read-only proxified object
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
import type { AccHub, AccProject } from '@speckle/shared/acc'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Placeholder types for demonstration. You should use your actual types.
|
||||
export interface AccItem {
|
||||
id: string
|
||||
type: string
|
||||
attributes: {
|
||||
name: string
|
||||
displayName: string
|
||||
fileType?: string
|
||||
objectCount?: number
|
||||
}
|
||||
latestVersionId?: string
|
||||
fileExtension?: string
|
||||
storageUrn?: string | null
|
||||
}
|
||||
|
||||
export interface AccFolder extends AccItem {
|
||||
children?: AccFolder[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable function to handle ACC data fetching and state management.
|
||||
* The project details are passed to the `init` function and exptects to refresh all state when user selected new project.
|
||||
*/
|
||||
export function useAcc() {
|
||||
const loadingTree = ref<boolean>(false)
|
||||
const loadingItems = ref<boolean>(false)
|
||||
const loadingHubs = ref<boolean>(false)
|
||||
const loadingProjects = ref<boolean>(false)
|
||||
|
||||
const folderTree = ref<AccFolder | undefined>()
|
||||
const folderItems = ref<AccItem[]>([])
|
||||
const hubs = ref<AccHub[]>([])
|
||||
const projects = ref<AccProject[]>([])
|
||||
|
||||
const rootProjectFolderId = ref<string | undefined>()
|
||||
const supportedFileExtensions = ['rvt']
|
||||
|
||||
const logger = useLogger()
|
||||
|
||||
// ACC API Functions
|
||||
|
||||
/**
|
||||
* Fetches all hubs for the authenticated user.
|
||||
*/
|
||||
const fetchHubs = async (token: string) => {
|
||||
loadingHubs.value = true
|
||||
try {
|
||||
const res = await fetch('https://developer.api.autodesk.com/project/v1/hubs', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to fetch hubs')
|
||||
hubs.value = (await res.json()).data
|
||||
} catch (error) {
|
||||
logger.error(error, 'Error fetching ACC hubs')
|
||||
hubs.value = []
|
||||
} finally {
|
||||
loadingHubs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all projects for a given hub.
|
||||
*/
|
||||
const fetchProjects = async (hubId: string, token: string) => {
|
||||
loadingProjects.value = true
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://developer.api.autodesk.com/project/v1/hubs/${hubId}/projects`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
if (!res.ok) throw new Error('Failed to fetch projects')
|
||||
projects.value = (await res.json()).data
|
||||
} catch (error) {
|
||||
logger.error(error, 'Error fetching ACC projects')
|
||||
projects.value = []
|
||||
} finally {
|
||||
loadingProjects.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the root folder ID for the project.
|
||||
*/
|
||||
const getProjectRootFolderId = async (
|
||||
hubId: string,
|
||||
projectId: string,
|
||||
token: string
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://developer.api.autodesk.com/project/v1/hubs/${hubId}/projects/${projectId}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
if (!res.ok) throw new Error('Failed to get project details')
|
||||
|
||||
const r = await res.json()
|
||||
rootProjectFolderId.value = r.data.relationships?.rootFolder?.data?.id || null
|
||||
return rootProjectFolderId.value
|
||||
} catch (error) {
|
||||
logger.error(error, `Error getting root folder ID for project: ${projectId}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the immediate contents (folders and items) of a single folder.
|
||||
* This is a non-recursive, single-level fetch.
|
||||
*/
|
||||
const fetchFolderContents = async (
|
||||
projectId: string,
|
||||
folderId: string,
|
||||
token: string
|
||||
): Promise<AccItem[]> => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://developer.api.autodesk.com/data/v1/projects/${projectId}/folders/${folderId}/contents`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch contents of folder ${folderId}`)
|
||||
}
|
||||
const data = (await res.json()).data
|
||||
return data
|
||||
} catch (error) {
|
||||
logger.error(error, `Error fetching folder contents for ${folderId}:`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the latest version details for a specific item (file).
|
||||
* This function is separated for on-demand use.
|
||||
*/
|
||||
const fetchItemLatestVersion = async (
|
||||
projectId: string,
|
||||
itemId: string,
|
||||
token: string
|
||||
) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://developer.api.autodesk.com/data/v1/projects/${projectId}/items/${itemId}/tip`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch latest version for item ${itemId}`)
|
||||
}
|
||||
const data = (await res.json()).data
|
||||
return data
|
||||
} catch (error) {
|
||||
logger.error(error, `Error fetching latest version for item ${itemId}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Application Logic
|
||||
|
||||
/**
|
||||
* Builds the nested folder tree structure on initial project load.
|
||||
* This is a recursive function that only fetches folders, not files.
|
||||
* It now uses `attributes.objectCount` to avoid unnecessary API calls for empty folders.
|
||||
*/
|
||||
const buildFolderTree = async (
|
||||
projectId: string,
|
||||
folderId: string,
|
||||
token: string
|
||||
): Promise<AccFolder> => {
|
||||
const contents = await fetchFolderContents(projectId, folderId, token)
|
||||
const folders = contents.filter((item) => item.type === 'folders') as AccFolder[]
|
||||
|
||||
const populatedFolders: AccFolder[] = []
|
||||
for (const folder of folders) {
|
||||
// We only want to add a folder to the tree if it contains something.
|
||||
// The `objectCount` attribute tells us if it's empty.
|
||||
if (folder.attributes.objectCount && folder.attributes.objectCount > 0) {
|
||||
// Recursively build the full subtree for this folder
|
||||
const subTree = await buildFolderTree(projectId, folder.id, token)
|
||||
populatedFolders.push({
|
||||
id: folder.id,
|
||||
type: folder.type,
|
||||
attributes: {
|
||||
name: folder.attributes.name || folder.attributes.displayName,
|
||||
displayName: folder.attributes.displayName
|
||||
},
|
||||
children: subTree.children
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const folderTree = {
|
||||
id: folderId,
|
||||
type: 'folders',
|
||||
attributes: { name: 'Root Folder', displayName: 'Root Folder' },
|
||||
relationships: {},
|
||||
children: populatedFolders
|
||||
} as AccFolder
|
||||
|
||||
return folderTree
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all items (files) for a specific folder when a user clicks on it.
|
||||
*/
|
||||
const fetchItemsForFolder = async (
|
||||
folderId: string,
|
||||
projectId: string,
|
||||
token: string
|
||||
) => {
|
||||
loadingItems.value = true
|
||||
folderItems.value = [] // Clear previous items
|
||||
|
||||
const contents = await fetchFolderContents(projectId, folderId, token)
|
||||
const items = contents.filter((item) => item.type === 'items') as AccItem[] // items === files
|
||||
|
||||
const itemPromises = items.map(async (item) => {
|
||||
const version = await fetchItemLatestVersion(projectId, item.id, token)
|
||||
if (version) {
|
||||
const storageUrn = version.relationships?.storage?.data?.id || null
|
||||
return {
|
||||
...item,
|
||||
latestVersionId: version.id,
|
||||
fileExtension: version.attributes.fileType,
|
||||
storageUrn
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
|
||||
folderItems.value = (await Promise.all(itemPromises)).filter((item) =>
|
||||
supportedFileExtensions.includes(item.fileExtension)
|
||||
)
|
||||
loadingItems.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point to initialize the folder tree for the selected project.
|
||||
*/
|
||||
const init = async (hubId: string, projectId: string, token: string) => {
|
||||
loadingTree.value = true
|
||||
folderItems.value = []
|
||||
folderTree.value = undefined
|
||||
rootProjectFolderId.value = undefined
|
||||
try {
|
||||
const rootFolderId = await getProjectRootFolderId(hubId, projectId, token)
|
||||
if (rootFolderId) {
|
||||
folderTree.value = await buildFolderTree(projectId, rootFolderId, token)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, 'Failed to initialize Autodesk ACC composable')
|
||||
} finally {
|
||||
loadingTree.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadingTree,
|
||||
loadingItems,
|
||||
loadingHubs,
|
||||
loadingProjects,
|
||||
folderTree,
|
||||
folderItems,
|
||||
hubs,
|
||||
projects,
|
||||
rootProjectFolderId,
|
||||
fetchHubs,
|
||||
fetchProjects,
|
||||
fetchItemsForFolder,
|
||||
init
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { AccTokens } from '@speckle/shared/acc'
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
/**
|
||||
* Manages authentication logic of ACC.
|
||||
* We store tokens and its timestamp in under `acc_tokens` cookie.
|
||||
* Detection of "Refresh needed" happens with timestamp check.
|
||||
* ACC auth logic returns only expires in seconds and we need to correlate it with timestamp to substract later to understand refresh needed or not.
|
||||
* Token lifespans:
|
||||
* - Bearer token: 60 minutes
|
||||
* - Refresh token: 15 days
|
||||
*/
|
||||
export function useAccAuthManager() {
|
||||
const ACC_COOKIE_KEY = 'acc_tokens'
|
||||
const logger = useLogger()
|
||||
const apiOrigin = useApiOrigin()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const loadingTokens = ref(false)
|
||||
const tokens = ref<AccTokens>()
|
||||
const isExpired = ref(false)
|
||||
const REFRESH_TOKEN_LIFESPAN = 15 * 24 * 60 * 60 // in seconds
|
||||
|
||||
/**
|
||||
* Main logic to understand existing token in cookies is expired or not.
|
||||
* If refresh needed, we refresh and schedule
|
||||
* Otherwise, we calculate the time diff and schedule refresh accordingly
|
||||
*/
|
||||
const tryGetTokensFromCookies = async () => {
|
||||
const accTokens = Cookies.get(ACC_COOKIE_KEY)
|
||||
if (accTokens) {
|
||||
logger.info('Acc tokens are found in cookies')
|
||||
const tokensInCookies = JSON.parse(accTokens) as AccTokens
|
||||
const timeDiff = (Date.now() - tokensInCookies.timestamp) / 1000 // in seconds
|
||||
|
||||
if (timeDiff > REFRESH_TOKEN_LIFESPAN) {
|
||||
logger.info('Acc refresh token in cookies is expired')
|
||||
isExpired.value = true
|
||||
logOut()
|
||||
} else if (timeDiff + 300 > tokensInCookies.expires_in) {
|
||||
logger.info('Acc access token in cookies need refreshing')
|
||||
// 300s (6min) is arbitrary guard
|
||||
const refreshedTokens = await refreshTokens(tokensInCookies)
|
||||
tokens.value = refreshedTokens
|
||||
await saveTokensToCookies()
|
||||
if (tokens.value) scheduleRefresh(tokens.value)
|
||||
} else {
|
||||
logger.info('Acc tokens in cookies still valid')
|
||||
tokens.value = tokensInCookies
|
||||
const remainingTime = tokensInCookies.expires_in - timeDiff
|
||||
scheduleRefresh(tokens.value, remainingTime)
|
||||
}
|
||||
}
|
||||
loadingTokens.value = false
|
||||
}
|
||||
|
||||
const logOut = () => {
|
||||
tokens.value = undefined
|
||||
Cookies.remove(ACC_COOKIE_KEY)
|
||||
}
|
||||
|
||||
const refreshTokens = async (tokensToRefresh: AccTokens) => {
|
||||
try {
|
||||
loadingTokens.value = true
|
||||
const res = await fetch(`${apiOrigin}/api/v1/acc/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(tokensToRefresh)
|
||||
})
|
||||
if (res.ok) {
|
||||
const refreshedTokens = await res.json()
|
||||
tokens.value = { ...refreshedTokens, timestamp: Date.now() }
|
||||
return refreshedTokens
|
||||
}
|
||||
} catch (error) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Error on refreshing ACC credientials',
|
||||
description: error instanceof Error ? error.message : 'Unexpected error'
|
||||
})
|
||||
} finally {
|
||||
loadingTokens.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveTokensToCookies = async () => {
|
||||
const tokensWithTimestamp = { ...tokens.value, timestamp: Date.now() }
|
||||
Cookies.set('acc_tokens', JSON.stringify(tokensWithTimestamp), {
|
||||
expires: 30, // since acc refresh token lifespan 15 days, it is a safe expiration
|
||||
secure: true,
|
||||
sameSite: 'Strict'
|
||||
})
|
||||
isExpired.value = false
|
||||
}
|
||||
|
||||
const fetchTokens = async () => {
|
||||
try {
|
||||
loadingTokens.value = true
|
||||
const res = await fetch(`${apiOrigin}/api/v1/acc/auth/status`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
if (!res.ok) return
|
||||
tokens.value = await res.json()
|
||||
if (tokens.value?.expires_in) {
|
||||
scheduleRefresh(tokens.value)
|
||||
}
|
||||
await saveTokensToCookies()
|
||||
} finally {
|
||||
loadingTokens.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const authAcc = async (callbackEndpoint: string) => {
|
||||
try {
|
||||
const response = await fetch(`${apiOrigin}/api/v1/acc/auth/login`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ callbackEndpoint })
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to initiate ACC login.')
|
||||
const { authorizeUrl } = await response.json()
|
||||
if (!authorizeUrl) throw new Error('No authorize URL returned by server.')
|
||||
window.location.href = authorizeUrl
|
||||
} catch (error) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Error starting ACC login',
|
||||
description: error instanceof Error ? error.message : 'Unexpected error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleRefresh = (
|
||||
tokensToScheduleRefresh: AccTokens,
|
||||
resfreshInSeconds?: number
|
||||
) => {
|
||||
const refreshTimeInMs =
|
||||
(resfreshInSeconds ?? tokensToScheduleRefresh.expires_in) * 1000
|
||||
setTimeout(async () => {
|
||||
loadingTokens.value = true
|
||||
const res = await fetch(`${apiOrigin}/api/v1/acc/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(tokensToScheduleRefresh)
|
||||
})
|
||||
if (res.ok) {
|
||||
const refreshed = await res.json()
|
||||
tokens.value = refreshed
|
||||
// triggerNotification({
|
||||
// type: ToastNotificationType.Success,
|
||||
// title: 'ACC tokens refreshed'
|
||||
// })
|
||||
await saveTokensToCookies()
|
||||
scheduleRefresh(refreshed, refreshed.expires_in)
|
||||
}
|
||||
loadingTokens.value = false
|
||||
}, refreshTimeInMs)
|
||||
}
|
||||
|
||||
return {
|
||||
isExpired,
|
||||
tokens,
|
||||
loadingTokens,
|
||||
authAcc,
|
||||
logOut,
|
||||
fetchTokens,
|
||||
refreshTokens,
|
||||
tryGetTokensFromCookies,
|
||||
saveTokensToCookies
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { AccHub, AccProject } from '@speckle/shared/acc'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Placeholder types for demonstration. You should use your actual types.
|
||||
export interface AccItem {
|
||||
id: string
|
||||
type: string
|
||||
attributes: {
|
||||
name: string
|
||||
displayName: string
|
||||
fileType?: string
|
||||
objectCount?: number
|
||||
}
|
||||
latestVersionId?: string
|
||||
fileExtension?: string
|
||||
storageUrn?: string | null
|
||||
}
|
||||
|
||||
export interface AccFolder extends AccItem {
|
||||
children?: AccFolder[]
|
||||
}
|
||||
|
||||
export type AccItemVersion = {
|
||||
id: string
|
||||
name: string
|
||||
fileType?: string
|
||||
versionNumber: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable function to handle ACC data fetching and state management.
|
||||
* The project details are passed to the `init` function and exptects to refresh all state when user selected new project.
|
||||
*/
|
||||
export function useAcc() {
|
||||
const loadingTree = ref<boolean>(false)
|
||||
const loadingItems = ref<boolean>(false)
|
||||
const loadingHubs = ref<boolean>(false)
|
||||
const loadingProjects = ref<boolean>(false)
|
||||
|
||||
const folderTree = ref<AccFolder | undefined>()
|
||||
const folderItems = ref<AccItem[]>([])
|
||||
const hubs = ref<AccHub[]>([])
|
||||
const projects = ref<AccProject[]>([])
|
||||
|
||||
const rootProjectFolderId = ref<string | undefined>()
|
||||
|
||||
const logger = useLogger()
|
||||
|
||||
// ACC API Functions
|
||||
|
||||
/**
|
||||
* Fetches all hubs for the authenticated user.
|
||||
*/
|
||||
const fetchHubs = async (token: string) => {
|
||||
loadingHubs.value = true
|
||||
try {
|
||||
const res = await fetch('https://developer.api.autodesk.com/project/v1/hubs', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to fetch hubs')
|
||||
hubs.value = (await res.json()).data
|
||||
} catch (error) {
|
||||
logger.error(error, 'Error fetching ACC hubs')
|
||||
hubs.value = []
|
||||
} finally {
|
||||
loadingHubs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// type HubsResponse = {
|
||||
// data: {
|
||||
// id: string
|
||||
// type: 'hubs'
|
||||
// attributes: {
|
||||
// name: string
|
||||
// region: string
|
||||
// }
|
||||
// }[]
|
||||
// }
|
||||
|
||||
/**
|
||||
* Fetches all projects for a given hub.
|
||||
*/
|
||||
const fetchProjects = async (hubId: string, token: string) => {
|
||||
loadingProjects.value = true
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://developer.api.autodesk.com/project/v1/hubs/${hubId}/projects`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
if (!res.ok) throw new Error('Failed to fetch projects')
|
||||
projects.value = (await res.json()).data
|
||||
} catch (error) {
|
||||
logger.error(error, 'Error fetching ACC projects')
|
||||
projects.value = []
|
||||
} finally {
|
||||
loadingProjects.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// type ProjectsResponse = {
|
||||
// data: {
|
||||
// id: string
|
||||
// type: 'projects'
|
||||
// attributes: {
|
||||
// name: string
|
||||
// }
|
||||
// relationships: {
|
||||
// hub: {
|
||||
// data: {
|
||||
// id: string
|
||||
// type: string
|
||||
// }
|
||||
// }
|
||||
// rootFolder: {
|
||||
// data: {
|
||||
// id: string
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }[]
|
||||
// }
|
||||
|
||||
/**
|
||||
* Fetches the root folder ID for the project.
|
||||
*/
|
||||
const getProjectRootFolderId = async (
|
||||
hubId: string,
|
||||
projectId: string,
|
||||
token: string
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://developer.api.autodesk.com/project/v1/hubs/${hubId}/projects/${projectId}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
if (!res.ok) throw new Error('Failed to get project details')
|
||||
|
||||
const r = await res.json()
|
||||
rootProjectFolderId.value = r.data.relationships?.rootFolder?.data?.id || null
|
||||
return rootProjectFolderId.value
|
||||
} catch (error) {
|
||||
logger.error(error, `Error getting root folder ID for project: ${projectId}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point to initialize the folder tree for the selected project.
|
||||
*/
|
||||
const init = async (hubId: string, projectId: string, token: string) => {
|
||||
loadingTree.value = true
|
||||
folderItems.value = []
|
||||
folderTree.value = undefined
|
||||
rootProjectFolderId.value = undefined
|
||||
try {
|
||||
await getProjectRootFolderId(hubId, projectId, token)
|
||||
} catch (error) {
|
||||
logger.error(error, 'Failed to initialize Autodesk ACC composable')
|
||||
} finally {
|
||||
loadingTree.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadingTree,
|
||||
loadingItems,
|
||||
loadingHubs,
|
||||
loadingProjects,
|
||||
folderTree,
|
||||
folderItems,
|
||||
hubs,
|
||||
projects,
|
||||
rootProjectFolderId,
|
||||
fetchHubs,
|
||||
fetchProjects,
|
||||
init
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { gql } from '@apollo/client/core'
|
||||
import type { AccTokens } from '@speckle/shared/acc'
|
||||
import { useApolloClient, useQuery } from '@vue/apollo-composable'
|
||||
import { accFolderDataQuery } from '~/lib/acc/graphql/queries'
|
||||
import type { AccIntegrationFolderNode_AccFolderFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { useActiveWorkspaceSlug } from '~/lib/user/composables/activeWorkspace'
|
||||
|
||||
export const useAccFolder = (
|
||||
accProjectId: string,
|
||||
accFolderId: MaybeRef<string | undefined>,
|
||||
accTokens?: AccTokens
|
||||
) => {
|
||||
const workspaceSlug = useActiveWorkspaceSlug()
|
||||
|
||||
const apollo = useApolloClient()
|
||||
|
||||
const cachedFolder = computed(() => {
|
||||
return apollo.client.cache.readFragment<AccIntegrationFolderNode_AccFolderFragment>(
|
||||
{
|
||||
id: `AccFolder:${unref(accFolderId)}`,
|
||||
fragment: gql`
|
||||
fragment AccFolderContents on AccFolder {
|
||||
id
|
||||
name
|
||||
contents {
|
||||
items {
|
||||
id
|
||||
name
|
||||
latestVersion {
|
||||
id
|
||||
name
|
||||
versionNumber
|
||||
fileType
|
||||
}
|
||||
}
|
||||
}
|
||||
children {
|
||||
items {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// watch(cachedFolder, (v) => {
|
||||
// console.log({ cachedFolder: v })
|
||||
// }, {
|
||||
// immediate: true
|
||||
// })
|
||||
|
||||
const { result: folder } = useQuery(
|
||||
accFolderDataQuery,
|
||||
() => ({
|
||||
workspaceSlug: workspaceSlug.value!,
|
||||
accToken: accTokens!.access_token,
|
||||
accProjectId,
|
||||
accFolderId: unref(accFolderId)!
|
||||
}),
|
||||
() => ({
|
||||
enabled: !!unref(accFolderId) && !!accTokens && !!workspaceSlug.value
|
||||
})
|
||||
)
|
||||
|
||||
// watch(folder, (v) => {
|
||||
// console.log({ queryFolder: v })
|
||||
// }, {
|
||||
// immediate: true
|
||||
// })
|
||||
|
||||
const folderData = computed(() => ({
|
||||
id: accFolderId,
|
||||
...cachedFolder.value,
|
||||
...folder.value?.workspaceBySlug.integrations?.acc?.folder
|
||||
}))
|
||||
|
||||
return folderData
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AccUserInfo } from '@speckle/shared/acc'
|
||||
|
||||
export function useAccUser() {
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
|
||||
const loadingUser = ref(false)
|
||||
const userInfo = ref<AccUserInfo>()
|
||||
|
||||
const fetchUserInfo = async (token: string) => {
|
||||
loadingUser.value = true
|
||||
try {
|
||||
const res = await fetch(
|
||||
'https://developer.api.autodesk.com/userprofile/v1/users/@me',
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
if (!res.ok) throw new Error('Failed to get user info directly from ACC')
|
||||
userInfo.value = await res.json()
|
||||
} catch (error) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Error fetching user info directly',
|
||||
description: error instanceof Error ? error.message : 'Unexpected error'
|
||||
})
|
||||
} finally {
|
||||
loadingUser.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadingUser,
|
||||
userInfo,
|
||||
fetchUserInfo
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,12 @@ import { graphql } from '~~/lib/common/generated/gql'
|
||||
export const projectAccSyncItemFragment = graphql(`
|
||||
fragment ProjectAccSyncItem on AccSyncItem {
|
||||
id
|
||||
projectId
|
||||
modelId
|
||||
project {
|
||||
id
|
||||
}
|
||||
model {
|
||||
id
|
||||
}
|
||||
accRegion
|
||||
accHubId
|
||||
accProjectId
|
||||
|
||||
@@ -11,3 +11,24 @@ export const projectAccSyncItemsQuery = graphql(`
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const accFolderDataQuery = graphql(`
|
||||
query AccFolderData(
|
||||
$workspaceSlug: String!
|
||||
$accToken: String!
|
||||
$accProjectId: String!
|
||||
$accFolderId: String!
|
||||
) {
|
||||
workspaceBySlug(slug: $workspaceSlug) {
|
||||
id
|
||||
integrations {
|
||||
acc(token: $accToken) {
|
||||
folder(projectId: $accProjectId, folderId: $accFolderId) {
|
||||
id
|
||||
...AccIntegrationFolderNode_AccFolder
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import type { AccHub, AccItem } from '@speckle/shared/acc'
|
||||
import type { Integration } from '~/lib/integrations/types'
|
||||
import accLogo from '~/assets/images/integrations/acc.png'
|
||||
|
||||
export const AccIntegration: Integration = {
|
||||
cookieKey: 'acc_tokens',
|
||||
name: 'Autodesk Construction Cloud',
|
||||
description: 'Sync your files in ACC into Speckle.',
|
||||
logo: accLogo,
|
||||
connected: false,
|
||||
enabled: false,
|
||||
status: 'notConnected'
|
||||
}
|
||||
|
||||
// TODO ACC: Replace with type information inferred from gql queries, if possible
|
||||
export type AccSyncItem = {
|
||||
|
||||
@@ -20,7 +20,7 @@ export function usePasswordReset() {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Info,
|
||||
title: 'Password reset email sent',
|
||||
description: `We've sent the password reset instructions to ${email}`
|
||||
description: `If the email address '${email}' is associated with a registered user, we have sent password reset instructions to that address.`
|
||||
})
|
||||
} catch (e) {
|
||||
triggerNotification({
|
||||
|
||||
@@ -63,6 +63,8 @@ type Documents = {
|
||||
"\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": typeof types.HeaderNavShare_ProjectFragmentDoc,
|
||||
"\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n workspaceSlug\n user {\n id\n }\n }\n": typeof types.HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragmentDoc,
|
||||
"\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspace {\n id\n name\n }\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": typeof types.HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaboratorFragmentDoc,
|
||||
"\n fragment AccIntegrationFolderNode_AccFolder on AccFolder {\n id\n name\n contents {\n items {\n id\n name\n latestVersion {\n id\n name\n versionNumber\n fileType\n }\n }\n }\n children {\n items {\n id\n name\n children {\n items {\n id\n name\n }\n }\n contents {\n items {\n id\n name\n }\n }\n }\n }\n }\n": typeof types.AccIntegrationFolderNode_AccFolderFragmentDoc,
|
||||
"\n fragment SyncStatusModelItem_AccSyncItem on AccSyncItem {\n id\n status\n }\n": typeof types.SyncStatusModelItem_AccSyncItemFragmentDoc,
|
||||
"\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n name\n slug\n domainBasedMembershipProtectionEnabled\n defaultSeatType\n domains {\n domain\n id\n }\n seats {\n editors {\n available\n }\n }\n ...InviteDialogSharedSelectUsers_Workspace\n ...WorkspacesPlan_Workspace\n }\n": typeof types.InviteDialogWorkspace_WorkspaceFragmentDoc,
|
||||
"\n fragment InviteDialogProject_Project on Project {\n id\n name\n workspaceId\n workspace {\n id\n name\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n ...WorkspacesPlan_Workspace\n }\n ...InviteDialogProjectRow_Project\n }\n": typeof types.InviteDialogProject_ProjectFragmentDoc,
|
||||
"\n fragment InviteDialogProjectRow_Project on Project {\n id\n workspaceId\n workspace {\n id\n role\n }\n }\n": typeof types.InviteDialogProjectRow_ProjectFragmentDoc,
|
||||
@@ -115,10 +117,10 @@ type Documents = {
|
||||
"\n fragment ProjectPageModelsActions_Project on Project {\n id\n workspace {\n id\n slug\n }\n ...ProjectsModelPageEmbed_Project\n }\n": typeof types.ProjectPageModelsActions_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageModelsCardProject on Project {\n id\n role\n visibility\n ...ProjectPageModelsActions_Project\n ...ProjectCardImportFileArea_Project\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ProjectPageModelsCardProjectFragmentDoc,
|
||||
"\n fragment ProjectPageModelsCard_Model on Model {\n id\n homeView {\n id\n resourceIds\n }\n lastUpload: uploads(input: { limit: 1, cursor: null }) {\n items {\n id\n updatedAt\n convertedStatus\n }\n }\n lastVersion: versions(limit: 1, cursor: null) {\n items {\n id\n createdAt\n }\n }\n }\n": typeof types.ProjectPageModelsCard_ModelFragmentDoc,
|
||||
"\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n models {\n totalCount\n }\n team {\n id\n user {\n ...FormUsersSelectItem\n }\n }\n workspace {\n id\n role\n slug\n name\n readOnly\n plan {\n name\n }\n }\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectModelsAdd_Project\n }\n": typeof types.ProjectModelsPageHeader_ProjectFragmentDoc,
|
||||
"\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n models {\n totalCount\n }\n team {\n id\n user {\n ...FormUsersSelectItem\n }\n }\n workspace {\n id\n role\n slug\n name\n readOnly\n plan {\n name\n }\n }\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectModelsAdd_Project\n }\n": typeof types.ProjectModelsPageHeader_ProjectFragmentDoc,
|
||||
"\n fragment ProjectModelsPageResults_Project on Project {\n ...ProjectPageLatestItemsModels\n }\n": typeof types.ProjectModelsPageResults_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageModelsStructureItem_Project on Project {\n id\n ...ProjectPageModelsActions_Project\n ...ProjectCardImportFileArea_Project\n ...UseCanCreateModel_Project\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ProjectPageModelsStructureItem_ProjectFragmentDoc,
|
||||
"\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n ...ProjectPageModelsCard_Model\n }\n hasChildren\n updatedAt\n }\n": typeof types.SingleLevelModelTreeItemFragmentDoc,
|
||||
"\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n ...ProjectPageModelsCard_Model\n accSyncItem {\n ...SyncStatusModelItem_AccSyncItem\n }\n }\n hasChildren\n updatedAt\n }\n": typeof types.SingleLevelModelTreeItemFragmentDoc,
|
||||
"\n fragment ProjectPageModelsUploadsDialog_FileUpload on FileUpload {\n id\n convertedStatus\n convertedMessage\n fileName\n fileSize\n convertedLastUpdate\n convertedVersionId\n uploadDate\n uploadComplete\n branchName\n ...UseFailedFileImportJobUtils_FileUpload\n }\n": typeof types.ProjectPageModelsUploadsDialog_FileUploadFragmentDoc,
|
||||
"\n query GetModelUploads(\n $projectId: String!\n $modelId: String!\n $input: GetModelUploadsInput!\n ) {\n project(id: $projectId) {\n id\n model(id: $modelId) {\n id\n uploads(input: $input) {\n totalCount\n cursor\n items {\n id\n ...ProjectPageModelsUploadsDialog_FileUpload\n }\n }\n }\n }\n }\n": typeof types.GetModelUploadsDocument,
|
||||
"\n fragment ProjectPageModelsCardDeleteDialog on Model {\n id\n name\n }\n": typeof types.ProjectPageModelsCardDeleteDialogFragmentDoc,
|
||||
@@ -224,11 +226,13 @@ type Documents = {
|
||||
"\n fragment WorkspaceSidebar_Workspace on Workspace {\n ...WorkspaceSidebarMembers_Workspace\n ...WorkspaceSidebarAbout_Workspace\n ...WorkspaceSidebarSecurity_Workspace\n id\n role\n slug\n domains {\n id\n }\n plan {\n name\n }\n }\n": typeof types.WorkspaceSidebar_WorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceWizard_Workspace on Workspace {\n creationState {\n completed\n state\n }\n name\n slug\n }\n": typeof types.WorkspaceWizard_WorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceWizardStepRegion_ServerInfo on ServerInfo {\n multiRegion {\n regions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n": typeof types.WorkspaceWizardStepRegion_ServerInfoFragmentDoc,
|
||||
"\n fragment ProjectAccSyncItem on AccSyncItem {\n id\n projectId\n modelId\n accRegion\n accHubId\n accProjectId\n accRootProjectFolderUrn\n accFileLineageUrn\n accFileName\n accFileExtension\n accFileVersionIndex\n accFileViewName\n updatedAt\n status\n author {\n name\n avatar\n }\n }\n": typeof types.ProjectAccSyncItemFragmentDoc,
|
||||
"\n fragment AccFolderContents on AccFolder {\n id\n name\n contents {\n items {\n id\n name\n latestVersion {\n id\n name\n versionNumber\n fileType\n }\n }\n }\n children {\n items {\n id\n name\n }\n }\n }\n ": typeof types.AccFolderContentsFragmentDoc,
|
||||
"\n fragment ProjectAccSyncItem on AccSyncItem {\n id\n project {\n id\n }\n model {\n id\n }\n accRegion\n accHubId\n accProjectId\n accRootProjectFolderUrn\n accFileLineageUrn\n accFileName\n accFileExtension\n accFileVersionIndex\n accFileViewName\n updatedAt\n status\n author {\n name\n avatar\n }\n }\n": typeof types.ProjectAccSyncItemFragmentDoc,
|
||||
"\n mutation CreateAccSyncItem($input: CreateAccSyncItemInput!) {\n accSyncItemMutations {\n create(input: $input) {\n id\n accFileLineageUrn\n status\n }\n }\n }\n": typeof types.CreateAccSyncItemDocument,
|
||||
"\n mutation DeleteAccSyncItem($input: DeleteAccSyncItemInput!) {\n accSyncItemMutations {\n delete(input: $input)\n }\n }\n": typeof types.DeleteAccSyncItemDocument,
|
||||
"\n mutation UpdateAccSyncItem($input: UpdateAccSyncItemInput!) {\n accSyncItemMutations {\n update(input: $input) {\n id\n status\n }\n }\n }\n": typeof types.UpdateAccSyncItemDocument,
|
||||
"\n query ProjectAccSyncItems($id: String!) {\n project(id: $id) {\n accSyncItems {\n items {\n ...ProjectAccSyncItem\n }\n }\n }\n }\n": typeof types.ProjectAccSyncItemsDocument,
|
||||
"\n query AccFolderData(\n $workspaceSlug: String!\n $accToken: String!\n $accProjectId: String!\n $accFolderId: String!\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n integrations {\n acc(token: $accToken) {\n folder(projectId: $accProjectId, folderId: $accFolderId) {\n id\n ...AccIntegrationFolderNode_AccFolder\n }\n }\n }\n }\n }\n": typeof types.AccFolderDataDocument,
|
||||
"\n subscription OnProjectAccSyncItemUpdated($id: String!, $itemIds: [String!]) {\n projectAccSyncItemsUpdated(id: $id, itemIds: $itemIds) {\n type\n accSyncItem {\n ...ProjectAccSyncItem\n }\n }\n }\n": typeof types.OnProjectAccSyncItemUpdatedDocument,
|
||||
"\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n emails {\n id\n email\n verified\n primary\n }\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n ...ProjectsAdd_User\n }\n }\n": typeof types.ActiveUserMainMetadataDocument,
|
||||
"\n query ActiveUserProjectsToMove($filter: UserProjectsFilter) {\n activeUser {\n id\n projects(filter: $filter) {\n totalCount\n }\n }\n }\n": typeof types.ActiveUserProjectsToMoveDocument,
|
||||
@@ -458,7 +462,7 @@ type Documents = {
|
||||
"\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n position\n group {\n id\n }\n permissions {\n canMove {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n": typeof types.UseDraggableView_SavedViewFragmentDoc,
|
||||
"\n fragment UseDraggableViewTargetView_SavedView on SavedView {\n id\n name\n position\n group {\n id\n }\n }\n": typeof types.UseDraggableViewTargetView_SavedViewFragmentDoc,
|
||||
"\n fragment UseDraggableViewTargetGroup_SavedViewGroup on SavedViewGroup {\n id\n title\n }\n": typeof types.UseDraggableViewTargetGroup_SavedViewGroupFragmentDoc,
|
||||
"\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseSavedViewValidationHelpers_SavedViewFragmentDoc,
|
||||
"\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n canSetAsHomeView {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseSavedViewValidationHelpers_SavedViewFragmentDoc,
|
||||
"\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n }\n": typeof types.UseViewerSavedViewSetup_SavedViewFragmentDoc,
|
||||
"\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n ...ViewerCommentThreadData\n }\n": typeof types.ViewerCommentThreadFragmentDoc,
|
||||
"\n fragment ViewerCommentsReplyItem on Comment {\n id\n archived\n rawText\n text {\n doc\n }\n author {\n ...LimitedUserAvatar\n }\n createdAt\n ...ThreadCommentAttachment\n }\n": typeof types.ViewerCommentsReplyItemFragmentDoc,
|
||||
@@ -508,6 +512,7 @@ type Documents = {
|
||||
"\n mutation WorkspaceUpdateAutoJoinMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n discoverabilityAutoJoinEnabled\n }\n }\n }\n": typeof types.WorkspaceUpdateAutoJoinMutationDocument,
|
||||
"\n mutation WorkspaceUpdateDefaultSeatTypeMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n defaultSeatType\n }\n }\n }\n": typeof types.WorkspaceUpdateDefaultSeatTypeMutationDocument,
|
||||
"\n mutation WorkspaceUpdateExclusiveMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n isExclusive\n }\n }\n }\n": typeof types.WorkspaceUpdateExclusiveMutationDocument,
|
||||
"\n query Workspace($featureName: WorkspaceFeatureName!, $workspaceId: String!) {\n workspace(id: $workspaceId) {\n hasAccessToFeature(featureName: $featureName)\n }\n }\n": typeof types.WorkspaceDocument,
|
||||
"\n query WorkspaceAccessCheck($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n slug\n }\n activeUser {\n id\n activeWorkspace {\n id\n slug\n }\n }\n }\n": typeof types.WorkspaceAccessCheckDocument,
|
||||
"\n query WorkspacePageQuery(\n $workspaceSlug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n ...WorkspacePage_Workspace\n }\n }\n": typeof types.WorkspacePageQueryDocument,
|
||||
"\n query WorkspaceProjectsQuery(\n $workspaceSlug: String!\n $filter: WorkspaceProjectsFilter\n $cursor: String\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n projects(filter: $filter, cursor: $cursor, limit: 10) {\n ...WorkspaceDashboardProjectList_ProjectCollection\n }\n }\n }\n": typeof types.WorkspaceProjectsQueryDocument,
|
||||
@@ -540,10 +545,10 @@ type Documents = {
|
||||
"\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": typeof types.AutomateFunctionPage_AutomateFunctionFragmentDoc,
|
||||
"\n query AutomateFunctionPage($functionId: ID!) {\n automateFunction(id: $functionId) {\n ...AutomateFunctionPage_AutomateFunction\n }\n activeUser {\n workspaces {\n items {\n ...AutomateFunctionCreateDialog_Workspace\n ...AutomateFunctionEditDialog_Workspace\n }\n }\n }\n }\n": typeof types.AutomateFunctionPageDocument,
|
||||
"\n query AutomateFunctionPageWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...AutomateFunctionPageHeader_Workspace\n }\n }\n": typeof types.AutomateFunctionPageWorkspaceDocument,
|
||||
"\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMoveToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...WorkspaceMoveProjectManager_ProjectBase\n ...ProjectPageSettingsTab_Project\n ...WorkspaceMoveProject_Project\n }\n": typeof types.ProjectPageProjectFragmentDoc,
|
||||
"\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMoveToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...WorkspaceMoveProjectManager_ProjectBase\n ...ProjectPageSettingsTab_Project\n ...WorkspaceMoveProject_Project\n }\n": typeof types.ProjectPageProjectFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": typeof types.ProjectPageAutomationPage_AutomationFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationPage_Project on Project {\n id\n workspaceId\n ...ProjectPageAutomationHeader_Project\n }\n": typeof types.ProjectPageAutomationPage_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ProjectPageSettingsTab_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ProjectPageSettingsTab_ProjectFragmentDoc,
|
||||
"\n fragment SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": typeof types.SettingsServerProjects_ProjectCollectionFragmentDoc,
|
||||
"\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n": typeof types.SettingsServerRegionsDocument,
|
||||
"\n fragment SettingsWorkspacesGeneral_Workspace on Workspace {\n ...SettingsWorkspacesGeneralEditAvatar_Workspace\n ...SettingsWorkspaceGeneralDeleteDialog_Workspace\n ...SettingsWorkspacesGeneralEditSlugDialog_Workspace\n id\n name\n slug\n description\n logo\n role\n plan {\n status\n name\n }\n embedOptions {\n hideSpeckleBranding\n }\n permissions {\n canEditEmbedOptions {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.SettingsWorkspacesGeneral_WorkspaceFragmentDoc,
|
||||
@@ -606,6 +611,8 @@ const documents: Documents = {
|
||||
"\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": types.HeaderNavShare_ProjectFragmentDoc,
|
||||
"\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n workspaceSlug\n user {\n id\n }\n }\n": types.HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragmentDoc,
|
||||
"\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspace {\n id\n name\n }\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": types.HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaboratorFragmentDoc,
|
||||
"\n fragment AccIntegrationFolderNode_AccFolder on AccFolder {\n id\n name\n contents {\n items {\n id\n name\n latestVersion {\n id\n name\n versionNumber\n fileType\n }\n }\n }\n children {\n items {\n id\n name\n children {\n items {\n id\n name\n }\n }\n contents {\n items {\n id\n name\n }\n }\n }\n }\n }\n": types.AccIntegrationFolderNode_AccFolderFragmentDoc,
|
||||
"\n fragment SyncStatusModelItem_AccSyncItem on AccSyncItem {\n id\n status\n }\n": types.SyncStatusModelItem_AccSyncItemFragmentDoc,
|
||||
"\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n name\n slug\n domainBasedMembershipProtectionEnabled\n defaultSeatType\n domains {\n domain\n id\n }\n seats {\n editors {\n available\n }\n }\n ...InviteDialogSharedSelectUsers_Workspace\n ...WorkspacesPlan_Workspace\n }\n": types.InviteDialogWorkspace_WorkspaceFragmentDoc,
|
||||
"\n fragment InviteDialogProject_Project on Project {\n id\n name\n workspaceId\n workspace {\n id\n name\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n ...WorkspacesPlan_Workspace\n }\n ...InviteDialogProjectRow_Project\n }\n": types.InviteDialogProject_ProjectFragmentDoc,
|
||||
"\n fragment InviteDialogProjectRow_Project on Project {\n id\n workspaceId\n workspace {\n id\n role\n }\n }\n": types.InviteDialogProjectRow_ProjectFragmentDoc,
|
||||
@@ -658,10 +665,10 @@ const documents: Documents = {
|
||||
"\n fragment ProjectPageModelsActions_Project on Project {\n id\n workspace {\n id\n slug\n }\n ...ProjectsModelPageEmbed_Project\n }\n": types.ProjectPageModelsActions_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageModelsCardProject on Project {\n id\n role\n visibility\n ...ProjectPageModelsActions_Project\n ...ProjectCardImportFileArea_Project\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ProjectPageModelsCardProjectFragmentDoc,
|
||||
"\n fragment ProjectPageModelsCard_Model on Model {\n id\n homeView {\n id\n resourceIds\n }\n lastUpload: uploads(input: { limit: 1, cursor: null }) {\n items {\n id\n updatedAt\n convertedStatus\n }\n }\n lastVersion: versions(limit: 1, cursor: null) {\n items {\n id\n createdAt\n }\n }\n }\n": types.ProjectPageModelsCard_ModelFragmentDoc,
|
||||
"\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n models {\n totalCount\n }\n team {\n id\n user {\n ...FormUsersSelectItem\n }\n }\n workspace {\n id\n role\n slug\n name\n readOnly\n plan {\n name\n }\n }\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectModelsAdd_Project\n }\n": types.ProjectModelsPageHeader_ProjectFragmentDoc,
|
||||
"\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n models {\n totalCount\n }\n team {\n id\n user {\n ...FormUsersSelectItem\n }\n }\n workspace {\n id\n role\n slug\n name\n readOnly\n plan {\n name\n }\n }\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectModelsAdd_Project\n }\n": types.ProjectModelsPageHeader_ProjectFragmentDoc,
|
||||
"\n fragment ProjectModelsPageResults_Project on Project {\n ...ProjectPageLatestItemsModels\n }\n": types.ProjectModelsPageResults_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageModelsStructureItem_Project on Project {\n id\n ...ProjectPageModelsActions_Project\n ...ProjectCardImportFileArea_Project\n ...UseCanCreateModel_Project\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ProjectPageModelsStructureItem_ProjectFragmentDoc,
|
||||
"\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n ...ProjectPageModelsCard_Model\n }\n hasChildren\n updatedAt\n }\n": types.SingleLevelModelTreeItemFragmentDoc,
|
||||
"\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n ...ProjectPageModelsCard_Model\n accSyncItem {\n ...SyncStatusModelItem_AccSyncItem\n }\n }\n hasChildren\n updatedAt\n }\n": types.SingleLevelModelTreeItemFragmentDoc,
|
||||
"\n fragment ProjectPageModelsUploadsDialog_FileUpload on FileUpload {\n id\n convertedStatus\n convertedMessage\n fileName\n fileSize\n convertedLastUpdate\n convertedVersionId\n uploadDate\n uploadComplete\n branchName\n ...UseFailedFileImportJobUtils_FileUpload\n }\n": types.ProjectPageModelsUploadsDialog_FileUploadFragmentDoc,
|
||||
"\n query GetModelUploads(\n $projectId: String!\n $modelId: String!\n $input: GetModelUploadsInput!\n ) {\n project(id: $projectId) {\n id\n model(id: $modelId) {\n id\n uploads(input: $input) {\n totalCount\n cursor\n items {\n id\n ...ProjectPageModelsUploadsDialog_FileUpload\n }\n }\n }\n }\n }\n": types.GetModelUploadsDocument,
|
||||
"\n fragment ProjectPageModelsCardDeleteDialog on Model {\n id\n name\n }\n": types.ProjectPageModelsCardDeleteDialogFragmentDoc,
|
||||
@@ -767,11 +774,13 @@ const documents: Documents = {
|
||||
"\n fragment WorkspaceSidebar_Workspace on Workspace {\n ...WorkspaceSidebarMembers_Workspace\n ...WorkspaceSidebarAbout_Workspace\n ...WorkspaceSidebarSecurity_Workspace\n id\n role\n slug\n domains {\n id\n }\n plan {\n name\n }\n }\n": types.WorkspaceSidebar_WorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceWizard_Workspace on Workspace {\n creationState {\n completed\n state\n }\n name\n slug\n }\n": types.WorkspaceWizard_WorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceWizardStepRegion_ServerInfo on ServerInfo {\n multiRegion {\n regions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n": types.WorkspaceWizardStepRegion_ServerInfoFragmentDoc,
|
||||
"\n fragment ProjectAccSyncItem on AccSyncItem {\n id\n projectId\n modelId\n accRegion\n accHubId\n accProjectId\n accRootProjectFolderUrn\n accFileLineageUrn\n accFileName\n accFileExtension\n accFileVersionIndex\n accFileViewName\n updatedAt\n status\n author {\n name\n avatar\n }\n }\n": types.ProjectAccSyncItemFragmentDoc,
|
||||
"\n fragment AccFolderContents on AccFolder {\n id\n name\n contents {\n items {\n id\n name\n latestVersion {\n id\n name\n versionNumber\n fileType\n }\n }\n }\n children {\n items {\n id\n name\n }\n }\n }\n ": types.AccFolderContentsFragmentDoc,
|
||||
"\n fragment ProjectAccSyncItem on AccSyncItem {\n id\n project {\n id\n }\n model {\n id\n }\n accRegion\n accHubId\n accProjectId\n accRootProjectFolderUrn\n accFileLineageUrn\n accFileName\n accFileExtension\n accFileVersionIndex\n accFileViewName\n updatedAt\n status\n author {\n name\n avatar\n }\n }\n": types.ProjectAccSyncItemFragmentDoc,
|
||||
"\n mutation CreateAccSyncItem($input: CreateAccSyncItemInput!) {\n accSyncItemMutations {\n create(input: $input) {\n id\n accFileLineageUrn\n status\n }\n }\n }\n": types.CreateAccSyncItemDocument,
|
||||
"\n mutation DeleteAccSyncItem($input: DeleteAccSyncItemInput!) {\n accSyncItemMutations {\n delete(input: $input)\n }\n }\n": types.DeleteAccSyncItemDocument,
|
||||
"\n mutation UpdateAccSyncItem($input: UpdateAccSyncItemInput!) {\n accSyncItemMutations {\n update(input: $input) {\n id\n status\n }\n }\n }\n": types.UpdateAccSyncItemDocument,
|
||||
"\n query ProjectAccSyncItems($id: String!) {\n project(id: $id) {\n accSyncItems {\n items {\n ...ProjectAccSyncItem\n }\n }\n }\n }\n": types.ProjectAccSyncItemsDocument,
|
||||
"\n query AccFolderData(\n $workspaceSlug: String!\n $accToken: String!\n $accProjectId: String!\n $accFolderId: String!\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n integrations {\n acc(token: $accToken) {\n folder(projectId: $accProjectId, folderId: $accFolderId) {\n id\n ...AccIntegrationFolderNode_AccFolder\n }\n }\n }\n }\n }\n": types.AccFolderDataDocument,
|
||||
"\n subscription OnProjectAccSyncItemUpdated($id: String!, $itemIds: [String!]) {\n projectAccSyncItemsUpdated(id: $id, itemIds: $itemIds) {\n type\n accSyncItem {\n ...ProjectAccSyncItem\n }\n }\n }\n": types.OnProjectAccSyncItemUpdatedDocument,
|
||||
"\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n emails {\n id\n email\n verified\n primary\n }\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n ...ProjectsAdd_User\n }\n }\n": types.ActiveUserMainMetadataDocument,
|
||||
"\n query ActiveUserProjectsToMove($filter: UserProjectsFilter) {\n activeUser {\n id\n projects(filter: $filter) {\n totalCount\n }\n }\n }\n": types.ActiveUserProjectsToMoveDocument,
|
||||
@@ -1001,7 +1010,7 @@ const documents: Documents = {
|
||||
"\n fragment UseDraggableView_SavedView on SavedView {\n id\n projectId\n name\n position\n group {\n id\n }\n permissions {\n canMove {\n ...FullPermissionCheckResult\n }\n }\n ...UseUpdateSavedView_SavedView\n }\n": types.UseDraggableView_SavedViewFragmentDoc,
|
||||
"\n fragment UseDraggableViewTargetView_SavedView on SavedView {\n id\n name\n position\n group {\n id\n }\n }\n": types.UseDraggableViewTargetView_SavedViewFragmentDoc,
|
||||
"\n fragment UseDraggableViewTargetGroup_SavedViewGroup on SavedViewGroup {\n id\n title\n }\n": types.UseDraggableViewTargetGroup_SavedViewGroupFragmentDoc,
|
||||
"\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseSavedViewValidationHelpers_SavedViewFragmentDoc,
|
||||
"\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n canSetAsHomeView {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseSavedViewValidationHelpers_SavedViewFragmentDoc,
|
||||
"\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n }\n": types.UseViewerSavedViewSetup_SavedViewFragmentDoc,
|
||||
"\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n ...ViewerCommentThreadData\n }\n": types.ViewerCommentThreadFragmentDoc,
|
||||
"\n fragment ViewerCommentsReplyItem on Comment {\n id\n archived\n rawText\n text {\n doc\n }\n author {\n ...LimitedUserAvatar\n }\n createdAt\n ...ThreadCommentAttachment\n }\n": types.ViewerCommentsReplyItemFragmentDoc,
|
||||
@@ -1051,6 +1060,7 @@ const documents: Documents = {
|
||||
"\n mutation WorkspaceUpdateAutoJoinMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n discoverabilityAutoJoinEnabled\n }\n }\n }\n": types.WorkspaceUpdateAutoJoinMutationDocument,
|
||||
"\n mutation WorkspaceUpdateDefaultSeatTypeMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n defaultSeatType\n }\n }\n }\n": types.WorkspaceUpdateDefaultSeatTypeMutationDocument,
|
||||
"\n mutation WorkspaceUpdateExclusiveMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n isExclusive\n }\n }\n }\n": types.WorkspaceUpdateExclusiveMutationDocument,
|
||||
"\n query Workspace($featureName: WorkspaceFeatureName!, $workspaceId: String!) {\n workspace(id: $workspaceId) {\n hasAccessToFeature(featureName: $featureName)\n }\n }\n": types.WorkspaceDocument,
|
||||
"\n query WorkspaceAccessCheck($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n slug\n }\n activeUser {\n id\n activeWorkspace {\n id\n slug\n }\n }\n }\n": types.WorkspaceAccessCheckDocument,
|
||||
"\n query WorkspacePageQuery(\n $workspaceSlug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n ...WorkspacePage_Workspace\n }\n }\n": types.WorkspacePageQueryDocument,
|
||||
"\n query WorkspaceProjectsQuery(\n $workspaceSlug: String!\n $filter: WorkspaceProjectsFilter\n $cursor: String\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n projects(filter: $filter, cursor: $cursor, limit: 10) {\n ...WorkspaceDashboardProjectList_ProjectCollection\n }\n }\n }\n": types.WorkspaceProjectsQueryDocument,
|
||||
@@ -1083,10 +1093,10 @@ const documents: Documents = {
|
||||
"\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 activeUser {\n workspaces {\n items {\n ...AutomateFunctionCreateDialog_Workspace\n ...AutomateFunctionEditDialog_Workspace\n }\n }\n }\n }\n": types.AutomateFunctionPageDocument,
|
||||
"\n query AutomateFunctionPageWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...AutomateFunctionPageHeader_Workspace\n }\n }\n": types.AutomateFunctionPageWorkspaceDocument,
|
||||
"\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMoveToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...WorkspaceMoveProjectManager_ProjectBase\n ...ProjectPageSettingsTab_Project\n ...WorkspaceMoveProject_Project\n }\n": types.ProjectPageProjectFragmentDoc,
|
||||
"\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMoveToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...WorkspaceMoveProjectManager_ProjectBase\n ...ProjectPageSettingsTab_Project\n ...WorkspaceMoveProject_Project\n }\n": types.ProjectPageProjectFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": types.ProjectPageAutomationPage_AutomationFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationPage_Project on Project {\n id\n workspaceId\n ...ProjectPageAutomationHeader_Project\n }\n": types.ProjectPageAutomationPage_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ProjectPageSettingsTab_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ProjectPageSettingsTab_ProjectFragmentDoc,
|
||||
"\n fragment SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": types.SettingsServerProjects_ProjectCollectionFragmentDoc,
|
||||
"\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n": types.SettingsServerRegionsDocument,
|
||||
"\n fragment SettingsWorkspacesGeneral_Workspace on Workspace {\n ...SettingsWorkspacesGeneralEditAvatar_Workspace\n ...SettingsWorkspaceGeneralDeleteDialog_Workspace\n ...SettingsWorkspacesGeneralEditSlugDialog_Workspace\n id\n name\n slug\n description\n logo\n role\n plan {\n status\n name\n }\n embedOptions {\n hideSpeckleBranding\n }\n permissions {\n canEditEmbedOptions {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.SettingsWorkspacesGeneral_WorkspaceFragmentDoc,
|
||||
@@ -1310,6 +1320,14 @@ export function graphql(source: "\n fragment HeaderNavNotificationsProjectInvit
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspace {\n id\n name\n }\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n"): (typeof documents)["\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspace {\n id\n name\n }\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\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 fragment AccIntegrationFolderNode_AccFolder on AccFolder {\n id\n name\n contents {\n items {\n id\n name\n latestVersion {\n id\n name\n versionNumber\n fileType\n }\n }\n }\n children {\n items {\n id\n name\n children {\n items {\n id\n name\n }\n }\n contents {\n items {\n id\n name\n }\n }\n }\n }\n }\n"): (typeof documents)["\n fragment AccIntegrationFolderNode_AccFolder on AccFolder {\n id\n name\n contents {\n items {\n id\n name\n latestVersion {\n id\n name\n versionNumber\n fileType\n }\n }\n }\n children {\n items {\n id\n name\n children {\n items {\n id\n name\n }\n }\n contents {\n items {\n id\n name\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 fragment SyncStatusModelItem_AccSyncItem on AccSyncItem {\n id\n status\n }\n"): (typeof documents)["\n fragment SyncStatusModelItem_AccSyncItem on AccSyncItem {\n id\n status\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1521,7 +1539,7 @@ export function graphql(source: "\n fragment ProjectPageModelsCard_Model on Mod
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n models {\n totalCount\n }\n team {\n id\n user {\n ...FormUsersSelectItem\n }\n }\n workspace {\n id\n role\n slug\n name\n readOnly\n plan {\n name\n }\n }\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectModelsAdd_Project\n }\n"): (typeof documents)["\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n models {\n totalCount\n }\n team {\n id\n user {\n ...FormUsersSelectItem\n }\n }\n workspace {\n id\n role\n slug\n name\n readOnly\n plan {\n name\n }\n }\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectModelsAdd_Project\n }\n"];
|
||||
export function graphql(source: "\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n models {\n totalCount\n }\n team {\n id\n user {\n ...FormUsersSelectItem\n }\n }\n workspace {\n id\n role\n slug\n name\n readOnly\n plan {\n name\n }\n }\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectModelsAdd_Project\n }\n"): (typeof documents)["\n fragment ProjectModelsPageHeader_Project on Project {\n id\n name\n sourceApps\n role\n models {\n totalCount\n }\n team {\n id\n user {\n ...FormUsersSelectItem\n }\n }\n workspace {\n id\n role\n slug\n name\n readOnly\n plan {\n name\n }\n }\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectModelsAdd_Project\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1533,7 +1551,7 @@ export function graphql(source: "\n fragment ProjectPageModelsStructureItem_Pro
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n ...ProjectPageModelsCard_Model\n }\n hasChildren\n updatedAt\n }\n"): (typeof documents)["\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n ...ProjectPageModelsCard_Model\n }\n hasChildren\n updatedAt\n }\n"];
|
||||
export function graphql(source: "\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n ...ProjectPageModelsCard_Model\n accSyncItem {\n ...SyncStatusModelItem_AccSyncItem\n }\n }\n hasChildren\n updatedAt\n }\n"): (typeof documents)["\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n ...ProjectCardImportFileArea_Model\n ...ProjectPageModelsCard_Model\n accSyncItem {\n ...SyncStatusModelItem_AccSyncItem\n }\n }\n hasChildren\n updatedAt\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1957,7 +1975,11 @@ export function graphql(source: "\n fragment WorkspaceWizardStepRegion_ServerIn
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ProjectAccSyncItem on AccSyncItem {\n id\n projectId\n modelId\n accRegion\n accHubId\n accProjectId\n accRootProjectFolderUrn\n accFileLineageUrn\n accFileName\n accFileExtension\n accFileVersionIndex\n accFileViewName\n updatedAt\n status\n author {\n name\n avatar\n }\n }\n"): (typeof documents)["\n fragment ProjectAccSyncItem on AccSyncItem {\n id\n projectId\n modelId\n accRegion\n accHubId\n accProjectId\n accRootProjectFolderUrn\n accFileLineageUrn\n accFileName\n accFileExtension\n accFileVersionIndex\n accFileViewName\n updatedAt\n status\n author {\n name\n avatar\n }\n }\n"];
|
||||
export function graphql(source: "\n fragment AccFolderContents on AccFolder {\n id\n name\n contents {\n items {\n id\n name\n latestVersion {\n id\n name\n versionNumber\n fileType\n }\n }\n }\n children {\n items {\n id\n name\n }\n }\n }\n "): (typeof documents)["\n fragment AccFolderContents on AccFolder {\n id\n name\n contents {\n items {\n id\n name\n latestVersion {\n id\n name\n versionNumber\n fileType\n }\n }\n }\n children {\n items {\n id\n name\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 fragment ProjectAccSyncItem on AccSyncItem {\n id\n project {\n id\n }\n model {\n id\n }\n accRegion\n accHubId\n accProjectId\n accRootProjectFolderUrn\n accFileLineageUrn\n accFileName\n accFileExtension\n accFileVersionIndex\n accFileViewName\n updatedAt\n status\n author {\n name\n avatar\n }\n }\n"): (typeof documents)["\n fragment ProjectAccSyncItem on AccSyncItem {\n id\n project {\n id\n }\n model {\n id\n }\n accRegion\n accHubId\n accProjectId\n accRootProjectFolderUrn\n accFileLineageUrn\n accFileName\n accFileExtension\n accFileVersionIndex\n accFileViewName\n updatedAt\n status\n author {\n name\n avatar\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1974,6 +1996,10 @@ export function graphql(source: "\n mutation UpdateAccSyncItem($input: UpdateAc
|
||||
* 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 ProjectAccSyncItems($id: String!) {\n project(id: $id) {\n accSyncItems {\n items {\n ...ProjectAccSyncItem\n }\n }\n }\n }\n"): (typeof documents)["\n query ProjectAccSyncItems($id: String!) {\n project(id: $id) {\n accSyncItems {\n items {\n ...ProjectAccSyncItem\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 AccFolderData(\n $workspaceSlug: String!\n $accToken: String!\n $accProjectId: String!\n $accFolderId: String!\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n integrations {\n acc(token: $accToken) {\n folder(projectId: $accProjectId, folderId: $accFolderId) {\n id\n ...AccIntegrationFolderNode_AccFolder\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query AccFolderData(\n $workspaceSlug: String!\n $accToken: String!\n $accProjectId: String!\n $accFolderId: String!\n ) {\n workspaceBySlug(slug: $workspaceSlug) {\n id\n integrations {\n acc(token: $accToken) {\n folder(projectId: $accProjectId, folderId: $accFolderId) {\n id\n ...AccIntegrationFolderNode_AccFolder\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.
|
||||
*/
|
||||
@@ -2893,7 +2919,7 @@ export function graphql(source: "\n fragment UseDraggableViewTargetGroup_SavedV
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n canSetAsHomeView {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n canSetAsHomeView {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -3090,6 +3116,10 @@ export function graphql(source: "\n mutation WorkspaceUpdateDefaultSeatTypeMuta
|
||||
* 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 WorkspaceUpdateExclusiveMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n isExclusive\n }\n }\n }\n"): (typeof documents)["\n mutation WorkspaceUpdateExclusiveMutation($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n isExclusive\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 Workspace($featureName: WorkspaceFeatureName!, $workspaceId: String!) {\n workspace(id: $workspaceId) {\n hasAccessToFeature(featureName: $featureName)\n }\n }\n"): (typeof documents)["\n query Workspace($featureName: WorkspaceFeatureName!, $workspaceId: String!) {\n workspace(id: $workspaceId) {\n hasAccessToFeature(featureName: $featureName)\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -3221,7 +3251,7 @@ export function graphql(source: "\n query AutomateFunctionPageWorkspace($worksp
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMoveToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...WorkspaceMoveProjectManager_ProjectBase\n ...ProjectPageSettingsTab_Project\n ...WorkspaceMoveProject_Project\n }\n"): (typeof documents)["\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMoveToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...WorkspaceMoveProjectManager_ProjectBase\n ...ProjectPageSettingsTab_Project\n ...WorkspaceMoveProject_Project\n }\n"];
|
||||
export function graphql(source: "\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMoveToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...WorkspaceMoveProjectManager_ProjectBase\n ...ProjectPageSettingsTab_Project\n ...WorkspaceMoveProject_Project\n }\n"): (typeof documents)["\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n permissions {\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMoveToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...WorkspaceMoveProjectManager_ProjectBase\n ...ProjectPageSettingsTab_Project\n ...WorkspaceMoveProject_Project\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -3233,7 +3263,7 @@ export function graphql(source: "\n fragment ProjectPageAutomationPage_Project
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n canReadAccIntegrationSettings {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -23,7 +23,7 @@ export function wrapRefWithTracking<R extends Ref<unknown>>(
|
||||
},
|
||||
set: (newVal) => {
|
||||
if (!readsOnly) {
|
||||
logger().debug(`debugging: '${name}' written to`, newVal, getTrace())
|
||||
logger().debug(`debugging: '${name}' written to`, { newVal }, getTrace())
|
||||
}
|
||||
|
||||
ref.value = newVal
|
||||
|
||||
@@ -70,6 +70,11 @@ export const settingsWorkspaceRoutes = {
|
||||
route: (slug: MaybeNullOrUndefined<string>) =>
|
||||
slug ? `/settings/workspaces/${slug}/projects` : '/'
|
||||
},
|
||||
integrations: {
|
||||
name: 'settings-workspaces-slug-integrations',
|
||||
route: (slug: MaybeNullOrUndefined<string>) =>
|
||||
slug ? `/settings/workspaces/${slug}/integrations` : '/'
|
||||
},
|
||||
automation: {
|
||||
name: 'settings-workspaces-slug-automation',
|
||||
route: (slug?: string) => `/settings/workspaces/${slug}/automation`
|
||||
@@ -134,6 +139,9 @@ export const projectWebhooksRoute = (projectId: string) =>
|
||||
export const projectTokensRoute = (projectId: string) =>
|
||||
`/projects/${projectId}/settings/tokens`
|
||||
|
||||
export const projectIntegrationsRoute = (projectId: string) =>
|
||||
`/projects/${projectId}/settings/integrations`
|
||||
|
||||
export const threadRedirectRoute = (projectId: string, threadId: string) =>
|
||||
`/projects/${projectId}/threads/${threadId}`
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useApolloClient } from '@vue/apollo-composable'
|
||||
import { useAccAuthManager } from '~/lib/acc/composables/useAccAuthManager'
|
||||
import { AccIntegration } from '~/lib/acc/types'
|
||||
import { WorkspaceFeatureName } from '~/lib/common/generated/gql/graphql'
|
||||
import type { Integration } from '~/lib/integrations/types'
|
||||
import { workspaceFeatureEnabledCheckQuery } from '~/lib/workspaces/graphql/queries'
|
||||
|
||||
export function useAccIntegration() {
|
||||
const integration = ref<Integration>(AccIntegration)
|
||||
const apollo = useApolloClient().client
|
||||
const loading = ref(false)
|
||||
|
||||
const checkConnection = async (workspaceSlug: string, workspaceId: string) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const isAccModuleEnabled = useIsAccModuleEnabled()
|
||||
|
||||
if (isAccModuleEnabled) {
|
||||
const accIntegationEnabled = await isAccEnabledInWorkspace(workspaceId)
|
||||
const callbackEndpoint = `settings/workspaces/${workspaceSlug}/integrations`
|
||||
|
||||
if (accIntegationEnabled) {
|
||||
const { isExpired, tokens, tryGetTokensFromCookies } = useAccAuthManager()
|
||||
await tryGetTokensFromCookies() // also refreshes the tokens - so we can rely on existance of tokens to say 'connected'
|
||||
|
||||
integration.value = {
|
||||
...AccIntegration,
|
||||
connected: tokens.value !== undefined,
|
||||
status: isExpired.value
|
||||
? 'expired'
|
||||
: tokens.value !== undefined
|
||||
? 'connected'
|
||||
: 'notConnected',
|
||||
enabled: true,
|
||||
callbackEndpoint
|
||||
}
|
||||
} else {
|
||||
integration.value = { ...AccIntegration, callbackEndpoint }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isAccEnabledInWorkspace = async (workspaceId: string) => {
|
||||
const { data } = await apollo.query({
|
||||
query: workspaceFeatureEnabledCheckQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
featureName: WorkspaceFeatureName.AccIntegration
|
||||
},
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
return data?.workspace?.hasAccessToFeature ?? false
|
||||
}
|
||||
|
||||
const checkCredientials = async () => {
|
||||
const { tryGetTokensFromCookies } = useAccAuthManager()
|
||||
await tryGetTokensFromCookies()
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
integration,
|
||||
checkConnection,
|
||||
checkCredientials
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type Integration = {
|
||||
name: string
|
||||
description: string
|
||||
cookieKey: string
|
||||
logo: string
|
||||
connected: boolean
|
||||
enabled: boolean
|
||||
status: 'connected' | 'expired' | 'notConnected'
|
||||
callbackEndpoint?: string
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
SavedViewVisibility
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { projectPresentationPageQuery } from '~/lib/presentations/graphql/queries'
|
||||
import { useEventBus } from '~/lib/core/composables/eventBus'
|
||||
import { ViewerEventBusKeys } from '~/lib/viewer/helpers/eventBus'
|
||||
import { useProjectSavedViewsUpdateTracking } from '~/lib/viewer/composables/savedViews/subscriptions'
|
||||
|
||||
type ResponseProject = Optional<Get<ProjectPresentationPageQuery, 'project'>>
|
||||
@@ -44,10 +42,6 @@ export type InjectablePresentationState = Readonly<{
|
||||
* active slide etc.
|
||||
*/
|
||||
resourceIdString: ComputedRef<string>
|
||||
/**
|
||||
* Reset the current view to the saved view state of the current slide
|
||||
*/
|
||||
resetView: () => void
|
||||
/**
|
||||
* Whether the current view has been changed from the saved view state
|
||||
*/
|
||||
@@ -101,8 +95,6 @@ const setupStateViewer = (initState: ResponseState & UiState): ViewerState => {
|
||||
ui: { slideIdx }
|
||||
} = initState
|
||||
|
||||
const { emit, on } = useEventBus()
|
||||
|
||||
const hasViewChanged = ref(false)
|
||||
|
||||
const resourceIdString = computed(() => {
|
||||
@@ -113,32 +105,9 @@ const setupStateViewer = (initState: ResponseState & UiState): ViewerState => {
|
||||
.toString()
|
||||
})
|
||||
|
||||
const resetView = () => {
|
||||
const slides = presentation.value?.views.items || []
|
||||
const currentSlide = slides.at(slideIdx.value)
|
||||
|
||||
if (!currentSlide?.id) return
|
||||
|
||||
emit(ViewerEventBusKeys.ApplySavedView, {
|
||||
id: currentSlide.id,
|
||||
loadOriginal: false
|
||||
})
|
||||
|
||||
hasViewChanged.value = false
|
||||
}
|
||||
|
||||
on(ViewerEventBusKeys.UserChangedOpenedView, () => {
|
||||
hasViewChanged.value = true
|
||||
})
|
||||
|
||||
watch(slideIdx, () => {
|
||||
hasViewChanged.value = false
|
||||
})
|
||||
|
||||
return {
|
||||
viewer: {
|
||||
resourceIdString,
|
||||
resetView,
|
||||
hasViewChanged
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
|
||||
|
||||
export const useResetViewUtils = () => {
|
||||
const {
|
||||
response: { presentation },
|
||||
ui: { slideIdx },
|
||||
viewer: { hasViewChanged }
|
||||
} = useInjectedPresentationState()
|
||||
|
||||
const { emit } = useEventBus()
|
||||
|
||||
const resetView = () => {
|
||||
const slides = presentation.value?.views.items || []
|
||||
const currentSlide = slides.at(slideIdx.value)
|
||||
|
||||
if (!currentSlide?.id) return
|
||||
|
||||
emit(ViewerEventBusKeys.ApplySavedView, {
|
||||
id: currentSlide.id,
|
||||
loadOriginal: false
|
||||
})
|
||||
|
||||
hasViewChanged.value = false
|
||||
}
|
||||
|
||||
return { resetView }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
|
||||
import { useViewerUserActivityTracking } from '~/lib/viewer/composables/activity'
|
||||
import { useInjectedViewerState } from '~/lib/viewer/composables/setup'
|
||||
|
||||
const useActivityTrackingIntegration = () => {
|
||||
useViewerUserActivityTracking({
|
||||
trackInternallyOnly: true
|
||||
})
|
||||
}
|
||||
|
||||
const useResetTrackingIntegration = () => {
|
||||
const { on } = useEventBus()
|
||||
const {
|
||||
ui: { slideIdx },
|
||||
viewer: { hasViewChanged }
|
||||
} = useInjectedPresentationState()
|
||||
const {
|
||||
ui: {
|
||||
savedViews: { savedViewStateId }
|
||||
}
|
||||
} = useInjectedViewerState()
|
||||
|
||||
on(ViewerEventBusKeys.UserChangedOpenedView, () => {
|
||||
hasViewChanged.value = true
|
||||
})
|
||||
|
||||
watch(
|
||||
slideIdx,
|
||||
() => {
|
||||
savedViewStateId.value = undefined
|
||||
hasViewChanged.value = false
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Post setup work to run after the viewer (and presentation) states have been set up
|
||||
*/
|
||||
export const usePresentationViewerPostSetup = () => {
|
||||
if (import.meta.server) return
|
||||
useActivityTrackingIntegration()
|
||||
useResetTrackingIntegration()
|
||||
}
|
||||
@@ -66,6 +66,12 @@ export const useSettingsMenu = () => {
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: 'Integrations',
|
||||
name: settingsWorkspaceRoutes.integrations.name,
|
||||
route: (slug?: string) => settingsWorkspaceRoutes.integrations.route(slug),
|
||||
permission: [Roles.Workspace.Admin, Roles.Workspace.Member]
|
||||
},
|
||||
{
|
||||
title: 'Security',
|
||||
name: settingsWorkspaceRoutes.security.name,
|
||||
|
||||
@@ -261,10 +261,19 @@ export type UserActivityModel = Merge<
|
||||
/**
|
||||
* Track other user activity and emit viewing/disconnected updates
|
||||
*/
|
||||
export function useViewerUserActivityTracking(params: {
|
||||
parentEl: Ref<Nullable<HTMLElement>>
|
||||
}) {
|
||||
const { parentEl } = params
|
||||
export function useViewerUserActivityTracking(
|
||||
params?: Partial<{
|
||||
/**
|
||||
* Set if you need users to be positioned correctly in viewer world space in an overlaid anchored points element
|
||||
*/
|
||||
anchoredPointsParentEl: Ref<Nullable<HTMLElement>>
|
||||
/**
|
||||
* Whether to only track viewer state changes, without broadcasting it to other users or getting updates from them
|
||||
*/
|
||||
trackInternallyOnly: boolean
|
||||
}>
|
||||
) {
|
||||
const { anchoredPointsParentEl: parentEl, trackInternallyOnly } = params || {}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
@@ -275,10 +284,27 @@ export function useViewerUserActivityTracking(params: {
|
||||
} = useInjectedViewerState()
|
||||
const { isLoggedIn } = useActiveUser()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const { update } = useViewerRealtimeActivityTracker()
|
||||
const sendUpdate = useViewerUserActivityBroadcasting()
|
||||
const { isEnabled: isEmbedEnabled } = useEmbed()
|
||||
const { activeUser } = useActiveUser()
|
||||
|
||||
const processViewerViewing = async () => {
|
||||
if (trackInternallyOnly) {
|
||||
update({ status: ViewerUserActivityStatus.Viewing })
|
||||
} else {
|
||||
await sendUpdate.emitViewing()
|
||||
}
|
||||
}
|
||||
|
||||
const processViewerDisconnected = async () => {
|
||||
if (trackInternallyOnly) {
|
||||
update({ status: ViewerUserActivityStatus.Disconnected })
|
||||
} else {
|
||||
await sendUpdate.emitDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: For some reason subscription is set up twice? Vue Apollo bug?
|
||||
const { onResult: onUserActivity } = useSubscription(
|
||||
onViewerUserActivityBroadcastedSubscription,
|
||||
@@ -291,7 +317,7 @@ export function useViewerUserActivityTracking(params: {
|
||||
sessionId: sessionId.value
|
||||
}),
|
||||
() => ({
|
||||
enabled: isLoggedIn.value
|
||||
enabled: isLoggedIn.value && !trackInternallyOnly
|
||||
})
|
||||
)
|
||||
|
||||
@@ -378,53 +404,61 @@ export function useViewerUserActivityTracking(params: {
|
||||
}
|
||||
})
|
||||
|
||||
useViewerAnchoredPoints<UserActivityModel, Partial<{ smoothTranslation: boolean }>>({
|
||||
parentEl,
|
||||
points: computed(() => Object.values(users.value)),
|
||||
pointLocationGetter: (user) => {
|
||||
const selection = user.state.ui.selection
|
||||
const selectionVector = selection
|
||||
? new Vector3(selection[0], selection[1], selection[2])
|
||||
: null
|
||||
if (parentEl) {
|
||||
useViewerAnchoredPoints<UserActivityModel, Partial<{ smoothTranslation: boolean }>>(
|
||||
{
|
||||
parentEl,
|
||||
points: computed(() => Object.values(users.value)),
|
||||
pointLocationGetter: (user) => {
|
||||
const selection = user.state.ui.selection
|
||||
const selectionVector = selection
|
||||
? new Vector3(selection[0], selection[1], selection[2])
|
||||
: null
|
||||
|
||||
function getPointInBetweenByPerc(
|
||||
pointA: Vector3,
|
||||
pointB: Vector3,
|
||||
percentage: number
|
||||
) {
|
||||
let dir = pointB.clone().sub(pointA)
|
||||
const len = dir.length()
|
||||
dir = dir.normalize().multiplyScalar(len * percentage)
|
||||
return pointA.clone().add(dir)
|
||||
}
|
||||
function getPointInBetweenByPerc(
|
||||
pointA: Vector3,
|
||||
pointB: Vector3,
|
||||
percentage: number
|
||||
) {
|
||||
let dir = pointB.clone().sub(pointA)
|
||||
const len = dir.length()
|
||||
dir = dir.normalize().multiplyScalar(len * percentage)
|
||||
return pointA.clone().add(dir)
|
||||
}
|
||||
|
||||
// If there is no selection location, return to a blended location based on the camera's target and location.
|
||||
// This ensures that rotation and zoom will have an effect on the users' cursors and create a lively environment.
|
||||
if (!selectionVector) {
|
||||
const camPos = user.state.ui.camera.position
|
||||
const camPosVector = new Vector3(camPos[0], camPos[1], camPos[2])
|
||||
// If there is no selection location, return to a blended location based on the camera's target and location.
|
||||
// This ensures that rotation and zoom will have an effect on the users' cursors and create a lively environment.
|
||||
if (!selectionVector) {
|
||||
const camPos = user.state.ui.camera.position
|
||||
const camPosVector = new Vector3(camPos[0], camPos[1], camPos[2])
|
||||
|
||||
const camTarget = user.state.ui.camera.target
|
||||
const camTargetVector = new Vector3(camTarget[0], camTarget[1], camTarget[2])
|
||||
const camTarget = user.state.ui.camera.target
|
||||
const camTargetVector = new Vector3(
|
||||
camTarget[0],
|
||||
camTarget[1],
|
||||
camTarget[2]
|
||||
)
|
||||
|
||||
return getPointInBetweenByPerc(camTargetVector, camPosVector, 0.2)
|
||||
}
|
||||
return getPointInBetweenByPerc(camTargetVector, camPosVector, 0.2)
|
||||
}
|
||||
|
||||
return selectionVector.clone()
|
||||
},
|
||||
updatePositionCallback: (user, result, options) => {
|
||||
user.isOccluded = result.isOccluded
|
||||
user.style = {
|
||||
...user.style,
|
||||
target: {
|
||||
...user.style.target,
|
||||
...result.style,
|
||||
transition: options?.smoothTranslation === false ? '' : 'all 0.1s ease'
|
||||
// opacity: user.isOccluded ? '0.5' : user.isStale ? '0.2' : '1.0' // note: handled in component via css
|
||||
return selectionVector.clone()
|
||||
},
|
||||
updatePositionCallback: (user, result, options) => {
|
||||
user.isOccluded = result.isOccluded
|
||||
user.style = {
|
||||
...user.style,
|
||||
target: {
|
||||
...user.style.target,
|
||||
...result.style,
|
||||
transition: options?.smoothTranslation === false ? '' : 'all 0.1s ease'
|
||||
// opacity: user.isOccluded ? '0.5' : user.isStale ? '0.2' : '1.0' // note: handled in component via css
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const hideStaleUsers = () => {
|
||||
if (!Object.values(users.value).length) return
|
||||
@@ -451,7 +485,7 @@ export function useViewerUserActivityTracking(params: {
|
||||
// Debounced disconnect function - 30 second delay
|
||||
const debouncedDisconnect = debounce(
|
||||
async () => {
|
||||
await sendUpdate.emitDisconnected()
|
||||
await processViewerDisconnected()
|
||||
},
|
||||
30 * 1000 // 30 seconds
|
||||
)
|
||||
@@ -463,13 +497,13 @@ export function useViewerUserActivityTracking(params: {
|
||||
} else {
|
||||
// Window regained focus - cancel any pending disconnect and emit viewing
|
||||
debouncedDisconnect.cancel()
|
||||
await sendUpdate.emitViewing()
|
||||
await processViewerViewing()
|
||||
}
|
||||
})
|
||||
const sendUpdateAndHideStaleUsers = () => {
|
||||
if (!focused.value) return
|
||||
hideStaleUsers()
|
||||
sendUpdate.emitViewing()
|
||||
processViewerViewing()
|
||||
}
|
||||
|
||||
useIntervalFn(sendUpdateAndHideStaleUsers, OWN_ACTIVITY_UPDATE_INTERVAL)
|
||||
@@ -484,22 +518,22 @@ export function useViewerUserActivityTracking(params: {
|
||||
doubleClickCallback: selectionCallback
|
||||
})
|
||||
|
||||
useViewerCameraControlEndTracker(() => sendUpdate.emitViewing())
|
||||
useViewerCameraControlEndTracker(() => processViewerViewing())
|
||||
|
||||
useOnBeforeWindowUnload(async () => {
|
||||
// Cancel any pending debounced disconnect since we're actually leaving
|
||||
debouncedDisconnect.cancel()
|
||||
await sendUpdate.emitDisconnected()
|
||||
await processViewerDisconnected()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
sendUpdate.emitViewing()
|
||||
processViewerViewing()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// Cancel any pending debounced disconnect
|
||||
debouncedDisconnect.cancel()
|
||||
sendUpdate.emitDisconnected()
|
||||
void processViewerDisconnected()
|
||||
})
|
||||
|
||||
const state = useInjectedViewerState()
|
||||
@@ -519,7 +553,7 @@ export function useViewerUserActivityTracking(params: {
|
||||
|
||||
watch(resourceIdString, (newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
sendUpdate.emitViewing()
|
||||
void processViewerViewing()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -38,9 +38,12 @@ const createSavedViewMutation = graphql(`
|
||||
export const useCollectNewSavedViewViewerData = () => {
|
||||
const {
|
||||
projectId,
|
||||
viewer: { instance: viewerInstance }
|
||||
viewer: { instance: viewerInstance },
|
||||
resources: {
|
||||
response: { concreteResourceIdString }
|
||||
}
|
||||
} = useInjectedViewerState()
|
||||
const { serialize, buildConcreteResourceIdString } = useStateSerialization()
|
||||
const { serialize } = useStateSerialization()
|
||||
|
||||
const collect = async (): Promise<
|
||||
Pick<
|
||||
@@ -51,7 +54,7 @@ export const useCollectNewSavedViewViewerData = () => {
|
||||
const screenshot = await viewerInstance.screenshot()
|
||||
return {
|
||||
projectId: projectId.value,
|
||||
resourceIdString: buildConcreteResourceIdString(),
|
||||
resourceIdString: concreteResourceIdString.value,
|
||||
viewerState: serialize({ concreteResourceIdString: true }),
|
||||
screenshot
|
||||
}
|
||||
@@ -82,40 +85,12 @@ export const useCreateSavedView = () => {
|
||||
) => {
|
||||
if (!userId.value) return
|
||||
|
||||
const result = await mutate(
|
||||
{
|
||||
input: {
|
||||
...input,
|
||||
...(await collect())
|
||||
}
|
||||
},
|
||||
{
|
||||
update: (cache, { data }) => {
|
||||
const res = data?.projectMutations.savedViewMutations.createView
|
||||
if (!res) return
|
||||
|
||||
// const viewId = res.id
|
||||
|
||||
// onNewGroupViewCacheUpdates({
|
||||
// cache,
|
||||
// viewId,
|
||||
// projectId: projectId.value,
|
||||
// ...(res.groupId
|
||||
// ? {
|
||||
// group: {
|
||||
// id: res.groupId,
|
||||
// resourceIds: res.group.resourceIds
|
||||
// }
|
||||
// }
|
||||
// : {
|
||||
// view: {
|
||||
// resourceIds: res.resourceIds
|
||||
// }
|
||||
// })
|
||||
// })
|
||||
}
|
||||
const result = await mutate({
|
||||
input: {
|
||||
...input,
|
||||
...(await collect())
|
||||
}
|
||||
).catch(convertThrowIntoFetchResult)
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
const res = result?.data?.projectMutations.savedViewMutations.createView
|
||||
if (!res?.id) {
|
||||
@@ -174,40 +149,12 @@ export const useDeleteSavedView = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await mutate(
|
||||
{
|
||||
input: {
|
||||
projectId,
|
||||
id
|
||||
}
|
||||
},
|
||||
{
|
||||
update: (cache, res) => {
|
||||
if (!res.data?.projectMutations.savedViewMutations.deleteView) return
|
||||
|
||||
// onGroupViewRemovalCacheUpdates({
|
||||
// cache,
|
||||
// viewId: id,
|
||||
// projectId,
|
||||
// ...(group.groupId
|
||||
// ? {
|
||||
// group: {
|
||||
// id: group.groupId,
|
||||
// resourceIds: group.resourceIds
|
||||
// }
|
||||
// }
|
||||
// : {
|
||||
// view: {
|
||||
// resourceIds: params.view.resourceIds
|
||||
// }
|
||||
// })
|
||||
// })
|
||||
|
||||
// // Remove the view from the cache
|
||||
// cache.evict({ id: getCacheId('SavedView', id) })
|
||||
}
|
||||
const result = await mutate({
|
||||
input: {
|
||||
projectId,
|
||||
id
|
||||
}
|
||||
).catch(convertThrowIntoFetchResult)
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
const res = result?.data?.projectMutations.savedViewMutations.deleteView
|
||||
if (res) {
|
||||
@@ -294,143 +241,7 @@ export const useUpdateSavedView = () => {
|
||||
) => {
|
||||
if (!isLoggedIn.value) return
|
||||
const { input } = params
|
||||
const oldGroup = params.view.group
|
||||
|
||||
const result = await mutate(
|
||||
{ input },
|
||||
{
|
||||
update: (cache, res) => {
|
||||
const update = res.data?.projectMutations.savedViewMutations.updateView
|
||||
if (!update) return
|
||||
|
||||
const newGroup = update.group
|
||||
const groupChanged = oldGroup.id !== newGroup.id
|
||||
|
||||
if (groupChanged) {
|
||||
// Clean up old group
|
||||
// onGroupViewRemovalCacheUpdates({
|
||||
// cache,
|
||||
// viewId: params.view.id,
|
||||
// projectId: params.view.projectId,
|
||||
// ...(oldGroup.groupId
|
||||
// ? {
|
||||
// group: {
|
||||
// id: oldGroup.groupId,
|
||||
// resourceIds: oldGroup.resourceIds
|
||||
// }
|
||||
// }
|
||||
// : {
|
||||
// view: {
|
||||
// resourceIds: params.view.resourceIds
|
||||
// }
|
||||
// })
|
||||
// })
|
||||
// // Update new group
|
||||
// onNewGroupViewCacheUpdates({
|
||||
// cache,
|
||||
// viewId: update.id,
|
||||
// projectId: params.view.projectId,
|
||||
// ...(newGroup.groupId
|
||||
// ? {
|
||||
// group: {
|
||||
// id: newGroup.groupId,
|
||||
// resourceIds: newGroup.resourceIds
|
||||
// }
|
||||
// }
|
||||
// : {
|
||||
// view: {
|
||||
// resourceIds: params.view.resourceIds
|
||||
// }
|
||||
// })
|
||||
// })
|
||||
}
|
||||
|
||||
// // If set to home view, clear home view on all other views related to the same resourceIdString
|
||||
// if (update.isHomeView && update.groupResourceIds.length === 1) {
|
||||
// const allSavedViewKeys = getCachedObjectKeys(cache, 'SavedView')
|
||||
// const modelId = update.groupResourceIds[0]
|
||||
|
||||
// for (const savedViewKey of allSavedViewKeys) {
|
||||
// modifyObjectField(
|
||||
// cache,
|
||||
// savedViewKey,
|
||||
// 'isHomeView',
|
||||
// ({ value: isHomeView, helpers: { readObject } }) => {
|
||||
// const view = readObject()
|
||||
// const groupIds = view.groupResourceIds
|
||||
// const viewId = view.id
|
||||
// const projectId = view.projectId
|
||||
// if (viewId === update.id) return
|
||||
// if (update.projectId !== projectId) return
|
||||
|
||||
// if (isHomeView && groupIds?.length === 1 && groupIds[0] === modelId) {
|
||||
// return false
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
|
||||
// // If position changed, recalculate it according to sort dir in vars
|
||||
// if (input.position) {
|
||||
// // Go through all SavedViewGroup.views, where this view exists and update array position
|
||||
// iterateObjectField(
|
||||
// cache,
|
||||
// getCacheId('Project', params.view.projectId),
|
||||
// 'savedViewGroups',
|
||||
// ({ value }) => {
|
||||
// const items = value.items
|
||||
// if (!items) return
|
||||
|
||||
// items.forEach((groupRef) => {
|
||||
// const parsed = parseObjectReference(groupRef)
|
||||
// modifyObjectField(
|
||||
// cache,
|
||||
// getCacheId('SavedViewGroup', parsed.id),
|
||||
// 'views',
|
||||
// ({ helpers: { createUpdatedValue, readField }, variables }) => {
|
||||
// const sortDir =
|
||||
// variables.input.sortDirection || SortDirection.Desc
|
||||
// const sortBy = (variables.input.sortBy || 'position') as
|
||||
// | 'position'
|
||||
// | 'updatedAt'
|
||||
|
||||
// return createUpdatedValue(({ update }) => {
|
||||
// update('items', (items) => {
|
||||
// const newItems = items.slice().sort((a, b) => {
|
||||
// const process = (
|
||||
// ref: CacheObjectReference<'SavedView'>
|
||||
// ) => {
|
||||
// const val = readField(ref, sortBy)
|
||||
// if (!val) return -1
|
||||
|
||||
// if (sortBy === 'updatedAt') {
|
||||
// return new Date(val).getTime()
|
||||
// }
|
||||
// return val as number
|
||||
// }
|
||||
|
||||
// const aVal = process(a)
|
||||
// const bVal = process(b)
|
||||
|
||||
// if (aVal < bVal)
|
||||
// return sortDir === SortDirection.Asc ? -1 : 1
|
||||
// if (aVal > bVal)
|
||||
// return sortDir === SortDirection.Asc ? 1 : -1
|
||||
// return 0
|
||||
// })
|
||||
// return newItems
|
||||
// })
|
||||
// })
|
||||
// }
|
||||
// )
|
||||
// })
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
}
|
||||
}
|
||||
).catch(convertThrowIntoFetchResult)
|
||||
const result = await mutate({ input }).catch(convertThrowIntoFetchResult)
|
||||
|
||||
const res = result?.data?.projectMutations.savedViewMutations.updateView
|
||||
if (!options?.skipToast) {
|
||||
@@ -492,40 +303,7 @@ export const useCreateSavedViewGroup = () => {
|
||||
return async (input: CreateSavedViewGroupInput) => {
|
||||
if (!isLoggedIn.value) return
|
||||
|
||||
const ret = await mutate(
|
||||
{ input },
|
||||
{
|
||||
update: (cache, res) => {
|
||||
const group = res.data?.projectMutations.savedViewMutations.createGroup
|
||||
if (!group?.id) return
|
||||
|
||||
// // Project.savedViewGroups +1
|
||||
// modifyObjectField(
|
||||
// cache,
|
||||
// getCacheId('Project', input.projectId),
|
||||
// 'savedViewGroups',
|
||||
// ({ helpers: { createUpdatedValue, fromRef, ref } }) =>
|
||||
// createUpdatedValue(({ update }) => {
|
||||
// update('totalCount', (totalCount) => totalCount + 1)
|
||||
// update('items', (items) => {
|
||||
// const newItems = items.slice()
|
||||
|
||||
// // default comes first, then new group
|
||||
// const defaultIdx = newItems.findIndex((i) =>
|
||||
// isUngroupedGroup(fromRef(i).id)
|
||||
// )
|
||||
|
||||
// newItems.splice(defaultIdx + 1, 0, ref('SavedViewGroup', group.id))
|
||||
|
||||
// return newItems
|
||||
// })
|
||||
// }),
|
||||
// { autoEvictFiltered: filterKeys }
|
||||
// )
|
||||
}
|
||||
}
|
||||
).catch(convertThrowIntoFetchResult)
|
||||
|
||||
const ret = await mutate({ input }).catch(convertThrowIntoFetchResult)
|
||||
const res = ret?.data?.projectMutations.savedViewMutations.createGroup
|
||||
if (res?.id) {
|
||||
triggerNotification({
|
||||
@@ -583,28 +361,9 @@ export const useDeleteSavedViewGroup = () => {
|
||||
const projectId = group.projectId
|
||||
if (!groupId || group.isUngroupedViewsGroup) return // not real group
|
||||
|
||||
const result = await mutate(
|
||||
{ input: { groupId, projectId } },
|
||||
{
|
||||
update: (cache, res) => {
|
||||
const deleteSuccessful =
|
||||
res.data?.projectMutations.savedViewMutations.deleteGroup
|
||||
if (!deleteSuccessful) return
|
||||
|
||||
// // Views can be moved around, just easier to evict Project.savedViewGroups
|
||||
// modifyObjectField(
|
||||
// cache,
|
||||
// getCacheId('Project', projectId),
|
||||
// 'savedViewGroups',
|
||||
// ({ helpers: { evict } }) => evict()
|
||||
// )
|
||||
// // Evict
|
||||
// cache.evict({
|
||||
// id: getCacheId('SavedViewGroup', groupId)
|
||||
// })
|
||||
}
|
||||
}
|
||||
).catch(convertThrowIntoFetchResult)
|
||||
const result = await mutate({ input: { groupId, projectId } }).catch(
|
||||
convertThrowIntoFetchResult
|
||||
)
|
||||
|
||||
const res = result?.data?.projectMutations.savedViewMutations.deleteGroup
|
||||
if (res) {
|
||||
|
||||
@@ -24,16 +24,15 @@ export const useViewerSavedViewIntegration = () => {
|
||||
},
|
||||
response: { savedView }
|
||||
},
|
||||
urlHashState: { savedView: urlHashStateSavedViewSettings }
|
||||
urlHashState: { savedView: urlHashStateSavedViewSettings },
|
||||
ui: {
|
||||
savedViews: { savedViewStateId }
|
||||
}
|
||||
} = useInjectedViewerState()
|
||||
const applyState = useApplySerializedState()
|
||||
const { serializedStateId } = useViewerRealtimeActivityTracker()
|
||||
const { on, emit } = useEventBus()
|
||||
|
||||
// Saved View ID will be unset, once the user does anything to the viewer that
|
||||
// changes it from the saved view
|
||||
const savedViewStateId = ref<string>()
|
||||
|
||||
const validState = (state: unknown) => (isSerializedViewerState(state) ? state : null)
|
||||
|
||||
const apply = async () => {
|
||||
@@ -131,6 +130,7 @@ export type SavedViewsUIState = ReturnType<typeof useBuildSavedViewsUIState>
|
||||
|
||||
export const useBuildSavedViewsUIState = () => {
|
||||
const openedGroupState = ref<Map<string, true>>(new Map())
|
||||
const savedViewStateId = ref<string>()
|
||||
|
||||
onUnmounted(() => {
|
||||
openedGroupState.value = new Map()
|
||||
@@ -140,7 +140,12 @@ export const useBuildSavedViewsUIState = () => {
|
||||
/**
|
||||
* Groups that should currently be expanded/open
|
||||
*/
|
||||
openedGroupState
|
||||
openedGroupState,
|
||||
/**
|
||||
* A kind of a "viewer snapshot" ID associated w/ the saved view being loaded. Helps track
|
||||
* if user has changed the view since loading the saved view
|
||||
*/
|
||||
savedViewStateId
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -296,13 +296,25 @@ export const useOnProjectSavedViewGroupsUpdated = (params: {
|
||||
const group = event.savedViewGroup
|
||||
|
||||
if (event.type === ProjectSavedViewsUpdatedMessageType.Deleted) {
|
||||
// Views can be moved around, just easier to evict Project.savedViewGroups
|
||||
// Views can be moved around, just easier to evict Project groups
|
||||
modifyObjectField(
|
||||
cache,
|
||||
getCacheId('Project', unref(projectId)),
|
||||
'savedViewGroups',
|
||||
({ helpers: { evict } }) => evict()
|
||||
)
|
||||
|
||||
// Evict all 'default' groups - items will fall in there
|
||||
modifyObjectField(
|
||||
cache,
|
||||
getCacheId('Project', unref(projectId)),
|
||||
'savedViewGroup',
|
||||
({ helpers: { evict, fromRef }, value }) => {
|
||||
const { id } = fromRef(value)
|
||||
if (isUngroupedGroup(id)) return evict()
|
||||
}
|
||||
)
|
||||
|
||||
// Evict
|
||||
cache.evict({
|
||||
id: getCacheId('SavedViewGroup', id)
|
||||
|
||||
@@ -29,6 +29,9 @@ graphql(`
|
||||
canEditDescription {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
canSetAsHomeView {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -47,12 +50,11 @@ export const useSavedViewValidationHelpers = (params: {
|
||||
}
|
||||
} = useInjectedViewerState()
|
||||
|
||||
const canUpdate = computed(() => params.view.value?.permissions.canUpdate)
|
||||
const canMove = computed(() => params.view.value?.permissions.canMove)
|
||||
const canEditTitle = computed(() => params.view.value?.permissions.canEditTitle)
|
||||
const canEditDescription = computed(
|
||||
() => params.view.value?.permissions.canEditDescription
|
||||
)
|
||||
const permissions = computed(() => params.view.value?.permissions)
|
||||
const canUpdate = computed(() => permissions.value?.canUpdate)
|
||||
const canMove = computed(() => permissions.value?.canMove)
|
||||
const canEditTitle = computed(() => permissions.value?.canEditTitle)
|
||||
const canEditDescription = computed(() => permissions.value?.canEditDescription)
|
||||
|
||||
const canOpenEditDialog = computed(
|
||||
(): FullPermissionCheckResultFragment | undefined => {
|
||||
@@ -104,10 +106,10 @@ export const useSavedViewValidationHelpers = (params: {
|
||||
|
||||
const canSetHomeView = computed(
|
||||
(): { authorized: boolean; message: Optional<string> } => {
|
||||
if (!canUpdate.value?.authorized || isLoading.value) {
|
||||
if (!permissions.value?.canSetAsHomeView.authorized || isLoading.value) {
|
||||
return {
|
||||
authorized: false,
|
||||
message: canUpdate.value?.errorMessage || undefined
|
||||
message: permissions.value?.canSetAsHomeView.errorMessage || undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,13 +120,6 @@ export const useSavedViewValidationHelpers = (params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (isOnlyVisibleToMe.value) {
|
||||
return {
|
||||
authorized: false,
|
||||
message: 'A view must be shared to be set as home view'
|
||||
}
|
||||
}
|
||||
|
||||
return { authorized: true, message: undefined }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -37,26 +37,6 @@ export function useStateSerialization() {
|
||||
const dataStore = useFilteringDataStore()
|
||||
const { box3ToSectionBoxData } = useSectionBoxUtilities()
|
||||
|
||||
/**
|
||||
* We don't want to save a comment w/ implicit identifiers like ones that only have a model ID or a folder prefix, because
|
||||
* those can resolve to completely different versions/objects as time goes on
|
||||
*/
|
||||
const buildConcreteResourceIdString = () => {
|
||||
const resources = state.resources.response.resourceItems
|
||||
const builder = SpeckleViewer.ViewerRoute.resourceBuilder()
|
||||
|
||||
for (const resource of resources.value) {
|
||||
if (resource.modelId && resource.versionId) {
|
||||
builder.addModel(resource.modelId, resource.versionId)
|
||||
} else {
|
||||
builder.addObject(resource.objectId)
|
||||
}
|
||||
}
|
||||
|
||||
const finalString = builder.toString()
|
||||
return finalString || state.resources.request.resourceIdString.value
|
||||
}
|
||||
|
||||
const serialize = (
|
||||
options?: Partial<{
|
||||
/**
|
||||
@@ -89,7 +69,7 @@ export function useStateSerialization() {
|
||||
resources: {
|
||||
request: {
|
||||
resourceIdString: concreteResourceIdString
|
||||
? buildConcreteResourceIdString()
|
||||
? state.resources.response.concreteResourceIdString.value
|
||||
: state.resources.request.resourceIdString.value,
|
||||
threadFilters: { ...state.resources.request.threadFilters.value }
|
||||
}
|
||||
@@ -159,7 +139,7 @@ export function useStateSerialization() {
|
||||
return ret
|
||||
}
|
||||
|
||||
return { serialize, buildConcreteResourceIdString }
|
||||
return { serialize }
|
||||
}
|
||||
|
||||
export enum StateApplyMode {
|
||||
|
||||
@@ -295,6 +295,11 @@ export type InjectableViewerState = Readonly<{
|
||||
* but if none of them actually exist and are loaded then I wouldn't count that as a federated view.
|
||||
*/
|
||||
isFederatedView: ComputedRef<boolean>
|
||||
/**
|
||||
* We don't want to save a comment or view w/ implicit identifiers like ones that only have a model ID or a folder prefix, because
|
||||
* those can resolve to completely different versions/objects as time goes on
|
||||
*/
|
||||
concreteResourceIdString: ComputedRef<string>
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -643,6 +648,7 @@ function setupResponseResourceItems(
|
||||
| 'isFederatedView'
|
||||
| 'resourceItemsExtended'
|
||||
| 'resourceItemsIds'
|
||||
| 'concreteResourceIdString'
|
||||
> {
|
||||
const globalError = useError()
|
||||
const {
|
||||
@@ -819,6 +825,20 @@ function setupResponseResourceItems(
|
||||
})
|
||||
|
||||
const isFederatedView = computed(() => resourceItems.value.length > 1)
|
||||
const concreteResourceIdString = computed(() => {
|
||||
const builder = resourceBuilder()
|
||||
|
||||
for (const resource of resourceItems.value) {
|
||||
if (resource.modelId && resource.versionId) {
|
||||
builder.addModel(resource.modelId, resource.versionId)
|
||||
} else {
|
||||
builder.addObject(resource.objectId)
|
||||
}
|
||||
}
|
||||
|
||||
const finalString = builder.toString()
|
||||
return finalString || resourceIdString.value
|
||||
})
|
||||
|
||||
return {
|
||||
resourceItemsExtended,
|
||||
@@ -827,7 +847,8 @@ function setupResponseResourceItems(
|
||||
resourceItemsQueryVariables: computed(() => resourceItemsQueryVariables.value),
|
||||
resourceItemsLoaded,
|
||||
savedView,
|
||||
isFederatedView
|
||||
isFederatedView,
|
||||
concreteResourceIdString
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -262,7 +262,13 @@ export function useSelectionUtilities() {
|
||||
const addToSelectionFromObjectIds = (objectIds: string[]) => {
|
||||
const originalObjects = selectedObjects.value.slice()
|
||||
setSelectionFromObjectIds(objectIds)
|
||||
selectedObjects.value = [...originalObjects, ...selectedObjects.value]
|
||||
|
||||
// Filter out duplicates by checking if objects with the same ID already exist
|
||||
const newObjects = selectedObjects.value.filter(
|
||||
(newObj) => !originalObjects.some((existingObj) => existingObj.id === newObj.id)
|
||||
)
|
||||
|
||||
selectedObjects.value = [...originalObjects, ...newObjects]
|
||||
}
|
||||
|
||||
const removeFromSelectionObjectIds = (objectIds: string[]) => {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
|
||||
export const workspaceFeatureEnabledCheckQuery = graphql(`
|
||||
query Workspace($featureName: WorkspaceFeatureName!, $workspaceId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
hasAccessToFeature(featureName: $featureName)
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const workspaceAccessCheckQuery = graphql(`
|
||||
query WorkspaceAccessCheck($slug: String!) {
|
||||
workspaceBySlug(slug: $slug) {
|
||||
|
||||
@@ -104,9 +104,6 @@ graphql(`
|
||||
canReadSettings {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
canReadAccIntegrationSettings {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
canUpdate {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
@@ -182,9 +179,7 @@ const modelCount = computed(() => project.value?.modelCount.totalCount)
|
||||
const commentCount = computed(() => project.value?.commentThreadCount.totalCount)
|
||||
|
||||
const canReadSettings = computed(() => project.value?.permissions.canReadSettings)
|
||||
const canReadAccIntegrationSettings = computed(
|
||||
() => project.value?.permissions.canReadAccIntegrationSettings
|
||||
)
|
||||
|
||||
const canUpdate = computed(() => project.value?.permissions.canUpdate)
|
||||
const hasRole = computed(() => project.value?.role)
|
||||
const teamUsers = computed(() => project.value?.team.map((t) => t.user) || [])
|
||||
@@ -233,7 +228,6 @@ const onInviteAccepted = async (params: { accepted: boolean }) => {
|
||||
const isOwner = computed(() => project.value?.role === Roles.Stream.Owner)
|
||||
const isAutomateEnabled = useIsAutomateModuleEnabled()
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
const isAccEnabled = useIsAccModuleEnabled()
|
||||
|
||||
const pageTabItems = computed((): LayoutPageTabItem[] => {
|
||||
const items: LayoutPageTabItem[] = [
|
||||
@@ -260,13 +254,6 @@ const pageTabItems = computed((): LayoutPageTabItem[] => {
|
||||
})
|
||||
}
|
||||
|
||||
if (isAccEnabled.value && canReadAccIntegrationSettings.value?.authorized) {
|
||||
items.push({
|
||||
title: 'ACC',
|
||||
id: 'acc'
|
||||
})
|
||||
}
|
||||
|
||||
if (canReadSettings.value?.authorized) {
|
||||
items.push({
|
||||
title: 'Collaborators',
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<ProjectPageAccTab :project-id="projectId" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ProjectPageProjectFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
const route = useRoute()
|
||||
|
||||
const attrs = useAttrs() as {
|
||||
project: ProjectPageProjectFragment
|
||||
}
|
||||
|
||||
const projectName = computed(() =>
|
||||
attrs.project.name.length ? attrs.project.name : ''
|
||||
)
|
||||
|
||||
const projectId = computed(() => route.params.id as string)
|
||||
|
||||
useHead({
|
||||
title: `Acc | ${projectName.value}`
|
||||
})
|
||||
</script>
|
||||
@@ -14,7 +14,8 @@ import { LayoutTabsVertical, type LayoutPageTabItem } from '@speckle/ui-componen
|
||||
import {
|
||||
projectSettingsRoute,
|
||||
projectWebhooksRoute,
|
||||
projectTokensRoute
|
||||
projectTokensRoute,
|
||||
projectIntegrationsRoute
|
||||
} from '~~/lib/common/helpers/route'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import type { ProjectPageSettingsTab_ProjectFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
@@ -34,6 +35,9 @@ graphql(`
|
||||
canReadEmbedTokens {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
canReadAccIntegrationSettings {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -49,6 +53,10 @@ const canReadWebhooks = computed(() => attrs.project.permissions.canReadWebhooks
|
||||
const projectName = computed(() =>
|
||||
attrs.project.name.length ? attrs.project.name : ''
|
||||
)
|
||||
const isAccEnabled = useIsAccModuleEnabled() // check permission over project
|
||||
const canReadAccIntegrationSettings = computed(
|
||||
() => attrs.project.permissions.canReadAccIntegrationSettings
|
||||
)
|
||||
|
||||
useHead({
|
||||
title: `Settings | ${projectName.value}`
|
||||
@@ -70,6 +78,12 @@ const settingsTabItems = computed((): LayoutPageTabItem[] => [
|
||||
id: 'tokens',
|
||||
disabled: !canReadEmbedTokens.value.authorized,
|
||||
disabledMessage: canReadEmbedTokens.value.message
|
||||
},
|
||||
{
|
||||
title: 'Integrations',
|
||||
id: 'integrations',
|
||||
disabled: isAccEnabled && !canReadAccIntegrationSettings.value.authorized,
|
||||
disabledMessage: canReadAccIntegrationSettings.value.message
|
||||
}
|
||||
])
|
||||
|
||||
@@ -80,6 +94,7 @@ const activeSettingsPageTab = computed({
|
||||
const path = route.path
|
||||
if (path.includes('/settings/webhooks')) return settingsTabItems.value[1]
|
||||
if (path.includes('/settings/tokens')) return settingsTabItems.value[2]
|
||||
if (path.includes('/settings/integrations')) return settingsTabItems.value[3]
|
||||
return settingsTabItems.value[0]
|
||||
},
|
||||
set: (val: LayoutPageTabItem) => {
|
||||
@@ -90,6 +105,9 @@ const activeSettingsPageTab = computed({
|
||||
case 'tokens':
|
||||
router.push(projectTokensRoute(projectId.value))
|
||||
break
|
||||
case 'integrations':
|
||||
router.push(projectIntegrationsRoute(projectId.value))
|
||||
break
|
||||
case 'general':
|
||||
default:
|
||||
router.push(projectSettingsRoute(projectId.value))
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<ProjectPageSettingsAccTab :project-id="projectId" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.id as string)
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<section>
|
||||
<SettingsSectionHeader
|
||||
title="Integrations"
|
||||
text="Connect your workspace to authorized applications."
|
||||
/>
|
||||
<IntegrationsAccCard
|
||||
:workspace-id="workspaceResult?.workspaceBySlug.id || ''"
|
||||
:workspace-slug="routeSlug"
|
||||
></IntegrationsAccCard>
|
||||
<!-- <div v-for="integration in integrations" :key="integration.cookieKey">
|
||||
<IntegrationsCard
|
||||
:integration="integration"
|
||||
@handle-c-t-a="handleCTA(integration)"
|
||||
></IntegrationsCard>
|
||||
</div> -->
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { settingsWorkspaceGeneralQuery } from '~/lib/settings/graphql/queries'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const routeSlug = computed(() => (route.params.slug as string) || '')
|
||||
const { result: workspaceResult } = useQuery(settingsWorkspaceGeneralQuery, () => ({
|
||||
slug: routeSlug.value
|
||||
}))
|
||||
</script>
|
||||
@@ -342,7 +342,6 @@ export default defineNuxtPlugin(async (nuxtApp) => {
|
||||
{
|
||||
err: error,
|
||||
info,
|
||||
isAppError: true,
|
||||
vm: _vm?.$options.name,
|
||||
errString: errorToString(error),
|
||||
vueErrorHandler: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isObject } from 'lodash-es'
|
||||
import mitt from 'mitt'
|
||||
import type {
|
||||
EventBusKeyPayloadMap,
|
||||
@@ -19,7 +20,7 @@ export default defineNuxtPlugin(() => {
|
||||
handler?: (event: EventBusKeyPayloadMap[T]) => void
|
||||
) => emitter.off(key, handler),
|
||||
emit: <T extends EventBusKeys>(key: T, payload: EventBusKeyPayloadMap[T]) =>
|
||||
emitter.emit(key, payload)
|
||||
emitter.emit(key, isObject(payload) ? toRaw(payload) : payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
extend type WorkspaceIntegrations {
|
||||
acc(token: String): AccIntegration
|
||||
}
|
||||
|
||||
type AccIntegration {
|
||||
hub(id: String!): AccHub!
|
||||
hubs: AccHubCollection!
|
||||
project(hubId: String!, projectId: String!): AccProject!
|
||||
folder(projectId: String!, folderId: String!): AccFolder!
|
||||
item(projectId: String!, itemId: String!): AccItem!
|
||||
}
|
||||
|
||||
type AccHub {
|
||||
id: ID!
|
||||
name: String!
|
||||
project(id: ID!): AccProject!
|
||||
projects: AccProjectCollection!
|
||||
}
|
||||
|
||||
type AccHubCollection {
|
||||
items: [AccHub!]!
|
||||
cursor: String
|
||||
}
|
||||
|
||||
type AccProject {
|
||||
id: ID!
|
||||
name: String!
|
||||
# hub: AccHub!
|
||||
folder(id: String!): AccFolder!
|
||||
rootFolder: AccFolder!
|
||||
}
|
||||
|
||||
type AccProjectCollection {
|
||||
items: [AccProject!]!
|
||||
cursor: String
|
||||
}
|
||||
|
||||
type AccFolder {
|
||||
id: ID!
|
||||
name: String!
|
||||
# project: AccProject!
|
||||
contents: AccItemCollection!
|
||||
children: AccFolderCollection!
|
||||
}
|
||||
|
||||
type AccFolderCollection {
|
||||
items: [AccFolder!]!
|
||||
cursor: String
|
||||
}
|
||||
|
||||
type AccItem {
|
||||
"""
|
||||
lineage urn
|
||||
"""
|
||||
id: ID!
|
||||
name: String!
|
||||
# version(version: Int): AccItemVersion!
|
||||
latestVersion: AccItemVersion!
|
||||
}
|
||||
|
||||
type AccItemCollection {
|
||||
items: [AccItem!]!
|
||||
cursor: String
|
||||
}
|
||||
|
||||
type AccItemVersion {
|
||||
"""
|
||||
version urn
|
||||
"""
|
||||
id: ID!
|
||||
name: String!
|
||||
versionNumber: Int!
|
||||
fileType: String
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
extend type Workspace {
|
||||
integrations: WorkspaceIntegrations
|
||||
}
|
||||
|
||||
type WorkspaceIntegrations
|
||||
@@ -0,0 +1,3 @@
|
||||
extend type ProjectPermissionChecks {
|
||||
canReadAccIntegrationSettings: PermissionCheckResult!
|
||||
}
|
||||
+7
-3
@@ -3,6 +3,10 @@ extend type Project {
|
||||
accSyncItem(id: String!): AccSyncItem!
|
||||
}
|
||||
|
||||
extend type Model {
|
||||
accSyncItem: AccSyncItem
|
||||
}
|
||||
|
||||
type AccSyncItemCollection {
|
||||
items: [AccSyncItem!]!
|
||||
totalCount: Int!
|
||||
@@ -11,8 +15,9 @@ type AccSyncItemCollection {
|
||||
|
||||
type AccSyncItem {
|
||||
id: ID!
|
||||
projectId: String!
|
||||
modelId: String!
|
||||
project: Project!
|
||||
model: Model
|
||||
author: LimitedUser
|
||||
accRegion: String!
|
||||
accHubId: String!
|
||||
accProjectId: String!
|
||||
@@ -25,7 +30,6 @@ type AccSyncItem {
|
||||
accFileViewName: String
|
||||
accWebhookId: String
|
||||
status: AccSyncItemStatus!
|
||||
author: LimitedUser
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
@@ -10,7 +10,6 @@ type ProjectPermissionChecks {
|
||||
canDelete: PermissionCheckResult!
|
||||
canUpdateAllowPublicComments: PermissionCheckResult!
|
||||
canReadSettings: PermissionCheckResult!
|
||||
canReadAccIntegrationSettings: PermissionCheckResult!
|
||||
canReadWebhooks: PermissionCheckResult!
|
||||
canLeave: PermissionCheckResult!
|
||||
canRequestRender: PermissionCheckResult!
|
||||
|
||||
@@ -11,6 +11,7 @@ type SavedViewPermissionChecks {
|
||||
canMove: PermissionCheckResult!
|
||||
canEditTitle: PermissionCheckResult!
|
||||
canEditDescription: PermissionCheckResult!
|
||||
canSetAsHomeView: PermissionCheckResult!
|
||||
}
|
||||
|
||||
extend type SavedView {
|
||||
|
||||
@@ -698,8 +698,12 @@ enum WorkspaceFeatureFlagName {
|
||||
presentations
|
||||
}
|
||||
|
||||
"""
|
||||
Either the ID or slug must be set
|
||||
"""
|
||||
input AdminAccessToWorkspaceFeatureInput {
|
||||
workspaceId: ID!
|
||||
workspaceId: ID
|
||||
workspaceSlug: String
|
||||
featureFlagName: WorkspaceFeatureFlagName!
|
||||
}
|
||||
|
||||
|
||||
@@ -186,6 +186,12 @@ const config: CodegenConfig = {
|
||||
'@/modules/core/helpers/graphTypes#RootPermissionChecksGraphQLReturn',
|
||||
WorkspacePermissionChecks:
|
||||
'@/modules/workspacesCore/helpers/graphTypes#WorkspacePermissionChecksGraphQLReturn',
|
||||
WorkspaceIntegrations:
|
||||
'@/modules/acc/helpers/graphTypes#WorkspaceIntegrationsGraphQLReturn',
|
||||
AccIntegration:
|
||||
'@/modules/acc/helpers/graphTypes#AccIntegrationGraphQLReturn',
|
||||
AccFolder: '@/modules/acc/helpers/graphTypes#AccFolderGraphQLReturn',
|
||||
AccItem: '@/modules/acc/helpers/graphTypes#AccItemGraphQLReturn',
|
||||
AccSyncItem: '@/modules/acc/helpers/graphTypes#AccSyncItemGraphQLReturn',
|
||||
AccSyncItemMutations:
|
||||
'@/modules/acc/helpers/graphTypes#AccSyncItemMutationsGraphQLReturn',
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
import type { AccTokens } from '@speckle/shared/acc'
|
||||
import type { AccRegion } from '@/modules/acc/domain/constants'
|
||||
import { AccRegions } from '@/modules/acc/domain/constants'
|
||||
import type { ModelDerivativeServiceDesignManifest } from '@/modules/acc/domain/types'
|
||||
import {
|
||||
getAutodeskIntegrationClientId,
|
||||
getAutodeskIntegrationClientSecret
|
||||
} from '@/modules/shared/helpers/envHelper'
|
||||
import { logger } from '@/observability/logging'
|
||||
import { isObjectLike } from 'lodash-es'
|
||||
import { z } from 'zod'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const invokeJsonRequest = async <T>(params: {
|
||||
url: string
|
||||
token: string
|
||||
method?: RequestInit['method']
|
||||
body?: URLSearchParams | Record<string, unknown>
|
||||
headers?: Record<string, string>
|
||||
}) => {
|
||||
const { url, method = 'get', body, headers = {}, token } = params
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type':
|
||||
body instanceof URLSearchParams
|
||||
? 'application/x-www-form-urlencoded'
|
||||
: 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...headers
|
||||
},
|
||||
body:
|
||||
body && body instanceof URLSearchParams
|
||||
? body
|
||||
: isObjectLike(body)
|
||||
? JSON.stringify(body)
|
||||
: undefined
|
||||
})
|
||||
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
interface BuildAuthorizeUrlOptions {
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
codeChallenge: string
|
||||
scopes: string[]
|
||||
}
|
||||
|
||||
interface ExchangeCodeOptions {
|
||||
code: string
|
||||
codeVerifier: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
redirectUri: string
|
||||
}
|
||||
|
||||
const AccTokens = z.object({
|
||||
access_token: z.string(),
|
||||
refresh_token: z.string(),
|
||||
token_type: z.string(),
|
||||
id_token: z.string(),
|
||||
expires_in: z.number()
|
||||
})
|
||||
|
||||
export const generateCodeVerifier = () => {
|
||||
const codeVerifier = crypto.randomBytes(32).toString('base64url')
|
||||
const codeChallenge = crypto
|
||||
.createHash('sha256')
|
||||
.update(codeVerifier)
|
||||
.digest('base64url')
|
||||
return { codeVerifier, codeChallenge }
|
||||
}
|
||||
|
||||
export const buildAuthorizeUrl = ({
|
||||
clientId,
|
||||
redirectUri,
|
||||
codeChallenge,
|
||||
scopes
|
||||
}: BuildAuthorizeUrlOptions) => {
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: scopes.join(' '),
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256'
|
||||
})
|
||||
|
||||
return `https://developer.api.autodesk.com/authentication/v2/authorize?${params.toString()}`
|
||||
}
|
||||
|
||||
export const exchangeCodeForTokens = async ({
|
||||
code,
|
||||
codeVerifier,
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri
|
||||
}: ExchangeCodeOptions): Promise<AccTokens> => {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
code,
|
||||
code_verifier: codeVerifier
|
||||
})
|
||||
|
||||
const response = await fetch(
|
||||
'https://developer.api.autodesk.com/authentication/v2/token',
|
||||
{
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return AccTokens.parse(data)
|
||||
}
|
||||
|
||||
export const exchangeRefreshTokenForTokens = async (args: {
|
||||
refresh_token: string
|
||||
}): Promise<AccTokens> => {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: getAutodeskIntegrationClientId(),
|
||||
client_secret: getAutodeskIntegrationClientSecret(),
|
||||
refresh_token: args.refresh_token
|
||||
})
|
||||
|
||||
const response = await fetch(
|
||||
'https://developer.api.autodesk.com/authentication/v2/token',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return AccTokens.parse(data)
|
||||
}
|
||||
|
||||
type AutodeskIntegrationTokenData = {
|
||||
access_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a valid token for server-side operations as our custom integration
|
||||
*/
|
||||
export const getToken = async (): Promise<AutodeskIntegrationTokenData> => {
|
||||
const clientId = getAutodeskIntegrationClientId()
|
||||
const clientSecret = getAutodeskIntegrationClientSecret()
|
||||
|
||||
const token = Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64')
|
||||
|
||||
const response = await fetch(
|
||||
'https://developer.api.autodesk.com/authentication/v2/token',
|
||||
{
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
scope: 'data:read account:read viewables:read'
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Basic ${token}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return z
|
||||
.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.string(),
|
||||
expires_in: z.number()
|
||||
})
|
||||
.parse(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base Autodesk API endpoint for a given region.
|
||||
* NOTE: This has been true for all endpoints used so far but should be validated for any new ones!
|
||||
*/
|
||||
const getRegionUrl = (region: AccRegion): string => {
|
||||
switch (region) {
|
||||
case AccRegions.EMEA:
|
||||
return 'https://developer.api.autodesk.com/modelderivative/v2/regions/eu'
|
||||
default:
|
||||
return 'https://developer.api.autodesk.com/modelderivative/v2'
|
||||
}
|
||||
}
|
||||
|
||||
const getApiUrl = (path: string, region: AccRegion): string => {
|
||||
return `${getRegionUrl(region)}${path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a urn string in the modified url-safe base64 format expected by the Autodesk API
|
||||
*/
|
||||
const encodeUrn = (urn: string): string => {
|
||||
return btoa(urn).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
|
||||
}
|
||||
|
||||
type AccWebhookConfig = {
|
||||
// The (unencoded) ACC folder urn to subscribe to. Event will fire for all files in this folder.
|
||||
rootProjectFolderUrn: string
|
||||
// The Speckle endpoint to hit when the given event fires.
|
||||
callbackUrl: string
|
||||
// The ACC webhook event to subscribe to. You can register an event to a given callback url only once.
|
||||
event: 'dm.version.added'
|
||||
// The region where the request is executed
|
||||
region: AccRegion
|
||||
}
|
||||
|
||||
/**
|
||||
* Register relevant webhook callbacks for integration with a given ACC project.
|
||||
* @see https://aps.autodesk.com/en/docs/webhooks/v1/reference/http/webhooks/systems-system-events-event-hooks-POST/
|
||||
* @returns null if webhook already exists
|
||||
*/
|
||||
export const tryRegisterAccWebhook = async (
|
||||
webhook: AccWebhookConfig
|
||||
): Promise<string | null> => {
|
||||
const { rootProjectFolderUrn, callbackUrl, event, region } = webhook
|
||||
|
||||
const tokenData = await getToken()
|
||||
|
||||
const response = await fetch(
|
||||
`https://developer.api.autodesk.com/webhooks/v1/systems/data/events/${event}/hooks`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'x-ads-region': region
|
||||
},
|
||||
body: JSON.stringify({
|
||||
callbackUrl,
|
||||
scope: {
|
||||
folder: rootProjectFolderUrn
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
if (response.ok && response.status === 201) {
|
||||
const webhookId = response.headers.get('Location')?.split('/').at(-1)
|
||||
|
||||
if (!webhookId) {
|
||||
logger.info({ location: response.headers.get('Location') })
|
||||
throw new Error('Webhook created but failed to parse id')
|
||||
}
|
||||
|
||||
return webhookId
|
||||
}
|
||||
|
||||
const e = await response.json().catch(() => null)
|
||||
|
||||
const isConflict =
|
||||
response.status === 409 &&
|
||||
e?.code === 'CONFLICT_ERROR' &&
|
||||
e?.detail?.includes('Failed to save duplicate webhooks scope')
|
||||
|
||||
if (isConflict) {
|
||||
logger.warn('Webhook already exists. Skipping registration.')
|
||||
return null
|
||||
}
|
||||
|
||||
throw new Error(`Webhook registration failed: ${JSON.stringify(e, null, 2)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a manifest of the available derivative assets for a given design urn
|
||||
* @see https://aps.autodesk.com/en/docs/model-derivative/v2/reference/http/manifest/urn-manifest-GET/
|
||||
*/
|
||||
export const getManifestByUrn = async (
|
||||
params: {
|
||||
urn: string
|
||||
region: AccRegion
|
||||
},
|
||||
context: {
|
||||
token: string
|
||||
}
|
||||
): Promise<ModelDerivativeServiceDesignManifest> => {
|
||||
const { urn, region = AccRegions.EMEA } = params
|
||||
const { token } = context
|
||||
|
||||
const encodedUrn = encodeUrn(urn)
|
||||
|
||||
const url = getApiUrl(`/designdata/${encodedUrn}/manifest`, region)
|
||||
|
||||
return await invokeJsonRequest({
|
||||
url,
|
||||
token,
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
encodeUrn,
|
||||
getApiUrl,
|
||||
invokeJsonRequest
|
||||
} from '@/modules/acc/clients/autodesk/helpers'
|
||||
import { getToken } from '@/modules/acc/clients/autodesk/tokens'
|
||||
import type { AccRegion } from '@/modules/acc/domain/acc/constants'
|
||||
import { AccRegions } from '@/modules/acc/domain/acc/constants'
|
||||
import type {
|
||||
DataManagementFolderContentsFolder,
|
||||
DataManagementFolderContentsItem,
|
||||
DataManagementFolderContentsItemVersion,
|
||||
ModelDerivativeServiceDesignManifest
|
||||
} from '@/modules/acc/domain/acc/types'
|
||||
import { logger } from '@/observability/logging'
|
||||
|
||||
type AccWebhookConfig = {
|
||||
// The (unencoded) ACC folder urn to subscribe to. Event will fire for all files in this folder.
|
||||
rootProjectFolderUrn: string
|
||||
// The Speckle endpoint to hit when the given event fires.
|
||||
callbackUrl: string
|
||||
// The ACC webhook event to subscribe to. You can register an event to a given callback url only once.
|
||||
event: 'dm.version.added'
|
||||
// The region where the request is executed
|
||||
region: AccRegion
|
||||
}
|
||||
|
||||
/**
|
||||
* Register relevant webhook callbacks for integration with a given ACC project.
|
||||
* @see https://aps.autodesk.com/en/docs/webhooks/v1/reference/http/webhooks/systems-system-events-event-hooks-POST/
|
||||
* @returns null if webhook already exists
|
||||
*/
|
||||
export const tryRegisterAccWebhook = async (
|
||||
webhook: AccWebhookConfig
|
||||
): Promise<string | null> => {
|
||||
const { rootProjectFolderUrn, callbackUrl, event, region } = webhook
|
||||
|
||||
const tokenData = await getToken()
|
||||
|
||||
const response = await fetch(
|
||||
`https://developer.api.autodesk.com/webhooks/v1/systems/data/events/${event}/hooks`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'x-ads-region': region
|
||||
},
|
||||
body: JSON.stringify({
|
||||
callbackUrl,
|
||||
scope: {
|
||||
folder: rootProjectFolderUrn
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
if (response.ok && response.status === 201) {
|
||||
const webhookId = response.headers.get('Location')?.split('/').at(-1)
|
||||
|
||||
if (!webhookId) {
|
||||
logger.info({ location: response.headers.get('Location') })
|
||||
throw new Error('Webhook created but failed to parse id')
|
||||
}
|
||||
|
||||
return webhookId
|
||||
}
|
||||
|
||||
const e = await response.json().catch(() => null)
|
||||
|
||||
const isConflict =
|
||||
response.status === 409 &&
|
||||
e?.code === 'CONFLICT_ERROR' &&
|
||||
e?.detail?.includes('Failed to save duplicate webhooks scope')
|
||||
|
||||
if (isConflict) {
|
||||
logger.warn('Webhook already exists. Skipping registration.')
|
||||
return null
|
||||
}
|
||||
|
||||
throw new Error(`Webhook registration failed: ${JSON.stringify(e, null, 2)}`)
|
||||
}
|
||||
|
||||
type GetFolderMetadataResponse = { data: DataManagementFolderContentsFolder }
|
||||
|
||||
/**
|
||||
* Get information about a given folder, like its name and object count
|
||||
* @see https://aps.autodesk.com/en/docs/data/v2/reference/http/projects-project_id-folders-folder_id-GET/
|
||||
*/
|
||||
export const getFolderMetadata = async (
|
||||
params: {
|
||||
projectId: string
|
||||
folderId: string
|
||||
},
|
||||
context: {
|
||||
token: string
|
||||
userId?: string
|
||||
}
|
||||
): Promise<DataManagementFolderContentsFolder> => {
|
||||
const { projectId, folderId } = params
|
||||
const { token } = context
|
||||
|
||||
const { data } = (await invokeJsonRequest({
|
||||
url: `https://developer.api.autodesk.com/data/v1/projects/${projectId}/folders/${folderId}`,
|
||||
method: 'GET',
|
||||
token
|
||||
})) as GetFolderMetadataResponse
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
type GetFolderContentsResponse = {
|
||||
data: (DataManagementFolderContentsFolder | DataManagementFolderContentsItem)[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the contents (subfolders and items) of a given ACC folder
|
||||
* @see https://aps.autodesk.com/en/docs/data/v2/reference/http/projects-project_id-folders-folder_id-contents-GET/
|
||||
*/
|
||||
export const getFolderContents = async (
|
||||
args: {
|
||||
projectId: string
|
||||
folderId: string
|
||||
type?: 'items' | 'folders'
|
||||
},
|
||||
context: {
|
||||
token: string
|
||||
userId?: string
|
||||
}
|
||||
): Promise<
|
||||
(DataManagementFolderContentsFolder | DataManagementFolderContentsItem)[]
|
||||
> => {
|
||||
const { projectId, folderId } = args
|
||||
const { token } = context
|
||||
|
||||
const url = new URL(
|
||||
`https://developer.api.autodesk.com/data/v1/projects/${projectId}/folders/${folderId}/contents`
|
||||
)
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (args.type) {
|
||||
params.append('filter[type]', args.type)
|
||||
}
|
||||
|
||||
url.search = params.toString()
|
||||
|
||||
const { data } = (await invokeJsonRequest({
|
||||
url: url.toString(),
|
||||
method: 'GET',
|
||||
token
|
||||
})) as GetFolderContentsResponse
|
||||
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
type GetItemLatestVersionResponse = {
|
||||
data: DataManagementFolderContentsItemVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item version information, like version number and file type, for the latest version of a given file
|
||||
* @see https://aps.autodesk.com/en/docs/data/v2/reference/http/projects-project_id-items-item_id-tip-GET/
|
||||
*/
|
||||
export const getItemLatestVersion = async (
|
||||
args: {
|
||||
projectId: string
|
||||
itemId: string
|
||||
},
|
||||
context: {
|
||||
token: string
|
||||
userId?: string
|
||||
}
|
||||
): Promise<DataManagementFolderContentsItemVersion> => {
|
||||
const { projectId, itemId } = args
|
||||
const { token } = context
|
||||
|
||||
const { data } = (await invokeJsonRequest({
|
||||
url: `https://developer.api.autodesk.com/data/v1/projects/${projectId}/items/${itemId}/tip`,
|
||||
method: 'GET',
|
||||
token
|
||||
})) as GetItemLatestVersionResponse
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a manifest of the available derivative assets for a given design urn
|
||||
* @see https://aps.autodesk.com/en/docs/model-derivative/v2/reference/http/manifest/urn-manifest-GET/
|
||||
*/
|
||||
export const getManifestByUrn = async (
|
||||
params: {
|
||||
urn: string
|
||||
region: AccRegion
|
||||
},
|
||||
context: {
|
||||
token: string
|
||||
}
|
||||
): Promise<ModelDerivativeServiceDesignManifest> => {
|
||||
const { urn, region = AccRegions.EMEA } = params
|
||||
const { token } = context
|
||||
|
||||
const encodedUrn = encodeUrn(urn)
|
||||
|
||||
const url = getApiUrl(`/designdata/${encodedUrn}/manifest`, region)
|
||||
|
||||
return await invokeJsonRequest({
|
||||
url,
|
||||
token,
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { AccRegion } from '@/modules/acc/domain/acc/constants'
|
||||
import { AccRegions } from '@/modules/acc/domain/acc/constants'
|
||||
import { AutodeskApiRequestError } from '@/modules/acc/errors/acc'
|
||||
import { logger } from '@/observability/logging'
|
||||
|
||||
export const invokeJsonRequest = async <T>(params: {
|
||||
url: string
|
||||
token: string
|
||||
method?: RequestInit['method']
|
||||
body?: string
|
||||
headers?: Record<string, string>
|
||||
}) => {
|
||||
const { url, method = 'get', body, headers = {}, token } = params
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...headers
|
||||
},
|
||||
body
|
||||
})
|
||||
|
||||
return (await response.json()) as T
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
{
|
||||
...params,
|
||||
error: e
|
||||
},
|
||||
'Autodesk request failed'
|
||||
)
|
||||
throw new AutodeskApiRequestError(method, url)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base Autodesk API endpoint for a given region.
|
||||
* NOTE: This has been true for all endpoints used so far but should be validated for any new ones!
|
||||
*/
|
||||
export const getRegionUrl = (region: AccRegion): string => {
|
||||
switch (region) {
|
||||
case AccRegions.EMEA:
|
||||
return 'https://developer.api.autodesk.com/modelderivative/v2/regions/eu'
|
||||
default:
|
||||
return 'https://developer.api.autodesk.com/modelderivative/v2'
|
||||
}
|
||||
}
|
||||
|
||||
export const getApiUrl = (path: string, region: AccRegion): string => {
|
||||
return `${getRegionUrl(region)}${path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a urn string in the modified url-safe base64 format expected by the Autodesk API
|
||||
*/
|
||||
export const encodeUrn = (urn: string): string => {
|
||||
return btoa(urn).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
import crypto from 'crypto'
|
||||
import { z } from 'zod'
|
||||
import type { AccTokens } from '@speckle/shared/acc'
|
||||
import {
|
||||
getAutodeskIntegrationClientId,
|
||||
getAutodeskIntegrationClientSecret
|
||||
} from '@/modules/shared/helpers/envHelper'
|
||||
|
||||
interface BuildAuthorizeUrlOptions {
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
codeChallenge: string
|
||||
scopes: string[]
|
||||
}
|
||||
|
||||
interface ExchangeCodeOptions {
|
||||
code: string
|
||||
codeVerifier: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
redirectUri: string
|
||||
}
|
||||
|
||||
const AccTokens = z
|
||||
.object({
|
||||
access_token: z.string(),
|
||||
refresh_token: z.string(),
|
||||
token_type: z.string(),
|
||||
id_token: z.string().optional(),
|
||||
expires_in: z.number()
|
||||
})
|
||||
.transform((data) => ({
|
||||
...data,
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
|
||||
export const generateCodeVerifier = () => {
|
||||
const codeVerifier = crypto.randomBytes(32).toString('base64url')
|
||||
const codeChallenge = crypto
|
||||
.createHash('sha256')
|
||||
.update(codeVerifier)
|
||||
.digest('base64url')
|
||||
return { codeVerifier, codeChallenge }
|
||||
}
|
||||
|
||||
export const buildAuthorizeUrl = ({
|
||||
clientId,
|
||||
redirectUri,
|
||||
codeChallenge,
|
||||
scopes
|
||||
}: BuildAuthorizeUrlOptions) => {
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: scopes.join(' '),
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256'
|
||||
})
|
||||
|
||||
return `https://developer.api.autodesk.com/authentication/v2/authorize?${params.toString()}`
|
||||
}
|
||||
|
||||
export const exchangeCodeForTokens = async ({
|
||||
code,
|
||||
codeVerifier,
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri
|
||||
}: ExchangeCodeOptions): Promise<AccTokens> => {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
code,
|
||||
code_verifier: codeVerifier
|
||||
})
|
||||
|
||||
const response = await fetch(
|
||||
'https://developer.api.autodesk.com/authentication/v2/token',
|
||||
{
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
return AccTokens.parse(data)
|
||||
}
|
||||
|
||||
export const exchangeRefreshTokenForTokens = async (args: {
|
||||
refresh_token: string
|
||||
}): Promise<AccTokens> => {
|
||||
const clientId = getAutodeskIntegrationClientId()
|
||||
const clientSecret = getAutodeskIntegrationClientSecret()
|
||||
const token = Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64')
|
||||
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: args.refresh_token
|
||||
})
|
||||
|
||||
const response = await fetch(
|
||||
'https://developer.api.autodesk.com/authentication/v2/token',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${token}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: params
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return AccTokens.parse(data)
|
||||
}
|
||||
|
||||
type AutodeskIntegrationTokenData = {
|
||||
access_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a valid token for server-side operations as our custom integration
|
||||
*/
|
||||
export const getToken = async (): Promise<AutodeskIntegrationTokenData> => {
|
||||
const clientId = getAutodeskIntegrationClientId()
|
||||
const clientSecret = getAutodeskIntegrationClientSecret()
|
||||
|
||||
const token = Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64')
|
||||
|
||||
const response = await fetch(
|
||||
'https://developer.api.autodesk.com/authentication/v2/token',
|
||||
{
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
scope: 'data:read account:read viewables:read'
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Basic ${token}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return z
|
||||
.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.string(),
|
||||
expires_in: z.number()
|
||||
})
|
||||
.parse(data)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { StringEnumValues } from '@speckle/shared'
|
||||
import { StringEnum } from '@speckle/shared'
|
||||
|
||||
export const AccSyncItemStatuses = StringEnum([
|
||||
// A new file version had been detected, and we are awaiting a processable file.
|
||||
'pending',
|
||||
// We are actively processing the new file version. (The Automate function has been triggered.)
|
||||
'syncing',
|
||||
'failed',
|
||||
'paused',
|
||||
'succeeded'
|
||||
])
|
||||
export type AccSyncItemStatus = StringEnumValues<typeof AccSyncItemStatuses>
|
||||
|
||||
export const AccRegions = StringEnum([
|
||||
'US',
|
||||
'EMEA',
|
||||
'AUS',
|
||||
'CAN',
|
||||
'DEU',
|
||||
'IND',
|
||||
'JPN',
|
||||
'GBR'
|
||||
])
|
||||
export type AccRegion = StringEnumValues<typeof AccRegions>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { AccSyncItem } from '@/modules/acc/domain/types'
|
||||
import type { AccSyncItem } from '@/modules/acc/domain/acc/types'
|
||||
|
||||
export const accSyncItemEventsNamespace = 'accSyncItems' as const
|
||||
|
||||
+9
-2
@@ -1,5 +1,5 @@
|
||||
import type { AccSyncItemStatus } from '@/modules/acc/domain/constants'
|
||||
import type { AccSyncItem } from '@/modules/acc/domain/types'
|
||||
import type { AccSyncItemStatus } from '@/modules/acc/domain/acc/constants'
|
||||
import type { AccSyncItem } from '@/modules/acc/domain/acc/types'
|
||||
import type { Exact } from 'type-fest'
|
||||
|
||||
export type UpsertAccSyncItem = <Item extends Exact<AccSyncItem, Item>>(
|
||||
@@ -13,7 +13,14 @@ export type UpdateAccSyncItemStatus = (args: {
|
||||
|
||||
export type GetAccSyncItemById = (args: { id: string }) => Promise<AccSyncItem | null>
|
||||
|
||||
export type GetAccSyncItemByModelId = (args: {
|
||||
modelId: string
|
||||
}) => Promise<AccSyncItem | null>
|
||||
|
||||
export type GetAccSyncItemsById = (args: { ids: string[] }) => Promise<AccSyncItem[]>
|
||||
export type GetAccSyncItemsByModelId = (args: {
|
||||
ids: string[]
|
||||
}) => Promise<AccSyncItem[]>
|
||||
|
||||
export type ListAccSyncItems = (args: {
|
||||
projectId: string
|
||||
+31
-1
@@ -1,4 +1,4 @@
|
||||
import type { AccRegion, AccSyncItemStatus } from '@/modules/acc/domain/constants'
|
||||
import type { AccRegion, AccSyncItemStatus } from '@/modules/acc/domain/acc/constants'
|
||||
|
||||
export type AccSyncItem = {
|
||||
id: string
|
||||
@@ -22,6 +22,36 @@ export type AccSyncItem = {
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export type DataManagementFolderContentsFolder = {
|
||||
id: string
|
||||
type: 'folders'
|
||||
attributes: {
|
||||
name?: string
|
||||
displayName: string
|
||||
objectCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export type DataManagementFolderContentsItem = {
|
||||
id: string
|
||||
type: 'items'
|
||||
attributes: {
|
||||
name?: string
|
||||
displayName: string
|
||||
}
|
||||
}
|
||||
|
||||
export type DataManagementFolderContentsItemVersion = {
|
||||
id: string
|
||||
type: 'versions'
|
||||
attributes: {
|
||||
name?: string
|
||||
displayName: string
|
||||
versionNumber: number
|
||||
fileType?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ModelDerivativeServiceDesignManifest = {
|
||||
type: 'manifest'
|
||||
region: string
|
||||
@@ -1,32 +1,10 @@
|
||||
import type { StringEnumValues } from '@speckle/shared'
|
||||
import { StringEnum } from '@speckle/shared'
|
||||
|
||||
export const ImporterAutomateFunctions = {
|
||||
svf2: {
|
||||
functionId: '4665e0b3ba',
|
||||
functionReleaseId: '470ec84b63'
|
||||
},
|
||||
rvt: {
|
||||
functionId: '0725cb0ac6',
|
||||
functionReleaseId: 'b5c16a1606'
|
||||
}
|
||||
}
|
||||
|
||||
export const AccSyncItemStatuses = StringEnum([
|
||||
// A new file version had been detected, and we are awaiting a processable file.
|
||||
'pending',
|
||||
// We are actively processing the new file version. (The Automate function has been triggered.)
|
||||
'syncing',
|
||||
'failed',
|
||||
'paused',
|
||||
'succeeded'
|
||||
])
|
||||
export type AccSyncItemStatus = StringEnumValues<typeof AccSyncItemStatuses>
|
||||
|
||||
export const AccRegions = StringEnum([
|
||||
'US',
|
||||
'EMEA',
|
||||
'AUS',
|
||||
'CAN',
|
||||
'DEU',
|
||||
'IND',
|
||||
'JPN',
|
||||
'GBR'
|
||||
])
|
||||
export type AccRegion = StringEnumValues<typeof AccRegions>
|
||||
|
||||
@@ -6,6 +6,30 @@ export class AccModuleDisabledError extends BaseError {
|
||||
static statusCode = 423
|
||||
}
|
||||
|
||||
export class AccNotAuthorizedError extends BaseError {
|
||||
static defaultMessage = 'ACC token missing or not authorized'
|
||||
static code = 'ACC_MODULE_NOT_AUTHORIZED'
|
||||
static statusCode = 401
|
||||
}
|
||||
|
||||
export class AccNotYetImplementedError extends BaseError {
|
||||
static defaultMessage =
|
||||
'This functionality for the ACC integration is not yet implemented'
|
||||
static code = 'ACC_MODULE_NOT_YET_IMPLEMENTED'
|
||||
static statusCode = 501
|
||||
}
|
||||
|
||||
export class AutodeskApiRequestError extends BaseError {
|
||||
static defaultMessage = 'Error during external request to Autodesk'
|
||||
static code = 'ACC_AUTODESK_REQUEST_ERROR'
|
||||
static statusCode = 500
|
||||
|
||||
constructor(method: string, endpoint: string) {
|
||||
super()
|
||||
this.message = `Failed to issue ${method} request to ${endpoint}`
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateSyncItemError extends BaseError {
|
||||
static defaultMessage = 'A sync item with this lineage urn already exists.'
|
||||
static code = 'ACC_DUPLICATE_SYNC_ITEM_LINEAGE_URN'
|
||||
@@ -28,3 +52,14 @@ export class SyncItemAutomationTriggerError extends BaseError {
|
||||
static code = 'ACC_SYNC_ITEM_AUTOMATION_TRIGGER_ERROR'
|
||||
static statusCode = 422
|
||||
}
|
||||
|
||||
export class SyncItemUnsupportedFileExtensionError extends BaseError {
|
||||
static defaultMessage = 'Cannot sync this file type from ACC'
|
||||
static code = 'ACC_SYNC_ITEM_UNSUPPORTED_FILE_EXTENSION'
|
||||
static statusCode = 422
|
||||
|
||||
constructor(fileExtension: string) {
|
||||
super()
|
||||
this.message = `Received sync item update with unsupported file extension ${fileExtension}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AccSyncItemEvents } from '@/modules/acc/domain/events'
|
||||
import { AccSyncItemEvents } from '@/modules/acc/domain/acc/events'
|
||||
import type { EventBusListen, EventPayload } from '@/modules/shared/services/eventBus'
|
||||
import type { PublishSubscription } from '@/modules/shared/utils/subscriptions'
|
||||
import { ProjectSubscriptions } from '@/modules/shared/utils/subscriptions'
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { getFolderContents } from '@/modules/acc/clients/autodesk/acc'
|
||||
import type {
|
||||
AccSyncItem,
|
||||
DataManagementFolderContentsFolder,
|
||||
DataManagementFolderContentsItem
|
||||
} from '@/modules/acc/domain/acc/types'
|
||||
import {
|
||||
getAccSyncItemsByIdFactory,
|
||||
getAccSyncItemsByModelIdFactory
|
||||
} from '@/modules/acc/repositories/accSyncItems'
|
||||
import { defineRequestDataloaders } from '@/modules/shared/helpers/graphqlHelper'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import { keyBy } from 'lodash-es'
|
||||
|
||||
declare module '@/modules/core/loaders' {
|
||||
interface ModularizedDataLoaders
|
||||
extends Partial<ReturnType<typeof dataLoadersDefinition>> {}
|
||||
}
|
||||
|
||||
const dataLoadersDefinition = defineRequestDataloaders(
|
||||
({ createLoader, deps: { db } }) => {
|
||||
const getAccSyncItemsById = getAccSyncItemsByIdFactory({ db })
|
||||
const getAccSyncItemsByModelId = getAccSyncItemsByModelIdFactory({ db })
|
||||
|
||||
return {
|
||||
acc: {
|
||||
getFolderChildren: createLoader<
|
||||
{ projectId: string; folderId: string; token: string },
|
||||
DataManagementFolderContentsFolder[],
|
||||
string
|
||||
>(
|
||||
async (folderIds) => {
|
||||
return await Promise.all(
|
||||
folderIds.map(async ({ projectId, folderId, token }) => {
|
||||
const items = await getFolderContents(
|
||||
{ projectId, folderId, type: 'folders' },
|
||||
{ token }
|
||||
)
|
||||
return items.filter(
|
||||
(item): item is DataManagementFolderContentsFolder =>
|
||||
item.type === 'folders'
|
||||
)
|
||||
})
|
||||
)
|
||||
},
|
||||
{
|
||||
cacheKeyFn: (args) => `${args.projectId}-${args.projectId}`
|
||||
}
|
||||
),
|
||||
getFolderContents: createLoader<
|
||||
{ projectId: string; folderId: string; token: string },
|
||||
DataManagementFolderContentsItem[],
|
||||
string
|
||||
>(
|
||||
async (folderIds) => {
|
||||
return await Promise.all(
|
||||
folderIds.map(async ({ projectId, folderId, token }) => {
|
||||
const items = await getFolderContents(
|
||||
{ projectId, folderId, type: 'items' },
|
||||
{ token }
|
||||
)
|
||||
return items.filter(
|
||||
(item): item is DataManagementFolderContentsItem =>
|
||||
item.type === 'items'
|
||||
)
|
||||
})
|
||||
)
|
||||
},
|
||||
{
|
||||
cacheKeyFn: (args) => `${args.projectId}-${args.projectId}`
|
||||
}
|
||||
),
|
||||
getAccSyncItem: createLoader<string, Nullable<AccSyncItem>>(async (ids) => {
|
||||
const results = keyBy(
|
||||
await getAccSyncItemsById({ ids: ids.slice() }),
|
||||
(i) => i.id
|
||||
)
|
||||
return ids.map((i) => results[i] || null)
|
||||
}),
|
||||
getAccSyncItemByModelId: createLoader<string, Nullable<AccSyncItem>>(
|
||||
async (ids) => {
|
||||
const results = keyBy(
|
||||
await getAccSyncItemsByModelId({ ids: ids.slice() }),
|
||||
(i) => i.modelId
|
||||
)
|
||||
return ids.map((i) => results[i] || null)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default dataLoadersDefinition
|
||||
+148
-3
@@ -59,15 +59,117 @@ import {
|
||||
ProjectSubscriptions
|
||||
} from '@/modules/shared/utils/subscriptions'
|
||||
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
|
||||
import { AccModuleDisabledError, SyncItemNotFoundError } from '@/modules/acc/errors/acc'
|
||||
import {
|
||||
AccModuleDisabledError,
|
||||
AccNotAuthorizedError,
|
||||
SyncItemNotFoundError
|
||||
} from '@/modules/acc/errors/acc'
|
||||
import { getFeatureFlags } from '@speckle/shared/environment'
|
||||
import type { AccRegion } from '@/modules/acc/domain/constants'
|
||||
import type { AccRegion } from '@/modules/acc/domain/acc/constants'
|
||||
import { ProjectNotFoundError } from '@/modules/core/errors/projects'
|
||||
import {
|
||||
mapFolderToGql,
|
||||
mapItemToGql,
|
||||
mapVersionToGql
|
||||
} from '@/modules/acc/helpers/acc'
|
||||
import {
|
||||
getFolderMetadata,
|
||||
getItemLatestVersion
|
||||
} from '@/modules/acc/clients/autodesk/acc'
|
||||
|
||||
const { FF_ACC_INTEGRATION_ENABLED, FF_AUTOMATE_MODULE_ENABLED } = getFeatureFlags()
|
||||
|
||||
const enableAcc = FF_ACC_INTEGRATION_ENABLED && FF_AUTOMATE_MODULE_ENABLED
|
||||
|
||||
const resolvers: Resolvers = {
|
||||
WorkspaceIntegrations: {
|
||||
acc: async (_parent, args, ctx) => {
|
||||
if (!args.token) {
|
||||
throw new AccNotAuthorizedError()
|
||||
}
|
||||
// TODO ACC: Replace with Speckle user - ACC user association
|
||||
ctx.accToken = args.token
|
||||
return {}
|
||||
}
|
||||
},
|
||||
AccIntegration: {
|
||||
folder: async (_parent, args) => {
|
||||
const { projectId, folderId } = args
|
||||
return {
|
||||
id: folderId,
|
||||
projectId
|
||||
}
|
||||
}
|
||||
},
|
||||
AccFolder: {
|
||||
name: async (parent, _args, ctx) => {
|
||||
if (parent.name) return parent.name
|
||||
|
||||
const folderMetadata = await getFolderMetadata(
|
||||
{
|
||||
projectId: parent.projectId,
|
||||
folderId: parent.id
|
||||
},
|
||||
{
|
||||
token: ctx.accToken!
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
folderMetadata.attributes.name ?? folderMetadata.attributes.displayName ?? ''
|
||||
)
|
||||
},
|
||||
contents: async (parent, _args, ctx) => {
|
||||
const { id: folderId, projectId } = parent
|
||||
const { accToken } = ctx
|
||||
|
||||
const files = await ctx.loaders.acc!.getFolderContents.load({
|
||||
projectId,
|
||||
folderId,
|
||||
token: accToken!
|
||||
})
|
||||
const items = files.map((file) => ({
|
||||
...mapItemToGql(file),
|
||||
projectId
|
||||
}))
|
||||
|
||||
return { items }
|
||||
},
|
||||
children: async (parent, _args, ctx) => {
|
||||
const { id: folderId, projectId } = parent
|
||||
const { accToken } = ctx
|
||||
|
||||
const folders = await ctx.loaders.acc!.getFolderChildren.load({
|
||||
projectId,
|
||||
folderId,
|
||||
token: accToken!
|
||||
})
|
||||
const items = folders.map((folder) => ({
|
||||
...mapFolderToGql(folder),
|
||||
projectId
|
||||
}))
|
||||
|
||||
return { items }
|
||||
}
|
||||
},
|
||||
AccItem: {
|
||||
latestVersion: async (parent, _args, ctx) => {
|
||||
const { id: itemId, projectId } = parent
|
||||
const { accToken } = ctx
|
||||
|
||||
const version = await getItemLatestVersion(
|
||||
{
|
||||
projectId,
|
||||
itemId
|
||||
},
|
||||
{
|
||||
token: accToken!
|
||||
}
|
||||
)
|
||||
|
||||
return mapVersionToGql(version)
|
||||
}
|
||||
},
|
||||
Mutation: {
|
||||
accSyncItemMutations: () => ({})
|
||||
},
|
||||
@@ -193,6 +295,14 @@ const resolvers: Resolvers = {
|
||||
}
|
||||
},
|
||||
AccSyncItem: {
|
||||
project: async (parent, _args, context) => {
|
||||
const project = await context.loaders.streams.getStream.load(parent.projectId)
|
||||
if (!project) throw new ProjectNotFoundError()
|
||||
return project
|
||||
},
|
||||
model: async (parent, _args, context) => {
|
||||
return await context.loaders.branches.getById.load(parent.modelId)
|
||||
},
|
||||
author: async (parent, _args, context) => {
|
||||
return await context.loaders.users.getUser.load(parent.authorId)
|
||||
}
|
||||
@@ -237,7 +347,32 @@ const resolvers: Resolvers = {
|
||||
resourceType: TokenResourceIdentifierType.Project
|
||||
})
|
||||
|
||||
const syncItem = await ctx.loaders.acc.getAccSyncItem.load(id)
|
||||
const syncItem = await ctx.loaders.acc!.getAccSyncItem.load(id)
|
||||
|
||||
if (!syncItem) {
|
||||
throw new SyncItemNotFoundError()
|
||||
}
|
||||
|
||||
return syncItem
|
||||
}
|
||||
},
|
||||
Model: {
|
||||
accSyncItem: async (parent, _args, context) => {
|
||||
const authResult =
|
||||
await context.authPolicies.project.canReadAccIntegrationSettings({
|
||||
userId: context.userId,
|
||||
projectId: parent.streamId
|
||||
})
|
||||
throwIfAuthNotOk(authResult)
|
||||
throwIfResourceAccessNotAllowed({
|
||||
resourceId: parent.streamId,
|
||||
resourceAccessRules: context.resourceAccessRules,
|
||||
resourceType: TokenResourceIdentifierType.Project
|
||||
})
|
||||
|
||||
const syncItem = await context.loaders.acc!.getAccSyncItemByModelId.load(
|
||||
parent.id
|
||||
)
|
||||
|
||||
if (!syncItem) {
|
||||
throw new SyncItemNotFoundError()
|
||||
@@ -275,6 +410,11 @@ const resolvers: Resolvers = {
|
||||
}
|
||||
|
||||
const disabledResolvers: Resolvers = {
|
||||
WorkspaceIntegrations: {
|
||||
async acc() {
|
||||
throw new AccModuleDisabledError()
|
||||
}
|
||||
},
|
||||
Mutation: {
|
||||
accSyncItemMutations: () => ({})
|
||||
},
|
||||
@@ -297,6 +437,11 @@ const disabledResolvers: Resolvers = {
|
||||
throw new AccModuleDisabledError()
|
||||
}
|
||||
},
|
||||
Model: {
|
||||
async accSyncItem() {
|
||||
return null
|
||||
}
|
||||
},
|
||||
Subscription: {
|
||||
projectAccSyncItemsUpdated: {
|
||||
subscribe: filteredSubscribe(
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Resolvers } from '@/modules/core/graph/generated/graphql'
|
||||
|
||||
const resolvers: Resolvers = {
|
||||
Workspace: {
|
||||
integrations: () => ({})
|
||||
}
|
||||
}
|
||||
|
||||
export default resolvers
|
||||
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
DataManagementFolderContentsFolder,
|
||||
DataManagementFolderContentsItem,
|
||||
DataManagementFolderContentsItemVersion
|
||||
} from '@/modules/acc/domain/acc/types'
|
||||
import type {
|
||||
AccFolderGraphQLReturn,
|
||||
AccItemGraphQLReturn,
|
||||
AccItemVersionGraphQLReturn
|
||||
} from '@/modules/acc/helpers/graphTypes'
|
||||
|
||||
export const filterContentsToFolders = (
|
||||
contents: (DataManagementFolderContentsFolder | DataManagementFolderContentsItem)[]
|
||||
): DataManagementFolderContentsFolder[] => {
|
||||
return contents.filter(
|
||||
(entry): entry is DataManagementFolderContentsFolder => entry.type === 'folders'
|
||||
)
|
||||
}
|
||||
|
||||
export const filterContentsToItems = (
|
||||
contents: (DataManagementFolderContentsFolder | DataManagementFolderContentsItem)[]
|
||||
): DataManagementFolderContentsItem[] => {
|
||||
return contents.filter(
|
||||
(entry): entry is DataManagementFolderContentsItem => entry.type === 'items'
|
||||
)
|
||||
}
|
||||
|
||||
export const mapFolderToGql = (
|
||||
folder: DataManagementFolderContentsFolder
|
||||
): Omit<AccFolderGraphQLReturn, 'projectId'> => ({
|
||||
id: folder.id,
|
||||
name: folder.attributes.name ?? folder.attributes.displayName,
|
||||
objectCount: folder.attributes.objectCount
|
||||
})
|
||||
|
||||
export const mapItemToGql = (
|
||||
item: DataManagementFolderContentsItem
|
||||
): Omit<AccItemGraphQLReturn, 'projectId'> => ({
|
||||
id: item.id,
|
||||
name: item.attributes.name ?? item.attributes.displayName
|
||||
})
|
||||
|
||||
export const mapVersionToGql = (
|
||||
version: DataManagementFolderContentsItemVersion
|
||||
): AccItemVersionGraphQLReturn => ({
|
||||
id: version.id,
|
||||
name: version.attributes.name ?? version.attributes.displayName,
|
||||
versionNumber: version.attributes.versionNumber,
|
||||
fileType: version.attributes.fileType
|
||||
})
|
||||
@@ -1,4 +1,25 @@
|
||||
import type { AccSyncItem } from '@/modules/acc/domain/types'
|
||||
import type { AccSyncItem } from '@/modules/acc/domain/acc/types'
|
||||
|
||||
export type WorkspaceIntegrationsGraphQLReturn = {}
|
||||
|
||||
export type AccIntegrationGraphQLReturn = {}
|
||||
export type AccFolderGraphQLReturn = {
|
||||
id: string
|
||||
projectId: string
|
||||
// Resolver will use name provided instead of re-fetching, if possible
|
||||
name?: string
|
||||
objectCount?: number
|
||||
}
|
||||
export type AccItemGraphQLReturn = {
|
||||
id: string
|
||||
name: string
|
||||
projectId: string
|
||||
}
|
||||
export type AccItemVersionGraphQLReturn = {
|
||||
id: string
|
||||
name: string
|
||||
versionNumber: number
|
||||
fileType?: string
|
||||
}
|
||||
export type AccSyncItemGraphQLReturn = AccSyncItem
|
||||
export type AccSyncItemMutationsGraphQLReturn = {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ModelDerivativeServiceDesignManifest } from '@/modules/acc/domain/types'
|
||||
import type { ModelDerivativeServiceDesignManifest } from '@/modules/acc/domain/acc/types'
|
||||
|
||||
export const isReadyForImport = (
|
||||
manifest: ModelDerivativeServiceDesignManifest
|
||||
|
||||
@@ -3,14 +3,15 @@ import type {
|
||||
CountAccSyncItems,
|
||||
DeleteAccSyncItemById,
|
||||
GetAccSyncItemById,
|
||||
GetAccSyncItemByModelId,
|
||||
GetAccSyncItemsById,
|
||||
ListAccSyncItems,
|
||||
QueryAllAccSyncItems,
|
||||
UpdateAccSyncItemStatus,
|
||||
UpsertAccSyncItem
|
||||
} from '@/modules/acc/domain/operations'
|
||||
} from '@/modules/acc/domain/acc/operations'
|
||||
import { executeBatchedSelect } from '@/modules/shared/helpers/dbHelper'
|
||||
import type { AccSyncItem } from '@/modules/acc/domain/types'
|
||||
import type { AccSyncItem } from '@/modules/acc/domain/acc/types'
|
||||
import type { Knex } from 'knex'
|
||||
import { without } from 'lodash-es'
|
||||
|
||||
@@ -30,6 +31,18 @@ export const getAccSyncItemByIdFactory =
|
||||
)
|
||||
}
|
||||
|
||||
export const getAccSyncItemByModelIdFactory =
|
||||
(deps: { db: Knex }): GetAccSyncItemByModelId =>
|
||||
async ({ modelId }) => {
|
||||
return (
|
||||
(await tables
|
||||
.accSyncItems(deps.db)
|
||||
.select()
|
||||
.where(AccSyncItems.col.modelId, modelId)
|
||||
.first()) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export const getAccSyncItemsByIdFactory =
|
||||
(deps: { db: Knex }): GetAccSyncItemsById =>
|
||||
async ({ ids }) => {
|
||||
@@ -38,6 +51,17 @@ export const getAccSyncItemsByIdFactory =
|
||||
return await tables.accSyncItems(deps.db).select().whereIn(AccSyncItems.col.id, ids)
|
||||
}
|
||||
|
||||
export const getAccSyncItemsByModelIdFactory =
|
||||
(deps: { db: Knex }): GetAccSyncItemsById =>
|
||||
async ({ ids }) => {
|
||||
if (!ids.length) return []
|
||||
|
||||
return await tables
|
||||
.accSyncItems(deps.db)
|
||||
.select()
|
||||
.whereIn(AccSyncItems.col.modelId, ids)
|
||||
}
|
||||
|
||||
export const upsertAccSyncItemFactory =
|
||||
(deps: { db: Knex }): UpsertAccSyncItem =>
|
||||
async (item) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
exchangeCodeForTokens,
|
||||
exchangeRefreshTokenForTokens,
|
||||
generateCodeVerifier
|
||||
} from '@/modules/acc/clients/autodesk'
|
||||
} from '@/modules/acc/clients/autodesk/tokens'
|
||||
import { sessionMiddlewareFactory } from '@/modules/auth/middleware'
|
||||
import { corsMiddlewareFactory } from '@/modules/core/configs/cors'
|
||||
import {
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getAutodeskIntegrationClientSecret,
|
||||
getServerOrigin
|
||||
} from '@/modules/shared/helpers/envHelper'
|
||||
import { logger } from '@/observability/logging'
|
||||
import type { Express } from 'express'
|
||||
|
||||
export const setupAccOidcEndpoints = (app: Express) => {
|
||||
@@ -24,8 +25,8 @@ export const setupAccOidcEndpoints = (app: Express) => {
|
||||
corsMiddleware,
|
||||
sessionMiddleware,
|
||||
async (req, res) => {
|
||||
const { projectId } = req.body
|
||||
req.session.projectId = projectId
|
||||
const { callbackEndpoint } = req.body
|
||||
req.session.callbackEndpoint = callbackEndpoint
|
||||
|
||||
const { codeVerifier, codeChallenge } = generateCodeVerifier()
|
||||
req.session.codeVerifier = codeVerifier
|
||||
@@ -66,7 +67,13 @@ export const setupAccOidcEndpoints = (app: Express) => {
|
||||
|
||||
req.session.accTokens = tokens
|
||||
|
||||
return res.redirect(`/projects/${req.session.projectId}/acc`)
|
||||
logger.warn(req.session)
|
||||
|
||||
if (!req.session.callbackEndpoint) {
|
||||
return res.status(500)
|
||||
}
|
||||
|
||||
return res.redirect(req.session.callbackEndpoint)
|
||||
} catch (error) {
|
||||
console.error('Token exchange failed:', error)
|
||||
return res.status(500).send({ error: 'Token exchange failed' })
|
||||
@@ -75,10 +82,14 @@ export const setupAccOidcEndpoints = (app: Express) => {
|
||||
)
|
||||
|
||||
app.get('/api/v1/acc/auth/status', corsMiddleware, sessionMiddleware, (req, res) => {
|
||||
if (!req.session.accTokens) {
|
||||
return res.status(404).send({ error: 'No ACC tokens found' })
|
||||
try {
|
||||
if (!req.session.accTokens) {
|
||||
return res.status(404).send({ error: 'No ACC tokens found' })
|
||||
}
|
||||
res.send(req.session.accTokens)
|
||||
} finally {
|
||||
req.session.accTokens = undefined // we wanna return it just once
|
||||
}
|
||||
res.send(req.session.accTokens)
|
||||
})
|
||||
|
||||
app.post(
|
||||
@@ -86,7 +97,7 @@ export const setupAccOidcEndpoints = (app: Express) => {
|
||||
corsMiddleware,
|
||||
sessionMiddleware,
|
||||
async (req, res) => {
|
||||
const { refresh_token } = req.session.accTokens || {}
|
||||
const { refresh_token } = req.body || {}
|
||||
if (!refresh_token) {
|
||||
return res.status(401).json({ error: 'No refresh token found' })
|
||||
}
|
||||
@@ -97,7 +108,7 @@ export const setupAccOidcEndpoints = (app: Express) => {
|
||||
res.json(newTokens)
|
||||
} catch (error) {
|
||||
console.error('Error refreshing token:', error)
|
||||
res.status(500).json({ error: 'Error refreshing token' })
|
||||
res.status(500).json({ error })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {
|
||||
AccSyncItemStatuses,
|
||||
ImporterAutomateFunctions
|
||||
} from '@/modules/acc/domain/constants'
|
||||
import type { UpdateAccSyncItemStatus } from '@/modules/acc/domain/operations'
|
||||
import type { AccSyncItem } from '@/modules/acc/domain/types'
|
||||
import { ImporterAutomateFunctions } from '@/modules/acc/domain/constants'
|
||||
import { AccSyncItemStatuses } from '@/modules/acc/domain/acc/constants'
|
||||
import type { UpdateAccSyncItemStatus } from '@/modules/acc/domain/acc/operations'
|
||||
import type { AccSyncItem } from '@/modules/acc/domain/acc/types'
|
||||
import {
|
||||
SyncItemAutomationTriggerError,
|
||||
SyncItemNotFoundError
|
||||
@@ -22,7 +20,9 @@ import type { CreateAndStoreAppToken } from '@/modules/core/domain/tokens/operat
|
||||
import { TokenResourceIdentifierType } from '@/modules/core/graph/generated/graphql'
|
||||
import {
|
||||
getAutodeskIntegrationClientId,
|
||||
getAutodeskIntegrationClientSecret
|
||||
getAutodeskIntegrationClientSecret,
|
||||
getOdaUserId,
|
||||
getOdaUserSecret
|
||||
} from '@/modules/shared/helpers/envHelper'
|
||||
import { logger } from '@/observability/logging'
|
||||
import { Scopes } from '@speckle/shared'
|
||||
@@ -92,8 +92,8 @@ export const triggerSyncItemAutomationFactory =
|
||||
functionRuns: [
|
||||
{
|
||||
id: cryptoRandomString({ length: 15 }),
|
||||
functionId: ImporterAutomateFunctions.svf2.functionId,
|
||||
functionReleaseId: ImporterAutomateFunctions.svf2.functionReleaseId,
|
||||
functionId: ImporterAutomateFunctions.rvt.functionId,
|
||||
functionReleaseId: ImporterAutomateFunctions.rvt.functionReleaseId,
|
||||
status: 'pending' as const,
|
||||
elapsed: 0,
|
||||
results: null,
|
||||
@@ -148,9 +148,12 @@ export const triggerSyncItemAutomationFactory =
|
||||
modelId: syncItem.modelId,
|
||||
versionUrn: syncItem.accFileVersionUrn,
|
||||
viewName: syncItem.accFileViewName ?? null,
|
||||
autodeskProjectId: syncItem.accProjectId.replace('b.', ''),
|
||||
autodeskRegion: syncItem.accRegion === 'EMEA' ? 1 : 0,
|
||||
autodeskClientId: getAutodeskIntegrationClientId(),
|
||||
autodeskClientSecret: getAutodeskIntegrationClientSecret()
|
||||
autodeskClientSecret: getAutodeskIntegrationClientSecret(),
|
||||
odaUserId: getOdaUserId(),
|
||||
odaUserSignature: getOdaUserSecret()
|
||||
}
|
||||
})),
|
||||
manifests: [
|
||||
|
||||
@@ -4,9 +4,6 @@ import {
|
||||
} from '@/modules/acc/repositories/accSyncItems'
|
||||
import type { ScheduleExecution } from '@/modules/core/domain/scheduledTasks/operations'
|
||||
import { db } from '@/db/knex'
|
||||
import { getManifestByUrn, getToken } from '@/modules/acc/clients/autodesk'
|
||||
import { isReadyForImport } from '@/modules/acc/helpers/svfUtils'
|
||||
import type { Logger } from '@/observability/logging'
|
||||
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
|
||||
import {
|
||||
getAutomationFactory,
|
||||
@@ -22,7 +19,7 @@ import {
|
||||
} from '@/modules/core/repositories/tokens'
|
||||
import { createAppTokenFactory } from '@/modules/core/services/tokens'
|
||||
import { TIME_MS } from '@speckle/shared'
|
||||
import { AccSyncItemStatuses, type AccRegion } from '@/modules/acc/domain/constants'
|
||||
import { AccSyncItemStatuses } from '@/modules/acc/domain/acc/constants'
|
||||
import { triggerSyncItemAutomationFactory } from '@/modules/acc/services/automate'
|
||||
|
||||
const queryAllAccSyncItems = queryAllAccSyncItemsFactory({ db })
|
||||
@@ -30,28 +27,11 @@ const queryAllAccSyncItems = queryAllAccSyncItemsFactory({ db })
|
||||
export const schedulePendingSyncItemsCheck = (deps: {
|
||||
scheduleExecution: ScheduleExecution
|
||||
}) => {
|
||||
const callback = async (_now: Date, { logger }: { logger: Logger }) => {
|
||||
const tokenData = await getToken()
|
||||
const callback = async () => {
|
||||
for await (const items of queryAllAccSyncItems({
|
||||
filter: { status: AccSyncItemStatuses.pending }
|
||||
})) {
|
||||
for (const syncItem of items) {
|
||||
const manifest = await getManifestByUrn(
|
||||
{
|
||||
urn: syncItem.accFileVersionUrn,
|
||||
region: syncItem.accRegion as AccRegion
|
||||
},
|
||||
{ token: tokenData.access_token }
|
||||
)
|
||||
const isReady = isReadyForImport(manifest)
|
||||
|
||||
logger.info(
|
||||
{ isReady, syncItem, manifest },
|
||||
'Checking pending sync item {syncItem.id} for import readiness.'
|
||||
)
|
||||
|
||||
if (!isReady) continue
|
||||
|
||||
const projectDb = await getProjectDbClient({ projectId: syncItem.projectId })
|
||||
|
||||
await triggerSyncItemAutomationFactory({
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import {
|
||||
getManifestByUrn,
|
||||
getToken,
|
||||
tryRegisterAccWebhook
|
||||
} from '@/modules/acc/clients/autodesk'
|
||||
import {
|
||||
AccSyncItemStatuses,
|
||||
ImporterAutomateFunctions
|
||||
} from '@/modules/acc/domain/constants'
|
||||
import { AccSyncItemEvents } from '@/modules/acc/domain/events'
|
||||
import { isReadyForImport } from '@/modules/acc/helpers/svfUtils'
|
||||
import { tryRegisterAccWebhook } from '@/modules/acc/clients/autodesk/acc'
|
||||
import { ImporterAutomateFunctions } from '@/modules/acc/domain/constants'
|
||||
import { AccSyncItemStatuses } from '@/modules/acc/domain/acc/constants'
|
||||
import { AccSyncItemEvents } from '@/modules/acc/domain/acc/events'
|
||||
import type {
|
||||
CountAccSyncItems,
|
||||
DeleteAccSyncItemById,
|
||||
GetAccSyncItemById,
|
||||
ListAccSyncItems,
|
||||
UpsertAccSyncItem
|
||||
} from '@/modules/acc/domain/operations'
|
||||
import type { AccSyncItem } from '@/modules/acc/domain/types'
|
||||
import { SyncItemNotFoundError } from '@/modules/acc/errors/acc'
|
||||
} from '@/modules/acc/domain/acc/operations'
|
||||
import type { AccSyncItem } from '@/modules/acc/domain/acc/types'
|
||||
import {
|
||||
SyncItemNotFoundError,
|
||||
SyncItemUnsupportedFileExtensionError
|
||||
} from '@/modules/acc/errors/acc'
|
||||
import type { TriggerSyncItemAutomation } from '@/modules/acc/services/automate'
|
||||
import type {
|
||||
CreateAutomation,
|
||||
@@ -82,8 +78,8 @@ export const createAccSyncItemFactory =
|
||||
automationId: automation.id,
|
||||
functions: [
|
||||
{
|
||||
functionId: ImporterAutomateFunctions.svf2.functionId,
|
||||
functionReleaseId: ImporterAutomateFunctions.svf2.functionReleaseId
|
||||
functionId: ImporterAutomateFunctions.rvt.functionId,
|
||||
functionReleaseId: ImporterAutomateFunctions.rvt.functionReleaseId
|
||||
}
|
||||
],
|
||||
triggerDefinitions: {
|
||||
@@ -114,7 +110,6 @@ export const createAccSyncItemFactory =
|
||||
|
||||
await deps.upsertAccSyncItem(newSyncItem)
|
||||
|
||||
// TODO ACC: somehow i could not managed to get subsriptions work, doing stupid timeout refetch in FE after create/delete/update
|
||||
// Once we have it properly TODO ogu: fix it on FE
|
||||
await deps.eventEmit({
|
||||
eventName: AccSyncItemEvents.Created,
|
||||
@@ -125,19 +120,14 @@ export const createAccSyncItemFactory =
|
||||
})
|
||||
|
||||
// Import new sync item immediately, if possible
|
||||
const tokenData = await getToken()
|
||||
const manifest = await getManifestByUrn(
|
||||
{
|
||||
urn: newSyncItem.accFileVersionUrn,
|
||||
region: newSyncItem.accRegion
|
||||
},
|
||||
{ token: tokenData.access_token }
|
||||
)
|
||||
const isReady = isReadyForImport(manifest)
|
||||
|
||||
if (!isReady) return newSyncItem
|
||||
|
||||
return await deps.triggerSyncItemAutomation({ id: newSyncItem.id })
|
||||
switch (newSyncItem.accFileExtension.toLowerCase()) {
|
||||
case 'rvt': {
|
||||
return await deps.triggerSyncItemAutomation({ id: newSyncItem.id })
|
||||
}
|
||||
default: {
|
||||
throw new SyncItemUnsupportedFileExtensionError(newSyncItem.accFileExtension)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type GetPaginatedAccSyncItems = (params: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AccSyncItemStatuses } from '@/modules/acc/domain/constants'
|
||||
import { AccSyncItemStatuses } from '@/modules/acc/domain/acc/constants'
|
||||
import type {
|
||||
QueryAllAccSyncItems,
|
||||
UpsertAccSyncItem
|
||||
} from '@/modules/acc/domain/operations'
|
||||
} from '@/modules/acc/domain/acc/operations'
|
||||
import { logger } from '@/observability/logging'
|
||||
|
||||
type OnVersionAdded = (params: {
|
||||
|
||||
@@ -213,7 +213,7 @@ export const localAuthRestApi = (params: { express: Express }) => {
|
||||
|
||||
const user = await authCheck({ token })
|
||||
expect(user).to.be.ok
|
||||
expect(user.email).to.equal(params.email)
|
||||
expect(user.email.toLowerCase()).to.equal(params.email.toLowerCase())
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user