diff --git a/apps/backend/activity/activity.controller.js b/apps/backend/activity/activity.controller.js index 400e6a996..f74ccc99f 100644 --- a/apps/backend/activity/activity.controller.js +++ b/apps/backend/activity/activity.controller.js @@ -19,7 +19,7 @@ const { getOutlineLevels, isOutlineActivity } = schema; const logger = createLogger('activity:controller'); const log = (msg) => logger.info(msg.replace(/\n/g, ' ')); -function list({ repository, query, opts }, res) { +async function list({ repository, query, opts }, res) { if (!query.detached) opts.where.detached = false; if (query.outlineOnly) { // Include deleted if published and deletion is not published yet @@ -35,7 +35,9 @@ function list({ repository, query, opts }, res) { }, ]; } - return repository.getActivities(opts).then((data) => res.json({ data })); + const activities = await repository.getActivities(opts); + await Promise.all(activities.map((it) => it.processEmbeddedElements())); + return res.json({ data: activities }); } function create({ user, repository, body }, res) { diff --git a/apps/backend/activity/activity.model.js b/apps/backend/activity/activity.model.js index 33155ec7f..ea4b04eec 100644 --- a/apps/backend/activity/activity.model.js +++ b/apps/backend/activity/activity.model.js @@ -1,12 +1,15 @@ import { Model, Op } from 'sequelize'; import { schema, workflow } from '@tailor-cms/config'; import { Activity as Events } from '@tailor-cms/common/src/sse.js'; +import { ContentContainerType } from '@tailor-cms/content-container-collection/types.js'; +import calculatePosition from '#shared/util/calculatePosition.js'; +import contentElementHooks from '../content-element/hooks.js'; +import hooks from './hooks.js'; import isEmpty from 'lodash/isEmpty.js'; import map from 'lodash/map.js'; import pick from 'lodash/pick.js'; import Promise from 'bluebird'; -import hooks from './hooks.js'; -import calculatePosition from '#shared/util/calculatePosition.js'; + import { detectMissingReferences, removeReference, @@ -469,6 +472,16 @@ class Activity extends Model { }); } + async processEmbeddedElements() { + if (this.type !== ContentContainerType.CollectionItemContent) + return Promise.resolve(this); + for (const key of Object.keys(this.data)) { + if (!this.data?.[key]?.embedded) return; + contentElementHooks.applyFetchHooks(this.data[key]); + } + return this; + } + touch(transaction) { return this.update({ modifiedAt: new Date() }, { transaction }); } diff --git a/apps/backend/content-element/content-element.controller.js b/apps/backend/content-element/content-element.controller.js index 7dd8f64b6..16cba1ad1 100644 --- a/apps/backend/content-element/content-element.controller.js +++ b/apps/backend/content-element/content-element.controller.js @@ -3,6 +3,9 @@ import pick from 'lodash/pick.js'; import { createError } from '#shared/error/helpers.js'; import db from '#shared/database/index.js'; +import PluginRegistry from '#shared/content-plugins/index.js'; + +const { elementRegistry } = PluginRegistry; const { NOT_FOUND } = StatusCodes; const { Activity, ContentElement } = db; @@ -62,6 +65,19 @@ async function reorder({ body, contentElement }, res) { return res.json({ data: contentElement }); } +async function rpc({ contentElement, user, repository, body, params }, res) { + const { procedure } = params; + const { type } = contentElement; + const handler = elementRegistry.getProcedure(type, procedure); + if (!handler) { + const error = `Procedure "${procedure}" not found for element type "${type}"`; + return res.status(StatusCodes.NOT_FOUND).json({ error }); + } + const context = { userId: user.id, repository }; + const result = await handler(contentElement, body, { context }); + return res.json({ data: result }); +} + /** * Link element from another repository into this repository. * Creates a linked copy that receives auto-sync updates from source. @@ -131,6 +147,7 @@ export default { patch, remove, reorder, + rpc, link, unlink, getSource, diff --git a/apps/backend/content-element/index.js b/apps/backend/content-element/index.js index f511429f0..21014764c 100644 --- a/apps/backend/content-element/index.js +++ b/apps/backend/content-element/index.js @@ -23,6 +23,7 @@ router.post('/:elementId/reorder', ctrl.reorder); router.post('/:elementId/unlink', ctrl.unlink); router.get('/:elementId/source', ctrl.getSource); router.get('/:elementId/copies', ctrl.getCopies); +router.post('/:elementId/rpc/:procedure', ctrl.rpc); function getContentElement(req, _res, next, elementId) { if (!Number.isInteger(Number(elementId))) { diff --git a/apps/backend/package.json b/apps/backend/package.json index 9e2e155c3..51f7cee11 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -13,9 +13,9 @@ "#storage": "./repository/storage.js" }, "scripts": { - "dev": "node --watch --import ./script/preflight.js --experimental-strip-types ./index.ts || exit 0", - "start": "node --import ./script/preflight.js --experimental-strip-types ./index.ts", - "start:docker": "node --experimental-strip-types ./index.ts", + "dev": "node --watch --import ./script/preflight.js --experimental-transform-types ./index.ts || exit 0", + "start": "node --import ./script/preflight.js --experimental-transform-types ./index.ts", + "start:docker": "node --experimental-transform-types ./index.ts", "db": "node --import ./script/preflight.js ./script/sequelize.js", "db:reset": "pnpm db drop && pnpm db create && pnpm db migrate", "db:seed": "pnpm db seed:all", diff --git a/apps/backend/shared/content-plugins/elementRegistry.js b/apps/backend/shared/content-plugins/elementRegistry.js index b303c678c..e7b19dff2 100644 --- a/apps/backend/shared/content-plugins/elementRegistry.js +++ b/apps/backend/shared/content-plugins/elementRegistry.js @@ -8,6 +8,7 @@ class ElementsRegistry { constructor() { this._registry = elements; this._hooks = {}; + this._procedures = {}; this._aiSchemas = {}; } @@ -23,6 +24,7 @@ class ElementsRegistry { Object.assign(this._aiSchemas, { [it.type]: it.ai, }); + if (it.procedures) this._procedures[it.type] = it.procedures; }); } @@ -43,6 +45,16 @@ class ElementsRegistry { ); }; } + + getProcedure(elementType, procedure) { + const handlers = this._procedures[elementType]; + if (!handlers || !handlers[procedure]) return; + const services = { config: pick(config, ['tce']), storage }; + return (element, payload, options) => { + const context = options?.context || {}; + return handlers[procedure](element, { ...services, context }, payload); + }; + } } export default new ElementsRegistry(); diff --git a/apps/backend/shared/storage/helpers.js b/apps/backend/shared/storage/helpers.js index 1c5553e76..1ac58f38a 100644 --- a/apps/backend/shared/storage/helpers.js +++ b/apps/backend/shared/storage/helpers.js @@ -12,6 +12,7 @@ const isLegacyQuestion = (element) => !isComposite(element) && !!get(element, 'data.question'); const isStorageAsset = (v) => isString(v) && v.startsWith(config.protocol); const extractStorageKey = (v) => v.substr(config.protocol.length, v.length); +const isContentElement = (v) => !!get(v, 'data'); async function resolveAssetsMap(element) { if (!get(element, 'data.assets')) return element; @@ -25,24 +26,28 @@ async function resolveAssetsMap(element) { return element; } +/** + * Resolves a single meta value with storage URL - mutates in place and returns value + */ +async function resolveMeta(element) { + const url = get(element, 'url'); + if (url && isStorageAsset(url)) { + element.publicUrl = await storage.getFileUrl(extractStorageKey(url)); + } + return element; +} + async function resolveMetaMap(element) { const meta = Object.values(element.meta || {}); - await Promise.all( - meta.map(async (value) => { - const url = get(value, 'url'); - if (!url || !isStorageAsset(url)) return Promise.resolve(); - value.publicUrl = await storage.getFileUrl(extractStorageKey(url)); - }), - ); + await Promise.all(meta.map(resolveMeta)); return element; } -function resolveStatics(element) { - return isComposite(element) - ? resolveComposite(element) - : isLegacyQuestion(element) - ? resolveLegacyQuestion(element) - : resolvePrimitive(element); +async function resolveStatics(element) { + if (!isContentElement(element)) return resolveMeta(element); + if (isComposite(element)) return resolveComposite(element); + if (isLegacyQuestion(element)) return resolveLegacyQuestion(element); + return resolvePrimitive(element); } async function resolvePrimitive(primitive) { diff --git a/apps/frontend/app/api/contentElement.js b/apps/frontend/app/api/contentElement.js index fa2953236..9caf9263c 100644 --- a/apps/frontend/app/api/contentElement.js +++ b/apps/frontend/app/api/contentElement.js @@ -6,6 +6,8 @@ const urls = { root: (repositoryId) => `${urls.repository(repositoryId)}/content-elements`, resource: (repositoryId, id) => `${urls.root(repositoryId)}/${id}`, reorder: (repositoryId, id) => `${urls.resource(repositoryId, id)}/reorder`, + rpc: (repositoryId, id, procedure) => + `${urls.resource(repositoryId, id)}/rpc/${procedure}`, link: (repositoryId) => `${urls.root(repositoryId)}/link`, unlink: (repositoryId, id) => `${urls.resource(repositoryId, id)}/unlink`, source: (repositoryId, id) => `${urls.resource(repositoryId, id)}/source`, @@ -34,6 +36,12 @@ function remove(repositoryId, id) { return request.delete(urls.resource(repositoryId, id)); } +function rpc(repositoryId, id, procedure, payload = {}) { + return request + .post(urls.rpc(repositoryId, id, procedure), payload) + .then(extractData); +} + function link(repositoryId, payload) { return request.post(urls.link(repositoryId), payload).then(extractData); } @@ -56,6 +64,7 @@ export default { patch, reorder, remove, + rpc, link, unlink, getSource, diff --git a/apps/frontend/app/components/common/AppBar.vue b/apps/frontend/app/components/common/AppBar.vue index d11a16bca..d841a7425 100644 --- a/apps/frontend/app/components/common/AppBar.vue +++ b/apps/frontend/app/components/common/AppBar.vue @@ -154,7 +154,7 @@ const repositoryStore = useRepositoryStore(); const currentRepositoryStore = useCurrentRepository(); const route = useRoute(); -const { repository } = storeToRefs(currentRepositoryStore); +const { repository, isCollection } = storeToRefs(currentRepositoryStore); const routes = computed(() => { const items = [ @@ -170,7 +170,7 @@ const routes = computed(() => { if (!authStore.hasAdminAccess) items.pop(); if (repository.value) { items.unshift({ - name: `${repository.value.name} structure`, + name: `${repository.value.name} ${isCollection.value ? 'items' : 'structure'}`, to: `/repository/${repository.value?.id}/root/structure`, icon: 'mdi-file-tree-outline', }); diff --git a/apps/frontend/app/components/editor/ActivityContent/ContainerList.vue b/apps/frontend/app/components/editor/ActivityContent/ContainerList.vue index d950fe0ad..6116c8b9f 100644 --- a/apps/frontend/app/components/editor/ActivityContent/ContainerList.vue +++ b/apps/frontend/app/components/editor/ActivityContent/ContainerList.vue @@ -38,6 +38,7 @@ @delete:subcontainer="requestContainerDeletion" @reorder:element="reorderContentElements" @save:element="saveContentElements" + @update:container="updateContainer" @update:element="(val: any) => saveContentElements([val])" @update:subcontainer="activityStore.update" /> @@ -158,6 +159,15 @@ const addContainer = async (data: Record = {}) => { emit('createdContainer', payload); }; +const updateContainer = async (container: any) => { + try { + await activityStore.update(container); + showNotification(`${capitalize(name.value)} saved`); + } catch { + showNotification(`Failed to save ${name.value}`); + } +}; + const saveContentElements = (elements: ContentElement[]) => { const contentElements = castArray(elements); return BBPromise.map(contentElements, (element) => diff --git a/apps/frontend/app/components/editor/ActivityContent/index.vue b/apps/frontend/app/components/editor/ActivityContent/index.vue index 623e30de1..ea23caf52 100644 --- a/apps/frontend/app/components/editor/ActivityContent/index.vue +++ b/apps/frontend/app/components/editor/ActivityContent/index.vue @@ -163,6 +163,7 @@ const createActivity = async (payload: any) => { provide('$editorBus', editorChannel); provide('$eventBus', $eventBus); provide('$storageService', storageService); +provide('$rpc', contentElementStore.rpc); if (config.props.aiUiEnabled) { provide('$doTheMagic', doTheMagic); provide('$createActivity', createActivity); diff --git a/apps/frontend/app/components/editor/Sidebar/ElementSidebar/index.vue b/apps/frontend/app/components/editor/Sidebar/ElementSidebar/index.vue index 02d9bb680..9f5600c6a 100644 --- a/apps/frontend/app/components/editor/Sidebar/ElementSidebar/index.vue +++ b/apps/frontend/app/components/editor/Sidebar/ElementSidebar/index.vue @@ -15,24 +15,31 @@ import { schema } from '@tailor-cms/config'; import ElementMeta from './ElementMeta/index.vue'; import { exposedApi } from '@/api'; +import { useContentElementStore } from '@/stores/content-elements'; const eventBus = inject('$eventBus') as any; const authStore = useAuthStore(); +const { rpc } = useContentElementStore(); const storageService = useStorageService(); interface Props { element: ContentElement; metadata?: any; } + const props = withDefaults(defineProps(), { metadata: () => ({}), }); +const { repositoryId, id: elementId } = props.element; const elementBus = eventBus.channel(`element:${getElementId(props.element)}`); const editorChannel = eventBus.channel('editor'); provide('$elementBus', elementBus); provide('$editorBus', editorChannel); provide('$storageService', storageService); +provide('$rpc', (procedure: string, payload?: any) => + rpc(repositoryId, elementId, procedure, payload), +); provide('$api', exposedApi); provide('$schemaService', schema); provide('$getCurrentUser', () => authStore.user); diff --git a/apps/frontend/app/components/editor/Sidebar/index.vue b/apps/frontend/app/components/editor/Sidebar/index.vue index 0cd4b5e65..82e45bb37 100644 --- a/apps/frontend/app/components/editor/Sidebar/index.vue +++ b/apps/frontend/app/components/editor/Sidebar/index.vue @@ -79,6 +79,7 @@ import { useDisplay } from 'vuetify'; import ActivityNavigation from './ActivityNavigation.vue'; import ElementSidebar from './ElementSidebar/index.vue'; import ActivityDiscussion from '@/components/repository/Discussion/index.vue'; +import { useCurrentRepository } from '@/stores/current-repository'; const modelValue = defineModel({ required: true }); @@ -95,14 +96,14 @@ const ELEMENT_TAB = 'ELEMENT_TAB'; const { $ceRegistry, $schemaService } = useNuxtApp() as any; const { lgAndUp } = useDisplay(); +const { isCollection } = storeToRefs(useCurrentRepository()); -const selectedTab = ref(BROWSER_TAB); +const defaultTab = isCollection.value ? COMMENTS_TAB : BROWSER_TAB; +const selectedTab = ref(defaultTab); const tabs: any = computed(() => [ - { - name: BROWSER_TAB, - label: 'Browse', - icon: 'file-tree', - }, + ...(!isCollection.value + ? [{ name: BROWSER_TAB, label: 'Browse', icon: 'file-tree' }] + : []), { name: COMMENTS_TAB, label: 'Comments', @@ -142,7 +143,7 @@ watch( return; } if (selectedTab.value !== ELEMENT_TAB) return; - selectedTab.value = BROWSER_TAB; + selectedTab.value = defaultTab; }, ); diff --git a/apps/frontend/app/components/editor/Toolbar/index.vue b/apps/frontend/app/components/editor/Toolbar/index.vue index 2f024e2b7..99f294ca7 100644 --- a/apps/frontend/app/components/editor/Toolbar/index.vue +++ b/apps/frontend/app/components/editor/Toolbar/index.vue @@ -177,7 +177,7 @@ const usersWithActivity = computed(() => { position: absolute; padding: 0 !important; - .v-messages { + .v-messages__message { margin-top: 0.5rem; border-radius: 4px; padding: 0.5rem 0.75rem; diff --git a/apps/frontend/app/components/repository/Outline/CollectionTable.vue b/apps/frontend/app/components/repository/Outline/CollectionTable.vue new file mode 100644 index 000000000..218a8ec8e --- /dev/null +++ b/apps/frontend/app/components/repository/Outline/CollectionTable.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/apps/frontend/app/components/repository/Outline/CreateDialog/index.vue b/apps/frontend/app/components/repository/Outline/CreateDialog/index.vue index ed389f08d..471ee6700 100644 --- a/apps/frontend/app/components/repository/Outline/CreateDialog/index.vue +++ b/apps/frontend/app/components/repository/Outline/CreateDialog/index.vue @@ -7,11 +7,12 @@ >