mirror of
https://github.com/directus/directus.git
synced 2026-02-06 20:25:28 -05:00
🌊 Add Data Flows to Directus 🌊 (#12522)
* Replace attachment with correct icon
* Use standardized options formatting
* Improve preview styling, fix names
* Format IDs of DB read operation as csv
* Remove flow active state from header
* Don't return null for unknown flows
* Fix webhook trigger not showing id
* Fix alignment of attachment
* Make heading secondary if it's the reject handler
* Use flow name in subtitle of operation drawer
* Rename "Create new Operation" -> "Create Operation"
* Make name/key required
* Give name autofocus
* Add "uncaught exception" log message
* Various improvements on operations
* default status to "active"
* Add status dropdown at the bottom of trigger
* put status dot to the right of flow name header
* add toggle status option in context menu
* fix trigger options staging
* fix flow deletion
* show configured operation key on name hover
* prevent block pushing status toggle down
* ensure key is unique between operations in a flow
* allow add new panel when previous one is deleted
* fix staged panels temporarily disappear
The deletion of newTree.id causes it to "disconnect" when saving, thus causing it seemingly disappear. Using a cloneDeep prevents it from mutating the current stagedPanels
* hide key input when in query mode
* add write operation
* undo previous route props change
* include staged panel keys in key validation
* fix key validation logic
* add color to flow & insights
* ensure trigger does not have reject button
* prevent operation key error showing up when saving
* change context menu to Delete Operation
* fix add operation when removed operation is staged
* Hide ID in read preview when in query mode
* fix reject button showing without edit mode
* fix status toggle in flow overview
* simplify request operation methods & allow other
* fix preview function type
* simplify slot syntax
* add manual trigger
* simplify manual trigger handler
* prevent drawer closing on esc
* allow filter config without selecting collection
* improve affordance of add operation button
* fix loner reject button color
* Added emitEvents option to write operation (#13121)
* added emitEvent option toggle to write operation
* Re-gen package-lock
Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
* Clean up active/inactive toggle
* Align arrow to grid
* Visually align border radius of glow
* Tweak padding of footer in panels
* Remove init event
* Combine event triggers into single "Hook" trigger
* Fix mail operation
* Cleanup imports, return undefined for webhook executino flows
* Add border to panel footer
* Upgrade preview of hook trigger
* Don't warn on uncaught flow executions
* Clean up types
* Fix typing of reload
* Default to correct icon
* Add migrations to remove webhooks table
* Remove webhooks
* Update icons for triggers
* Reorganize triggers
* Merge flows and webhooks migrations
* Add permissions option to database read operatoin
* Add permissions configuration to database write
* Remove flow logs in favor of using directus_activity
* Upgrade webhook configuration, fix create operatoin
* Rename validation->condition
* Subdue everything when inactive
* Tweak tests
* Fix the test for real
* Remove circular FK trigger, please MSSQL
* Make things worse to please MSFT
* Add input
* Drop input scope from condition operation
* naming and description changes
* Default flow overview icon color to primary
* add danger styling to delete flow button
* fix hint buttons subdued style
* Hide trigger unlinked resolve btn when not editing
Don't show "check mark icon" or "operation arrow" on empty trigger when NOT in edit mode
* show email "to" value as CSV
* remove unused webhook.preview translation
* Default sort order of overview table to `name`
* Track activity / revisions in flows
* Extract w/ the intent to reuse revisions fetching
* Move Action type to shared to facilitate app use
* [WIP] Start rendering logs drawer from sidebar
* Fix type error (sorry Eron)
* add update operation
* add delete operation
* use parseJSON util in operations
* Add missing fields to flows system data
* Await promise in sleep operation
* fix e2e test missing flows & operations tables
* Add fallback title to flow logs drawer
* Add default value to flow prop for flow-dialog
* Hydrate flows store before moving to details page when creating flow
* Rename CRUD operations to item-*
* Change trigger options subtitle to Trigger Options
* Remove trigger name option
* Fix typescript complaining
* Remove two lines
* Fix notification operation
* Log error when executing a schedule flow
* Fix schedule flow activity tracking
* Fix notification operation when there is no user
* Make permissions for notifications configurable
* Do not drop non null constraint from column that is nullable
* Remove invalid option from activity seed
* Show resolve/reject dot when operation has successor
* Improve flow arrow placement
* Prevent arrow color from flickering
* Fix arrow being stuck when hovered while saving
* Fix arrows not being subdued on lone leaf operations
* Add tooltips to operation handles
* Remove option to trigger flow on init
* Move operation handle tooltip to icon
* Disconnect duplicated operation
* Fix deleting connected operations
* Make delete action name generic in v-workspace-panel
* Use flow-specific wording in flow edit tooltip
* Simplify hint handle check
* Fix deleting first operation
* Use useEditsGuard composable in flow component
* Add asynchronous option to webhook trigger
* Add option to make preview elements copyable
* Add hover transition to panels
* Register operation preview components as operation-preview-*
* Remove selectability of panel header and operation body
* Add return option to filter and operation triggers
* Add missing key
* Remove unnecessary ampersand from URLs in filter tests (#13523)
* Remove unused prop
* New translations en-US.yaml (Polish) (#13524)
* My favorite
* v9.11.1
* v9.11.1
* New translations en-US.yaml (Polish) (#13528)
* New translations en-US.yaml (Czech) (#13541)
* New translations en-US.yaml (Czech) (#13545)
* fix metadata for directus_folders (#13527)
* add `meta` to list responses for OAS (#13531)
* remove existing pasting check on slug values (#13532)
* Add copy button to user id on user info page (#13540)
* New translations en-US.yaml (Czech) (#13547)
* New translations en-US.yaml (Czech) (#13548)
* Added missing "DB_SSL_*_FILE" to the "_FILE" allow list. (#13539)
* Remove workaround in release flow (#13455)
This forces the release workflow to use `node@16.15` which includes `npm@8.5`.
* Remove npmrc files which prevent lockfile creation in workspaces (#13444)
* Remove npmrc files which prevent lockfile creation in workspaces
Since `v8.5.0` npm will detect that it is running inside a workspace and issue commands at the root package.
* Require a minimum npm version of 8.5.0
* Package-lock 🖤
* Don't consider SIGN_OUT an SSO error (#13389)
* Don't consider SIGN_OUT an SSO error
* Add SESSION_EXPIRED as valid reason
* Improve translation for require_value_to_be_set (#13363)
English + Dutch
Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
* Fix field conditions optionDefaults computed property (#13563)
* fix: remove .value from options
* additional ref fix & type/null errors
Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
* `last_page` type was `date` instead of `str` (#13577)
* `last_page` type was `date` instead of `str`
* Update docs/reference/system/users.md
Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
* New Crowdin updates (#13557)
* New translations en-US.yaml (Romanian)
* New translations en-US.yaml (Indonesian)
* New translations en-US.yaml (Spanish, Chile)
* New translations en-US.yaml (Thai)
* New translations en-US.yaml (Spanish, Latin America)
* New translations en-US.yaml (Russian)
* New translations en-US.yaml (Polish)
* New translations en-US.yaml (Swedish)
* New translations en-US.yaml (Turkish)
* New translations en-US.yaml (Portuguese, Brazilian)
* New translations en-US.yaml (French)
* New translations en-US.yaml (Spanish)
* New translations en-US.yaml (Bulgarian)
* New translations en-US.yaml (Catalan)
* New translations en-US.yaml (Danish)
* New translations en-US.yaml (German)
* New translations en-US.yaml (Finnish)
* New translations en-US.yaml (Hungarian)
* New translations en-US.yaml (Chinese Simplified)
* New translations en-US.yaml (Italian)
* New translations en-US.yaml (Slovenian)
* New translations en-US.yaml (Ukrainian)
* New translations en-US.yaml (English, United Kingdom)
* New translations en-US.yaml (English, Canada)
* New translations en-US.yaml (French, Canada)
* New translations en-US.yaml (Croatian)
* New translations en-US.yaml (Czech)
* New translations en-US.yaml (Czech)
* New translations en-US.yaml (Czech)
* New translations en-US.yaml (Czech)
* New translations en-US.yaml (Czech)
* New translations en-US.yaml (Czech)
* New translations en-US.yaml (Czech)
* New translations en-US.yaml (Czech)
* New translations en-US.yaml (Czech)
* New translations en-US.yaml (Czech)
* New translations en-US.yaml (Czech)
* New translations en-US.yaml (Czech)
* New translations en-US.yaml (Czech)
* New translations en-US.yaml (Czech)
* New translations en-US.yaml (Czech)
* Fix validate query number comparison
Ref https://github.com/directus/directus/pull/13492#issuecomment-1138770254
* New translations en-US.yaml (Polish) (#13580)
* add to project (#13581)
* Allow authentication using MSSQL azure-active-directory-service-principal-secret (#11141)
* Extract ignored settings requires by azure authentication
* Change the way to extract initial database settings
* Fix invalid names after extracting from env util
* Replace missing var after solving conflicts
* Add default value to poolconfig
* This should unbreak it
Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
* Create pull_request_template.md
* Update pull_request_template.md
* Add System token interface (#13549)
* Add system token interface
* use system token interface in users token field
* Update placeholder
* move notice below input
* fix clear value interaction
* update placeholder and notice text
* remove unused translation key
* rename class to match current naming
* fix bugs in disabled state and it's UX
Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
Co-authored-by: jaycammarano <jay.cammarano@gmail.com>
* New Crowdin updates (#13586)
* New translations en-US.yaml (Romanian)
* New translations en-US.yaml (Indonesian)
* New translations en-US.yaml (Spanish, Chile)
* New translations en-US.yaml (Thai)
* New translations en-US.yaml (Serbian (Latin))
* New translations en-US.yaml (Spanish, Latin America)
* New translations en-US.yaml (Russian)
* New translations en-US.yaml (Polish)
* New translations en-US.yaml (Portuguese)
* New translations en-US.yaml (Swedish)
* New translations en-US.yaml (Turkish)
* New translations en-US.yaml (Estonian)
* New translations en-US.yaml (Portuguese, Brazilian)
* New translations en-US.yaml (French)
* New translations en-US.yaml (Spanish)
* New translations en-US.yaml (Bulgarian)
* New translations en-US.yaml (Catalan)
* New translations en-US.yaml (Czech)
* New translations en-US.yaml (Danish)
* New translations en-US.yaml (German)
* New translations en-US.yaml (Finnish)
* New translations en-US.yaml (Hungarian)
* New translations en-US.yaml (Chinese Simplified)
* New translations en-US.yaml (Italian)
* New translations en-US.yaml (Japanese)
* New translations en-US.yaml (Dutch)
* New translations en-US.yaml (Slovenian)
* New translations en-US.yaml (Ukrainian)
* New translations en-US.yaml (English, United Kingdom)
* New translations en-US.yaml (English, Canada)
* New translations en-US.yaml (French, Canada)
* New translations en-US.yaml (Croatian)
* Add project_url to defaultTemplateData (#12033)
Might be useful in template footers.
* Update items.md
* Rename panel to tile
* Rename preview->overview
* Style flow log detail
* Log all parsed options
* Show used options in revision
* Finish log detail drawer
* new create flow flow
* fix firstOpen for new create flow flow
* update field layout for create flow form
* Fix TS typing
* Fix missing import
* Append random hash to key when duplicating operations
* Revert "Remove webhooks"
This reverts commit 044d3d8b66.
* Don't delete webhooks
* Make option preview selectable
* Prevent invalid linking when duplicating operations after creating operations
* Prevent sending of malformed query filter when deleting flow
* implement new manual trigger
* simplify payload for manual trigger
* use buttons instead of dropdown + run button
* add async option & loading state
* add collection check to manual trigger
* emit refresh after running flow in sidebar
* Add cross-instance messenger for reloading
* Use flow drawer for both create and edit
* Add manual trigger flow permissions to app recommended
Ensures that non-admin users can actually see the flows sidebar detail
* Add basic logs redaction
* Remove endpoint to trigger an operation
* Allow configuring location for manual trigger
* Rename "hook" trigger to "event"
* Tweak icon size
* Fix create flow button in info notice
* Make activity tracking full width
* Tweak descriptions
* Too long for comfort
* Remove mode option from item-* operations
* fix manual trigger empty collections option
* Add no-logs-yet message in sidebar detail
* Reset trigger options on change of trigger
* Rename `data`->`payload`
* Remove mode from preview of item-* operations
* Return operation options with "{{key}}" as raw value
* Show flow name in delete confirmation
* Add default generated name/key to new operations
* shorten arrows WIP
still needs icons moved
* rename note to description
* fix hint button icons
* update event hook type labels
* Animate resolve/reject arrow hints
* reorder event types
* Use x+4 instead of x+6 for new operation panels
* compress options to fit 6 lines in operation
* update hook labels
* animate trigger box shadow
sorry, rijk!
* update (global) disabled button color 1 shade
* Format times nicer
* Add placeholder for query
* add a note
* Fix formatting for curly brackets in translations
* Add item Create/update payload placeholder
* Add placeholder to user uuid
* Accept either null or undefined for nullable operation options
* Allow any string as request body
* Add more placeholders
* Consolidate filterScope and actionScope, filterCollections and actionCollections
* Rename flow note to description in types
Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
Co-authored-by: Brainslug <br41nslug@users.noreply.github.com>
Co-authored-by: Ben Haynes <ben@rngr.org>
Co-authored-by: Nicola Krumschmidt <nicola.krumschmidt@freenet.de>
Co-authored-by: Aiden Foxx <aiden.foxx.mail@gmail.com>
Co-authored-by: Jan-Willem <jan-willem@qdentity.nl>
Co-authored-by: Yasser Lahbibi <yasser.lahbibi@apenhet.com>
Co-authored-by: Louis <32719150+louisgavalda@users.noreply.github.com>
Co-authored-by: Jay Cammarano <67079013+jaycammarano@users.noreply.github.com>
Co-authored-by: Erick Torres <ericktorresh@gmail.com>
Co-authored-by: jaycammarano <jay.cammarano@gmail.com>
Co-authored-by: Yuriy Belenko <yura-bely@mail.ru>
Co-authored-by: ian <licitdev@gmail.com>
This commit is contained in:
@@ -252,6 +252,7 @@
|
||||
@download="download"
|
||||
@refresh="refresh"
|
||||
/>
|
||||
<flow-sidebar-detail location="collection" :collection="collection" :selection="selection" @refresh="refresh" />
|
||||
</template>
|
||||
|
||||
<v-dialog :model-value="deleteError !== null">
|
||||
@@ -281,6 +282,7 @@ import LayoutSidebarDetail from '@/views/private/components/layout-sidebar-detai
|
||||
import ArchiveSidebarDetail from '@/views/private/components/archive-sidebar-detail';
|
||||
import RefreshSidebarDetail from '@/views/private/components/refresh-sidebar-detail';
|
||||
import ExportSidebarDetail from '@/views/private/components/export-sidebar-detail.vue';
|
||||
import FlowSidebarDetail from '@/views/private/components/flow-sidebar-detail.vue';
|
||||
import SearchInput from '@/views/private/components/search-input';
|
||||
import BookmarkAdd from '@/views/private/components/bookmark-add';
|
||||
import { useRouter } from 'vue-router';
|
||||
@@ -307,6 +309,7 @@ export default defineComponent({
|
||||
ArchiveSidebarDetail,
|
||||
RefreshSidebarDetail,
|
||||
ExportSidebarDetail,
|
||||
FlowSidebarDetail,
|
||||
},
|
||||
props: {
|
||||
collection: {
|
||||
|
||||
@@ -197,6 +197,12 @@
|
||||
:primary-key="internalPrimaryKey"
|
||||
:allowed="shareAllowed"
|
||||
/>
|
||||
<flow-sidebar-detail
|
||||
v-if="isNew === false && internalPrimaryKey"
|
||||
location="item"
|
||||
:collection="collection"
|
||||
:primary-key="internalPrimaryKey"
|
||||
/>
|
||||
</template>
|
||||
</private-view>
|
||||
</template>
|
||||
@@ -211,6 +217,7 @@ import { useCollection } from '@directus/shared/composables';
|
||||
import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail';
|
||||
import CommentsSidebarDetail from '@/views/private/components/comments-sidebar-detail';
|
||||
import SharesSidebarDetail from '@/views/private/components/shares-sidebar-detail';
|
||||
import FlowSidebarDetail from '@/views/private/components/flow-sidebar-detail.vue';
|
||||
import useItem from '@/composables/use-item';
|
||||
import SaveOptions from '@/views/private/components/save-options';
|
||||
import useShortcut from '@/composables/use-shortcut';
|
||||
@@ -229,6 +236,7 @@ export default defineComponent({
|
||||
RevisionsDrawerDetail,
|
||||
CommentsSidebarDetail,
|
||||
SharesSidebarDetail,
|
||||
FlowSidebarDetail,
|
||||
SaveOptions,
|
||||
},
|
||||
props: {
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
|
||||
<v-card-text>
|
||||
<div class="fields">
|
||||
<v-input v-model="values.name" autofocus :placeholder="t('dashboard_name')" />
|
||||
<v-input v-model="values.name" class="full" autofocus :placeholder="t('dashboard_name')" />
|
||||
<interface-select-icon :value="values.icon" @input="values.icon = $event" />
|
||||
<interface-select-color width="half" :value="values.color" @input="values.color = $event" />
|
||||
<v-input v-model="values.note" class="full" :placeholder="t('note')" />
|
||||
</div>
|
||||
</v-card-text>
|
||||
@@ -59,6 +60,7 @@ export default defineComponent({
|
||||
const values = reactive({
|
||||
name: props.dashboard?.name ?? null,
|
||||
icon: props.dashboard?.icon ?? 'dashboard',
|
||||
color: props.dashboard?.color ?? null,
|
||||
note: props.dashboard?.note ?? null,
|
||||
});
|
||||
|
||||
@@ -68,6 +70,7 @@ export default defineComponent({
|
||||
if (isEqual(newValue, oldValue) === false) {
|
||||
values.name = props.dashboard?.name ?? null;
|
||||
values.icon = props.dashboard?.icon ?? 'dashboard';
|
||||
values.color = props.dashboard?.color ?? null;
|
||||
values.note = props.dashboard?.note ?? null;
|
||||
}
|
||||
}
|
||||
@@ -94,7 +97,7 @@ export default defineComponent({
|
||||
router.push(`/insights/${response.data.data.id}`);
|
||||
}
|
||||
emit('update:modelValue', false);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</v-button>
|
||||
|
||||
<v-list-item v-for="navItem in navItems" v-else :key="navItem.to" :to="navItem.to">
|
||||
<v-list-item-icon><v-icon :name="navItem.icon" /></v-list-item-icon>
|
||||
<v-list-item-icon><v-icon :name="navItem.icon" :color="navItem.color" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow :text="navItem.name" />
|
||||
</v-list-item-content>
|
||||
@@ -31,6 +31,7 @@ export default defineComponent({
|
||||
const navItems = computed(() =>
|
||||
insightsStore.dashboards.map((dashboard: Dashboard) => ({
|
||||
icon: dashboard.icon,
|
||||
color: dashboard.color,
|
||||
name: dashboard.name,
|
||||
to: `/insights/${dashboard.id}`,
|
||||
}))
|
||||
|
||||
@@ -1,514 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="panel"
|
||||
:style="positionStyling"
|
||||
:class="{
|
||||
editing: editMode,
|
||||
dragging,
|
||||
'br-tl': dragging || panel.borderRadius[0],
|
||||
'br-tr': dragging || panel.borderRadius[1],
|
||||
'br-br': dragging || panel.borderRadius[2],
|
||||
'br-bl': dragging || panel.borderRadius[3],
|
||||
}"
|
||||
data-move
|
||||
@pointerdown="onPointerDown('move', $event)"
|
||||
>
|
||||
<div v-if="panel.show_header" class="header">
|
||||
<v-icon class="icon" :style="iconColor" :name="headerIcon" />
|
||||
<v-text-overflow class="name selectable" :text="panel.name || ''" />
|
||||
<div class="spacer" />
|
||||
<v-icon v-if="panel.note" v-tooltip="panel.note" class="note" name="info" />
|
||||
</div>
|
||||
|
||||
<div v-if="editMode" class="edit-actions" @pointerdown.stop>
|
||||
<v-icon
|
||||
v-tooltip="t('edit')"
|
||||
class="edit-icon"
|
||||
name="edit"
|
||||
clickable
|
||||
@click.stop="$router.push(`/insights/${panel.dashboard}/${panel.id}`)"
|
||||
/>
|
||||
|
||||
<v-menu placement="bottom-end" show-arrow>
|
||||
<template #activator="{ toggle }">
|
||||
<v-icon class="more-icon" name="more_vert" clickable @click="toggle" />
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item clickable :disabled="panel.id.startsWith('_')" @click="$emit('move')">
|
||||
<v-list-item-icon>
|
||||
<v-icon class="move-icon" name="input" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
{{ t('copy_to') }}
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item clickable @click="$emit('duplicate')">
|
||||
<v-list-item-icon>
|
||||
<v-icon name="control_point_duplicate" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>{{ t('duplicate') }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="delete-action" clickable @click="$emit('delete')">
|
||||
<v-list-item-icon>
|
||||
<v-icon name="delete" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>{{ t('delete_panel') }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<div class="resize-details">
|
||||
({{ positioning.x - 1 }}:{{ positioning.y - 1 }}) {{ positioning.width }}×{{ positioning.height }}
|
||||
</div>
|
||||
|
||||
<div v-if="editMode" class="resize-handlers">
|
||||
<div class="top" @pointerdown.stop="onPointerDown('resize-top', $event)" />
|
||||
<div class="right" @pointerdown.stop="onPointerDown('resize-right', $event)" />
|
||||
<div class="bottom" @pointerdown.stop="onPointerDown('resize-bottom', $event)" />
|
||||
<div class="left" @pointerdown.stop="onPointerDown('resize-left', $event)" />
|
||||
<div class="top-left" @pointerdown.stop="onPointerDown('resize-top-left', $event)" />
|
||||
<div class="top-right" @pointerdown.stop="onPointerDown('resize-top-right', $event)" />
|
||||
<div class="bottom-right" @pointerdown.stop="onPointerDown('resize-bottom-right', $event)" />
|
||||
<div class="bottom-left" @pointerdown.stop="onPointerDown('resize-bottom-left', $event)" />
|
||||
</div>
|
||||
|
||||
<div class="panel-content" :class="{ 'has-header': panel.show_header }">
|
||||
<component
|
||||
:is="`panel-${panel.type}`"
|
||||
v-bind="panel.options"
|
||||
:id="panel.id"
|
||||
:show-header="panel.show_header"
|
||||
:height="panel.height"
|
||||
:width="panel.width"
|
||||
:dashboard="panel.dashboard"
|
||||
:now="now"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { getPanels } from '@/panels';
|
||||
import { Panel } from '@directus/shared/types';
|
||||
import { defineComponent, PropType, computed, ref, reactive } from 'vue';
|
||||
import { throttle, omit } from 'lodash';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Panel',
|
||||
props: {
|
||||
panel: {
|
||||
type: Object as PropType<Panel>,
|
||||
required: true,
|
||||
},
|
||||
editMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
now: {
|
||||
type: Date,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['update', 'move', 'duplicate', 'delete'],
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
const { panels } = getPanels();
|
||||
|
||||
const panelTypeInfo = computed(() => {
|
||||
return panels.value.find((panelConfig) => {
|
||||
return panelConfig.id === props.panel.type;
|
||||
});
|
||||
});
|
||||
|
||||
const headerIcon = computed(() => {
|
||||
return props.panel.icon ? props.panel.icon : panelTypeInfo.value.icon;
|
||||
});
|
||||
/**
|
||||
* When drag-n-dropping for positioning/resizing, we're
|
||||
*/
|
||||
const editedPosition = reactive<Partial<Panel>>({
|
||||
position_x: undefined,
|
||||
position_y: undefined,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
});
|
||||
|
||||
const { onPointerDown, onPointerUp, onPointerMove, dragging } = useDragDrop();
|
||||
|
||||
const positioning = computed(() => {
|
||||
if (dragging.value) {
|
||||
return {
|
||||
x: editedPosition.position_x ?? props.panel.position_x,
|
||||
y: editedPosition.position_y ?? props.panel.position_y,
|
||||
width: editedPosition.width ?? props.panel.width,
|
||||
height: editedPosition.height ?? props.panel.height,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x: props.panel.position_x,
|
||||
y: props.panel.position_y,
|
||||
width: props.panel.width,
|
||||
height: props.panel.height,
|
||||
};
|
||||
});
|
||||
|
||||
const positionStyling = computed(() => {
|
||||
if (dragging.value) {
|
||||
return {
|
||||
'--pos-x': editedPosition.position_x ?? props.panel.position_x,
|
||||
'--pos-y': editedPosition.position_y ?? props.panel.position_y,
|
||||
'--width': editedPosition.width ?? props.panel.width,
|
||||
'--height': editedPosition.height ?? props.panel.height,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
'--pos-x': props.panel.position_x,
|
||||
'--pos-y': props.panel.position_y,
|
||||
'--width': props.panel.width,
|
||||
'--height': props.panel.height,
|
||||
};
|
||||
});
|
||||
|
||||
const iconColor = computed(() => ({
|
||||
'--v-icon-color': props.panel.color || 'var(--primary)',
|
||||
}));
|
||||
|
||||
return {
|
||||
headerIcon,
|
||||
positioning,
|
||||
positionStyling,
|
||||
iconColor,
|
||||
onPointerDown,
|
||||
onPointerUp,
|
||||
onPointerMove,
|
||||
dragging,
|
||||
editedPosition,
|
||||
t,
|
||||
omit,
|
||||
};
|
||||
|
||||
function useDragDrop() {
|
||||
const dragging = ref(false);
|
||||
|
||||
let pointerStartPosX = 0;
|
||||
let pointerStartPosY = 0;
|
||||
|
||||
let panelStartPosX = 0;
|
||||
let panelStartPosY = 0;
|
||||
let panelStartWidth = 0;
|
||||
let panelStartHeight = 0;
|
||||
|
||||
type Operation =
|
||||
| 'move'
|
||||
| 'resize-top'
|
||||
| 'resize-right'
|
||||
| 'resize-bottom'
|
||||
| 'resize-left'
|
||||
| 'resize-top-left'
|
||||
| 'resize-top-right'
|
||||
| 'resize-bottom-right'
|
||||
| 'resize-bottom-left';
|
||||
|
||||
let operation: Operation = 'move';
|
||||
|
||||
const onPointerMove = throttle((event: PointerEvent) => {
|
||||
if (props.editMode === false) return;
|
||||
if (dragging.value === false) return;
|
||||
|
||||
const pointerDeltaX = event.pageX - pointerStartPosX;
|
||||
const pointerDeltaY = event.pageY - pointerStartPosY;
|
||||
|
||||
const gridDeltaX = Math.round(pointerDeltaX / 20);
|
||||
const gridDeltaY = Math.round(pointerDeltaY / 20);
|
||||
|
||||
if (operation === 'move') {
|
||||
editedPosition.position_x = panelStartPosX + gridDeltaX;
|
||||
editedPosition.position_y = panelStartPosY + gridDeltaY;
|
||||
|
||||
if (editedPosition.position_x < 1) editedPosition.position_x = 1;
|
||||
if (editedPosition.position_y < 1) editedPosition.position_y = 1;
|
||||
} else {
|
||||
if (operation.includes('top')) {
|
||||
editedPosition.height = panelStartHeight - gridDeltaY;
|
||||
editedPosition.position_y = panelStartPosY + gridDeltaY;
|
||||
}
|
||||
|
||||
if (operation.includes('right')) {
|
||||
editedPosition.width = panelStartWidth + gridDeltaX;
|
||||
}
|
||||
|
||||
if (operation.includes('bottom')) {
|
||||
editedPosition.height = panelStartHeight + gridDeltaY;
|
||||
}
|
||||
|
||||
if (operation.includes('left')) {
|
||||
editedPosition.width = panelStartWidth - gridDeltaX;
|
||||
editedPosition.position_x = panelStartPosX + gridDeltaX;
|
||||
}
|
||||
|
||||
const minWidth = panelTypeInfo.value?.minWidth || 6;
|
||||
const minHeight = panelTypeInfo.value?.minHeight || 6;
|
||||
|
||||
if (editedPosition.position_x && editedPosition.position_x < 1) editedPosition.position_x = 1;
|
||||
if (editedPosition.position_y && editedPosition.position_y < 1) editedPosition.position_y = 1;
|
||||
if (editedPosition.width && editedPosition.width < minWidth) editedPosition.width = minWidth;
|
||||
if (editedPosition.height && editedPosition.height < minHeight) editedPosition.height = minHeight;
|
||||
}
|
||||
}, 20);
|
||||
|
||||
return { dragging, onPointerDown, onPointerUp, onPointerMove };
|
||||
|
||||
function onPointerDown(op: Operation, event: PointerEvent) {
|
||||
if (props.editMode === false) return;
|
||||
|
||||
operation = op;
|
||||
|
||||
dragging.value = true;
|
||||
|
||||
pointerStartPosX = event.pageX;
|
||||
pointerStartPosY = event.pageY;
|
||||
|
||||
panelStartPosX = props.panel.position_x;
|
||||
panelStartPosY = props.panel.position_y;
|
||||
|
||||
panelStartWidth = props.panel.width;
|
||||
panelStartHeight = props.panel.height;
|
||||
|
||||
window.addEventListener('pointerup', onPointerUp);
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging.value = false;
|
||||
if (props.editMode === false) return;
|
||||
emit('update', editedPosition);
|
||||
window.removeEventListener('pointerup', onPointerUp);
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
|
||||
editedPosition.position_x = undefined;
|
||||
editedPosition.position_y = undefined;
|
||||
editedPosition.width = undefined;
|
||||
editedPosition.height = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
--pos-x: 1;
|
||||
--pos-y: 1;
|
||||
--width: 6;
|
||||
--height: 6;
|
||||
|
||||
position: relative;
|
||||
display: block;
|
||||
grid-row: var(--pos-y) / span var(--height);
|
||||
grid-column: var(--pos-x) / span var(--width);
|
||||
background-color: var(--background-page);
|
||||
border: 1px solid var(--border-subdued);
|
||||
box-shadow: 0 0 0 1px var(--border-subdued);
|
||||
}
|
||||
|
||||
.panel:hover {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.panel.editing {
|
||||
border-color: var(--border-normal);
|
||||
box-shadow: 0 0 0 1px var(--border-normal);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.panel.editing:hover {
|
||||
border-color: var(--border-normal-alt);
|
||||
box-shadow: 0 0 0 1px var(--border-normal-alt);
|
||||
}
|
||||
|
||||
.panel.editing.dragging {
|
||||
z-index: 3 !important;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 1px var(--primary);
|
||||
}
|
||||
|
||||
.resize-details {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
padding: 17px 14px;
|
||||
color: var(--foreground-subdued);
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
font-family: var(--family-monospace);
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
text-align: right;
|
||||
background-color: var(--background-page);
|
||||
border-top-right-radius: var(--border-radius-outline);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--transition), color var(--fast) var(--transition);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.panel.editing.dragging .resize-details {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-content.has-header {
|
||||
height: calc(100% - 48px);
|
||||
}
|
||||
|
||||
.panel.editing .panel-content {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: var(--foreground-normal-alt);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
font-family: var(--family-sans-serif);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.more-icon,
|
||||
.edit-icon,
|
||||
.note {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
--v-icon-color-hover: var(--foreground-normal);
|
||||
}
|
||||
|
||||
.delete-action {
|
||||
--v-list-item-color: var(--danger);
|
||||
--v-list-item-color-hover: var(--danger);
|
||||
--v-list-item-icon-color: var(--danger);
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 12px 12px 8px;
|
||||
background-color: var(--background-page);
|
||||
border-top-right-radius: var(--border-radius-outline);
|
||||
}
|
||||
|
||||
.resize-handlers div {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.resize-handlers .top {
|
||||
top: -3px;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .right {
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 10px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .bottom {
|
||||
bottom: -3px;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .left {
|
||||
top: 0;
|
||||
left: -3px;
|
||||
width: 10px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .top-left {
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .top-right {
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .bottom-right {
|
||||
right: -3px;
|
||||
bottom: -3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .bottom-left {
|
||||
bottom: -3px;
|
||||
left: -3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.br-tl {
|
||||
border-top-left-radius: var(--border-radius-outline);
|
||||
}
|
||||
|
||||
.br-tr {
|
||||
border-top-right-radius: var(--border-radius-outline);
|
||||
}
|
||||
|
||||
.br-br {
|
||||
border-bottom-right-radius: var(--border-radius-outline);
|
||||
}
|
||||
|
||||
.br-bl {
|
||||
border-bottom-left-radius: var(--border-radius-outline);
|
||||
}
|
||||
</style>
|
||||
@@ -1,174 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="workspace-padding-box"
|
||||
:class="{ editing: editMode }"
|
||||
:style="{ width: workspaceBoxSize.width + 'px', height: workspaceBoxSize.height + 'px' }"
|
||||
>
|
||||
<div
|
||||
class="workspace"
|
||||
:style="{
|
||||
transform: `scale(${zoomScale})`,
|
||||
width: workspaceSize.width + 'px',
|
||||
height: workspaceSize.height + 'px',
|
||||
}"
|
||||
>
|
||||
<insights-panel
|
||||
v-for="panel in panels"
|
||||
:key="panel.id"
|
||||
:panel="panel"
|
||||
:edit-mode="editMode"
|
||||
:now="now"
|
||||
@update="$emit('update', { edits: $event, id: panel.id })"
|
||||
@move="$emit('move', panel.id)"
|
||||
@delete="$emit('delete', panel.id)"
|
||||
@duplicate="$emit('duplicate', panel)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed, inject, ref } from 'vue';
|
||||
import { Panel } from '@directus/shared/types';
|
||||
import InsightsPanel from '../components/panel.vue';
|
||||
import { useElementSize } from '@/composables/use-element-size';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'InsightsWorkspace',
|
||||
components: { InsightsPanel },
|
||||
props: {
|
||||
panels: {
|
||||
type: Array as PropType<Panel[]>,
|
||||
required: true,
|
||||
},
|
||||
editMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
zoomToFit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
now: {
|
||||
type: Date,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['update', 'move', 'delete', 'duplicate'],
|
||||
setup(props) {
|
||||
const mainElement = inject('main-element', ref<Element>());
|
||||
const mainElementSize = useElementSize(mainElement);
|
||||
|
||||
const paddingSize = computed(() => Number(getVar('--content-padding')?.slice(0, -2) || 0));
|
||||
|
||||
const workspaceSize = computed(() => {
|
||||
const furthestPanelX = props.panels.reduce(
|
||||
(aggr, panel) => {
|
||||
if (panel.position_x! > aggr.position_x!) {
|
||||
aggr.position_x = panel.position_x!;
|
||||
aggr.width = panel.width!;
|
||||
}
|
||||
|
||||
return aggr;
|
||||
},
|
||||
{ position_x: 0, width: 0 }
|
||||
);
|
||||
|
||||
const furthestPanelY = props.panels.reduce(
|
||||
(aggr, panel) => {
|
||||
if (panel.position_y! > aggr.position_y!) {
|
||||
aggr.position_y = panel.position_y!;
|
||||
aggr.height = panel.height!;
|
||||
}
|
||||
|
||||
return aggr;
|
||||
},
|
||||
{ position_y: 0, height: 0 }
|
||||
);
|
||||
|
||||
if (props.editMode === true) {
|
||||
return {
|
||||
width: (furthestPanelX.position_x! + furthestPanelX.width! + 25) * 20,
|
||||
height: (furthestPanelY.position_y! + furthestPanelY.height! + 25) * 20,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: (furthestPanelX.position_x! + furthestPanelX.width! - 1) * 20,
|
||||
height: (furthestPanelY.position_y! + furthestPanelY.height! - 1) * 20,
|
||||
};
|
||||
});
|
||||
|
||||
const zoomScale = computed(() => {
|
||||
if (props.zoomToFit === false) return 1;
|
||||
|
||||
const { width, height } = mainElementSize;
|
||||
|
||||
const scaleWidth: number = (width.value - paddingSize.value * 2) / workspaceSize.value.width;
|
||||
const scaleHeight: number = (height.value - 114 - paddingSize.value * 2) / workspaceSize.value.height;
|
||||
|
||||
return Math.min(scaleWidth, scaleHeight);
|
||||
});
|
||||
|
||||
const workspaceBoxSize = computed(() => {
|
||||
return {
|
||||
width: workspaceSize.value.width * zoomScale.value + paddingSize.value * 2,
|
||||
height: workspaceSize.value.height * zoomScale.value + paddingSize.value * 2,
|
||||
};
|
||||
});
|
||||
|
||||
return { workspaceSize, workspaceBoxSize, mainElement, zoomScale };
|
||||
|
||||
function getVar(cssVar: string) {
|
||||
if (!mainElement.value) return;
|
||||
return getComputedStyle(mainElement.value).getPropertyValue(cssVar).trim();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workspace-padding-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
position: absolute;
|
||||
left: var(--content-padding);
|
||||
display: grid;
|
||||
grid-template-rows: repeat(auto-fill, 20px);
|
||||
grid-template-columns: repeat(auto-fill, 20px);
|
||||
min-width: calc(100%);
|
||||
min-height: calc(100% - 120px);
|
||||
transform: scale(1);
|
||||
transform-origin: top left;
|
||||
|
||||
/* This causes the header bar to "unhinge" on the left edge :C */
|
||||
|
||||
/* transition: transform var(--slow) var(--transition); */
|
||||
}
|
||||
|
||||
.workspace > * {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.workspace::before {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
display: block;
|
||||
width: calc(100% + 8px);
|
||||
height: calc(100% + 8px);
|
||||
background-image: radial-gradient(var(--border-normal) 10%, transparent 10%);
|
||||
background-position: -6px -6px;
|
||||
background-size: 20px 20px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--slow) var(--transition);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.workspace-padding-box.editing .workspace::before {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -82,16 +82,29 @@
|
||||
<insights-navigation />
|
||||
</template>
|
||||
|
||||
<insights-workspace
|
||||
<v-workspace
|
||||
:edit-mode="editMode"
|
||||
:panels="panels"
|
||||
:zoom-to-fit="zoomToFit"
|
||||
:now="now"
|
||||
@edit="editPanel"
|
||||
@update="stagePanelEdits"
|
||||
@move="movePanelID = $event"
|
||||
@delete="deletePanel"
|
||||
@duplicate="duplicatePanel"
|
||||
/>
|
||||
>
|
||||
<template #default="{ panel }">
|
||||
<component
|
||||
:is="`panel-${panel.type}`"
|
||||
v-bind="panel.options"
|
||||
:id="panel.id"
|
||||
:show-header="panel.show_header"
|
||||
:height="panel.height"
|
||||
:width="panel.width"
|
||||
:now="now"
|
||||
/>
|
||||
</template>
|
||||
</v-workspace>
|
||||
|
||||
<router-view
|
||||
name="detail"
|
||||
@@ -164,14 +177,13 @@ import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import api from '@/api';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { pointOnLine } from '@/utils/point-on-line';
|
||||
import InsightsWorkspace from '../components/workspace.vue';
|
||||
import { md } from '@/utils/md';
|
||||
import useShortcut from '@/composables/use-shortcut';
|
||||
import { getPanels } from '@/panels';
|
||||
import useEditsGuard from '@/composables/use-edits-guard';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'InsightsDashboard',
|
||||
components: { InsightsNotFound, InsightsNavigation, InsightsWorkspace },
|
||||
components: { InsightsNotFound, InsightsNavigation },
|
||||
props: {
|
||||
primaryKey: {
|
||||
type: String,
|
||||
@@ -184,6 +196,7 @@ export default defineComponent({
|
||||
},
|
||||
setup(props) {
|
||||
const { t } = useI18n();
|
||||
const { panels: panelsInfo } = getPanels();
|
||||
|
||||
const insightsStore = useInsightsStore();
|
||||
const appStore = useAppStore();
|
||||
@@ -287,11 +300,22 @@ export default defineComponent({
|
||||
|
||||
return {
|
||||
...panel,
|
||||
x: panel.position_x,
|
||||
y: panel.position_y,
|
||||
borderRadius: [!topLeftIntersects, !topRightIntersects, !bottomRightIntersects, !bottomLeftIntersects],
|
||||
};
|
||||
});
|
||||
|
||||
return withBorderRadii;
|
||||
const withIcons = withBorderRadii.map((panel) => {
|
||||
if (panel.icon) return panel;
|
||||
|
||||
return {
|
||||
...panel,
|
||||
icon: panelsInfo.value.find((panelConfig) => panelConfig.id === panel.type)?.icon,
|
||||
};
|
||||
});
|
||||
|
||||
return withIcons;
|
||||
});
|
||||
|
||||
const hasEdits = computed(() => stagedPanels.value.length > 0 || panelsToBeDeleted.value.length > 0);
|
||||
@@ -315,13 +339,13 @@ export default defineComponent({
|
||||
deletePanel,
|
||||
attemptCancelChanges,
|
||||
duplicatePanel,
|
||||
editPanel,
|
||||
movePanelLoading,
|
||||
t,
|
||||
toggleFullScreen,
|
||||
zoomToFit,
|
||||
fullScreen,
|
||||
toggleZoomToFit,
|
||||
md,
|
||||
movePanelChoices,
|
||||
movePanelTo,
|
||||
confirmLeave,
|
||||
@@ -441,6 +465,10 @@ export default defineComponent({
|
||||
stagePanelEdits({ edits: newPanel, id: '+' });
|
||||
}
|
||||
|
||||
function editPanel(panel: Panel) {
|
||||
router.push(`/insights/${panel.dashboard}/${panel.id}`);
|
||||
}
|
||||
|
||||
function toggleFullScreen() {
|
||||
fullScreen.value = !fullScreen.value;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
@click:row="navigateToDashboard"
|
||||
>
|
||||
<template #[`item.icon`]="{ item }">
|
||||
<v-icon class="icon" :name="item.icon" />
|
||||
<v-icon class="icon" :name="item.icon" :color="item.color" />
|
||||
</template>
|
||||
|
||||
<template #item-append="{ item }">
|
||||
@@ -216,12 +216,6 @@ export default defineComponent({
|
||||
--v-list-item-icon-color: var(--danger);
|
||||
}
|
||||
|
||||
.v-list-item.warning {
|
||||
--v-list-item-color: var(--warning);
|
||||
--v-list-item-color-hover: var(--warning);
|
||||
--v-list-item-icon-color: var(--warning);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
--v-button-color-disabled: var(--foreground-normal);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,11 @@ export default defineComponent({
|
||||
name: t('settings_webhooks'),
|
||||
to: `/settings/webhooks`,
|
||||
},
|
||||
{
|
||||
icon: 'bolt',
|
||||
name: t('settings_flows'),
|
||||
to: `/settings/flows`,
|
||||
},
|
||||
];
|
||||
|
||||
const externalItems = computed(() => {
|
||||
|
||||
@@ -17,6 +17,10 @@ import RolesPermissionsDetail from './routes/roles/permissions-detail/permission
|
||||
import RolesPublicItem from './routes/roles/public-item.vue';
|
||||
import WebhooksCollection from './routes/webhooks/collection.vue';
|
||||
import WebhooksItem from './routes/webhooks/item.vue';
|
||||
import FlowsOverview from './routes/flows/overview.vue';
|
||||
import FlowDrawer from './routes/flows/flow-drawer.vue';
|
||||
import FlowsDetail from './routes/flows/flow.vue';
|
||||
import FlowOperationDetail from './routes/flows/components/operation-detail.vue';
|
||||
import TranslationStringsCollection from './routes/translation-strings/collection.vue';
|
||||
|
||||
export default defineModule({
|
||||
@@ -176,6 +180,31 @@ export default defineModule({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'flows',
|
||||
component: RouterPass,
|
||||
children: [
|
||||
{
|
||||
name: 'settings-flows-collection',
|
||||
path: '',
|
||||
component: FlowsOverview,
|
||||
},
|
||||
{
|
||||
name: 'settings-flows-item',
|
||||
path: ':primaryKey',
|
||||
component: FlowsDetail,
|
||||
props: true,
|
||||
children: [
|
||||
{
|
||||
name: 'settings-flows-operation',
|
||||
path: ':operationId',
|
||||
component: FlowOperationDetail,
|
||||
props: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'translation-strings',
|
||||
component: RouterPass,
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
v-model="optionsValues"
|
||||
class="extension-options"
|
||||
:fields="optionsFields"
|
||||
:initial-values="disabled ? optionsValues : null"
|
||||
:disabled="disabled"
|
||||
primary-key="+"
|
||||
/>
|
||||
|
||||
@@ -23,6 +25,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed } from 'vue';
|
||||
import { getOperation } from '@/operations';
|
||||
import { getInterface } from '@/interfaces';
|
||||
import { getDisplay } from '@/displays';
|
||||
import { getPanel } from '@/panels';
|
||||
@@ -33,7 +36,7 @@ import { storeToRefs } from 'pinia';
|
||||
export default defineComponent({
|
||||
props: {
|
||||
type: {
|
||||
type: String as PropType<'interface' | 'display' | 'panel'>,
|
||||
type: String as PropType<'interface' | 'display' | 'panel' | 'operation'>,
|
||||
required: true,
|
||||
},
|
||||
extension: {
|
||||
@@ -52,6 +55,10 @@ export default defineComponent({
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
@@ -69,6 +76,8 @@ export default defineComponent({
|
||||
return getDisplay(props.extension);
|
||||
case 'panel':
|
||||
return getPanel(props.extension);
|
||||
case 'operation':
|
||||
return getOperation(props.extension);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
292
app/src/modules/settings/routes/flows/components/arrows.vue
Normal file
292
app/src/modules/settings/routes/flows/components/arrows.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<div class="arrow-container">
|
||||
<svg :width="size.width" :height="size.height" class="arrows">
|
||||
<transition-group name="fade">
|
||||
<path
|
||||
v-for="arrow in arrows"
|
||||
:key="arrow.id"
|
||||
:class="{ [arrow.type]: true, subdued: subdued || arrow.loner, hint: arrow.isHint }"
|
||||
:d="arrow.d"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</transition-group>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Vector2 } from '@/utils/vector2';
|
||||
import { computed } from 'vue';
|
||||
import { ATTACHMENT_OFFSET, PANEL_HEIGHT, PANEL_WIDTH, REJECT_OFFSET, RESOLVE_OFFSET } from '../constants';
|
||||
import { ArrowInfo, Target } from './operation.vue';
|
||||
import { ParentInfo } from '../flow.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
panels: Record<string, any>[];
|
||||
arrowInfo?: ArrowInfo;
|
||||
parentPanels: Record<string, ParentInfo>;
|
||||
editMode?: boolean;
|
||||
hoveredPanel?: string | null;
|
||||
subdued?: boolean;
|
||||
}>(),
|
||||
{
|
||||
arrowInfo: undefined,
|
||||
editMode: false,
|
||||
hoveredPanel: undefined,
|
||||
subdued: false,
|
||||
}
|
||||
);
|
||||
|
||||
const startOffset = 2;
|
||||
const endOffset = 13;
|
||||
|
||||
const size = computed(() => {
|
||||
let width = 0,
|
||||
height = 0;
|
||||
for (const panel of props.panels) {
|
||||
width = Math.max(width, (panel.x + PANEL_WIDTH) * 20);
|
||||
height = Math.max(height, (panel.y + PANEL_HEIGHT) * 20);
|
||||
}
|
||||
if (props.arrowInfo) {
|
||||
width = Math.max(width, props.arrowInfo.pos.x + 10);
|
||||
height = Math.max(height, props.arrowInfo.pos.y + 10);
|
||||
}
|
||||
|
||||
return { width: width + 100, height: height + 100 };
|
||||
});
|
||||
|
||||
const arrows = computed(() => {
|
||||
const arrows: { id: string; d: string; type: Target; loner: boolean; isHint?: boolean }[] = [];
|
||||
|
||||
for (const panel of props.panels) {
|
||||
const resolveChild = props.panels.find((pan) => pan.id === panel.resolve);
|
||||
const rejectChild = props.panels.find((pan) => pan.id === panel.reject);
|
||||
const parent = props.parentPanels[panel.id];
|
||||
const loner = (parent === undefined || parent.loner) && panel.id !== '$trigger';
|
||||
|
||||
if (props.arrowInfo?.id === panel.id && props.arrowInfo?.type === 'resolve') {
|
||||
const { x, y } = getPoints(panel, RESOLVE_OFFSET);
|
||||
arrows.push({
|
||||
id: panel.id + '_resolve',
|
||||
d: createLine(x, y, props.arrowInfo.pos.x, props.arrowInfo.pos.y),
|
||||
type: 'resolve',
|
||||
loner,
|
||||
});
|
||||
} else if (resolveChild) {
|
||||
const { x, y, toX, toY } = getPoints(panel, RESOLVE_OFFSET, resolveChild);
|
||||
arrows.push({
|
||||
id: panel.id + '_resolve',
|
||||
d: createLine(x, y, toX as number, toY as number),
|
||||
type: 'resolve',
|
||||
loner,
|
||||
});
|
||||
} else if (props.editMode && !props.arrowInfo && (panel.id === '$trigger' || props.hoveredPanel === panel.id)) {
|
||||
const { x: resolveX, y: resolveY } = getPoints(panel, RESOLVE_OFFSET);
|
||||
arrows.push({
|
||||
id: panel.id + '_resolve',
|
||||
d: createLine(resolveX, resolveY, resolveX + 3 * 20, resolveY),
|
||||
type: 'resolve',
|
||||
loner,
|
||||
isHint: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (props.arrowInfo?.id === panel.id && props.arrowInfo?.type === 'reject') {
|
||||
const { x, y } = getPoints(panel, REJECT_OFFSET);
|
||||
arrows.push({
|
||||
id: panel.id + '_reject',
|
||||
d: createLine(x, y, props.arrowInfo.pos.x, props.arrowInfo.pos.y),
|
||||
type: 'reject',
|
||||
loner,
|
||||
});
|
||||
} else if (rejectChild) {
|
||||
const { x, y, toX, toY } = getPoints(panel, REJECT_OFFSET, rejectChild);
|
||||
arrows.push({
|
||||
id: panel.id + '_reject',
|
||||
d: createLine(x, y, toX as number, toY as number),
|
||||
type: 'reject',
|
||||
loner,
|
||||
});
|
||||
} else if (props.editMode && !props.arrowInfo && panel.id !== '$trigger' && props.hoveredPanel === panel.id) {
|
||||
const { x: rejectX, y: rejectY } = getPoints(panel, REJECT_OFFSET);
|
||||
arrows.push({
|
||||
id: panel.id + '_reject',
|
||||
d: createLine(rejectX, rejectY, rejectX + 3 * 20, rejectY),
|
||||
type: 'reject',
|
||||
loner,
|
||||
isHint: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (props.arrowInfo) {
|
||||
arrows.push();
|
||||
}
|
||||
return arrows;
|
||||
|
||||
function getPoints(panel: Record<string, any>, offset: Vector2, to?: Record<string, any>) {
|
||||
const x = (panel.x - 1) * 20 + offset.x;
|
||||
const y = (panel.y - 1) * 20 + offset.y;
|
||||
if (to) {
|
||||
const toX = (to.x - 1) * 20 + ATTACHMENT_OFFSET.x;
|
||||
const toY = (to.y - 1) * 20 + ATTACHMENT_OFFSET.y;
|
||||
|
||||
return { x, y, toX, toY };
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function createLine(x: number, y: number, toX: number, toY: number) {
|
||||
if (y === toY) return generatePath(Vector2.fromMany({ x: x + startOffset, y }, { x: toX - endOffset, y: toY }));
|
||||
|
||||
if (x + 3 * 20 < toX) {
|
||||
const centerX = findBestPosition(new Vector2(x + 2 * 20, y), new Vector2(toX - 2 * 20, toY), 'x');
|
||||
return generatePath(
|
||||
Vector2.fromMany(
|
||||
{ x: x + startOffset, y },
|
||||
{ x: centerX, y },
|
||||
{ x: centerX, y: toY },
|
||||
{ x: toX - endOffset, y: toY }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const offsetBox = 40;
|
||||
const centerY = findBestPosition(new Vector2(x + 2 * 20, y), new Vector2(toX - 2 * 20, toY), 'y');
|
||||
return generatePath(
|
||||
Vector2.fromMany(
|
||||
{ x: x + startOffset, y },
|
||||
{ x: x + offsetBox, y },
|
||||
{ x: x + offsetBox, y: centerY },
|
||||
{ x: toX - offsetBox, y: centerY },
|
||||
{ x: toX - offsetBox, y: toY },
|
||||
{ x: toX - endOffset, y: toY }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function generatePath(points: Vector2[]) {
|
||||
// Add 8px to the x axis so that the arrow not overlaps with the icon
|
||||
let path = `M ${points[0].add(new Vector2(8, 0))}`;
|
||||
|
||||
if (points.length >= 3) {
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
path += generateCorner(points[i - 1], points[i], points[i + 1]);
|
||||
}
|
||||
}
|
||||
const arrowSize = 8;
|
||||
const arrow = `M ${points.at(-1)} L ${points
|
||||
.at(-1)
|
||||
?.clone()
|
||||
.add(new Vector2(-arrowSize, -arrowSize))} M ${points.at(-1)} L ${points
|
||||
.at(-1)
|
||||
?.clone()
|
||||
.add(new Vector2(-arrowSize, arrowSize))}`;
|
||||
|
||||
return path + ` L ${points.at(-1)} ${arrow}`;
|
||||
}
|
||||
|
||||
function generateCorner(start: Vector2, middle: Vector2, end: Vector2) {
|
||||
return ` L ${start.moveNextTo(middle)} Q ${middle} ${end.moveNextTo(middle)}`;
|
||||
}
|
||||
|
||||
function findBestPosition(from: Vector2, to: Vector2, axis: 'x' | 'y') {
|
||||
const possiblePlaces: boolean[] = [];
|
||||
|
||||
const otherAxis = axis === 'x' ? 'y' : 'x';
|
||||
|
||||
const { min, max } = minMaxPoint(from, to);
|
||||
|
||||
const outerPoints = range(min[otherAxis], max[otherAxis], (axis === 'x' ? PANEL_WIDTH : PANEL_HEIGHT) * 20);
|
||||
const innerPoints = range(min[axis], max[axis], 20);
|
||||
|
||||
for (let outer of outerPoints) {
|
||||
for (let inner = 0; inner < innerPoints.length; inner++) {
|
||||
const point = axis === 'x' ? new Vector2(innerPoints[inner], outer) : new Vector2(outer, innerPoints[inner]);
|
||||
possiblePlaces[inner] = (possiblePlaces[inner] ?? true) && !isPointInPanel(point);
|
||||
}
|
||||
}
|
||||
|
||||
let pointer = Math.floor(possiblePlaces.length / 2);
|
||||
for (let i = 0; i < possiblePlaces.length; i++) {
|
||||
pointer += i * (i % 2 == 0 ? -1 : 1);
|
||||
if (possiblePlaces[pointer]) return min[axis] + pointer * 20;
|
||||
}
|
||||
|
||||
return from[axis] + Math.floor((to[axis] - from[axis]) / 2 / 20) * 20;
|
||||
}
|
||||
|
||||
function range(min: number, max: number, step: number) {
|
||||
const points: number[] = [];
|
||||
for (let i = min; i < max; i += step) {
|
||||
points.push(i);
|
||||
}
|
||||
points.push(max);
|
||||
return points;
|
||||
}
|
||||
|
||||
function isPointInPanel(point: Vector2) {
|
||||
return (
|
||||
props.panels.findIndex(
|
||||
(panel) =>
|
||||
point.x >= (panel.x - 2) * 20 &&
|
||||
point.x <= (panel.x - 1 + PANEL_WIDTH) * 20 &&
|
||||
point.y >= (panel.y - 1) * 20 &&
|
||||
point.y <= (panel.y - 1 + PANEL_HEIGHT) * 20
|
||||
) !== -1
|
||||
);
|
||||
}
|
||||
|
||||
function minMaxPoint(point1: Vector2, point2: Vector2) {
|
||||
return {
|
||||
min: new Vector2(Math.min(point1.x, point2.x), Math.min(point1.y, point2.y)),
|
||||
max: new Vector2(Math.max(point1.x, point2.x), Math.max(point1.y, point2.y)),
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.arrow-container {
|
||||
position: relative;
|
||||
|
||||
.arrows {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
left: var(--content-padding);
|
||||
pointer-events: none;
|
||||
|
||||
path {
|
||||
fill: transparent;
|
||||
stroke: var(--primary);
|
||||
stroke-width: 2px;
|
||||
transition: stroke var(--fast) var(--transition);
|
||||
transform: translateX(0);
|
||||
|
||||
&.reject {
|
||||
stroke: var(--secondary);
|
||||
}
|
||||
|
||||
&.subdued {
|
||||
stroke: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
&.fade-enter-active,
|
||||
&.fade-leave-active {
|
||||
transition: var(--fast) var(--transition);
|
||||
transition-property: opacity transform;
|
||||
}
|
||||
|
||||
&.fade-enter-from,
|
||||
&.fade-leave-to {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
108
app/src/modules/settings/routes/flows/components/flow-dialog.vue
Normal file
108
app/src/modules/settings/routes/flows/components/flow-dialog.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<v-dialog :model-value="modelValue" persistent @update:modelValue="$emit('update:modelValue', $event)" @esc="cancel">
|
||||
<template #activator="slotBinding">
|
||||
<slot name="activator" v-bind="slotBinding" />
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-card-title>{{ t('edit_flow') }}</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<div class="fields">
|
||||
<v-input v-model="values.name" class="full" autofocus :placeholder="t('flow_name')" />
|
||||
<interface-select-icon :value="values.icon" @input="values.icon = $event" />
|
||||
<interface-select-color width="half" :value="values.color" @input="values.color = $event" />
|
||||
<v-input v-model="values.description" class="full" />
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="cancel">
|
||||
{{ t('cancel') }}
|
||||
</v-button>
|
||||
<v-button :disabled="!values.name" :loading="saving" @click="save">
|
||||
{{ t('save') }}
|
||||
</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import api from '@/api';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { FlowRaw } from '@directus/shared/types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useFlowsStore } from '@/stores';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean;
|
||||
flow?: FlowRaw;
|
||||
}>(),
|
||||
{
|
||||
modelValue: false,
|
||||
flow: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const flowsStore = useFlowsStore();
|
||||
|
||||
const values = reactive({
|
||||
name: props.flow?.name ?? null,
|
||||
icon: props.flow?.icon ?? 'bolt',
|
||||
color: props.flow?.color ?? null,
|
||||
description: props.flow?.description ?? null,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue, oldValue) => {
|
||||
if (isEqual(newValue, oldValue) === false) {
|
||||
values.name = props.flow?.name ?? null;
|
||||
values.icon = props.flow?.icon ?? 'bolt';
|
||||
values.color = props.flow?.color ?? null;
|
||||
values.description = props.flow?.description ?? null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const saving = ref(false);
|
||||
|
||||
function cancel() {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
await api.patch(`/flows/${props.flow.id}`, values, { params: { fields: ['id'] } });
|
||||
await flowsStore.hydrate();
|
||||
|
||||
emit('update:modelValue', false);
|
||||
} catch (err: any) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.full {
|
||||
grid-column: 1 / span 2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<sidebar-detail :title="t('logs')" icon="fact_check" :badge="revisionsCount">
|
||||
<div v-if="revisionsCount === 0" class="empty">{{ t('no_logs') }}</div>
|
||||
|
||||
<v-detail
|
||||
v-for="group in revisionsByDate"
|
||||
:key="group.dateFormatted"
|
||||
:label="group.dateFormatted"
|
||||
class="revisions-date-group"
|
||||
start-open
|
||||
>
|
||||
<div class="scroll-container">
|
||||
<div v-for="revision in group.revisions" :key="revision.id" class="log">
|
||||
<button @click="previewing = revision">
|
||||
<v-icon name="play_arrow" color="var(--primary)" small />
|
||||
{{ revision.timeRelative }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</v-detail>
|
||||
</sidebar-detail>
|
||||
|
||||
<v-drawer
|
||||
:model-value="!!previewing"
|
||||
:title="previewing ? previewing.timestampFormatted : t('logs')"
|
||||
icon="fact_check"
|
||||
@cancel="previewing = null"
|
||||
@esc="previewing = null"
|
||||
>
|
||||
<div class="content">
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="header">
|
||||
<span class="dot" />
|
||||
<span class="type-label">
|
||||
{{ t('trigger') }}
|
||||
<span class="subdued"> {{ usedTrigger?.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="inset">
|
||||
<v-detail v-if="triggerData.options" :label="t('options')">
|
||||
<pre class="json selectable">{{ triggerData.options }}</pre>
|
||||
</v-detail>
|
||||
|
||||
<v-detail v-if="triggerData.trigger" :label="t('payload')">
|
||||
<pre class="json selectable">{{ triggerData.trigger }}</pre>
|
||||
</v-detail>
|
||||
|
||||
<v-detail v-if="triggerData.accountability" :label="t('accountability')">
|
||||
<pre class="json selectable">{{ triggerData.accountability }}</pre>
|
||||
</v-detail>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="step of steps" :key="step.id" class="step">
|
||||
<div class="header">
|
||||
<span class="dot" :class="step.status" />
|
||||
<span v-tooltip="step.key" class="type-label">
|
||||
{{ step.name }}
|
||||
<span class="subdued"> {{ step.operationType }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="inset">
|
||||
<v-detail v-if="step.options" :label="t('options')">
|
||||
<pre class="json selectable">{{ step.options }}</pre>
|
||||
</v-detail>
|
||||
|
||||
<v-detail v-if="step.data" :label="t('payload')">
|
||||
<pre class="json selectable">{{ step.data }}</pre>
|
||||
</v-detail>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRevisions } from '@/composables/use-revisions';
|
||||
import { getOperations } from '@/operations';
|
||||
import { Action, FlowRaw } from '@directus/shared/types';
|
||||
import { computed, ref, toRefs, unref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getTriggers } from '../triggers';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface Props {
|
||||
flow: FlowRaw;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { flow } = toRefs(props);
|
||||
|
||||
const { triggers } = getTriggers();
|
||||
const { operations } = getOperations();
|
||||
|
||||
const usedTrigger = computed(() => {
|
||||
return triggers.find((trigger) => trigger.id === unref(flow).trigger);
|
||||
});
|
||||
|
||||
const { revisionsByDate, revisionsCount } = useRevisions(
|
||||
ref('directus_flows'),
|
||||
computed(() => unref(flow).id),
|
||||
{
|
||||
action: Action.RUN,
|
||||
}
|
||||
);
|
||||
|
||||
const previewing = ref();
|
||||
|
||||
const triggerData = computed(() => {
|
||||
if (!unref(previewing)?.data) return { trigger: null, accountability: null, options: null };
|
||||
|
||||
const { data } = unref(previewing).data;
|
||||
|
||||
return {
|
||||
trigger: data.$trigger,
|
||||
accountability: data.$accountability,
|
||||
options: props.flow.options,
|
||||
};
|
||||
});
|
||||
|
||||
const steps = computed(() => {
|
||||
if (!unref(previewing)?.data?.steps) return [];
|
||||
const { steps } = unref(previewing).data;
|
||||
|
||||
return steps.map(
|
||||
({
|
||||
operation,
|
||||
status,
|
||||
key,
|
||||
options,
|
||||
}: {
|
||||
operation: string;
|
||||
status: 'reject' | 'resolve' | 'unknown';
|
||||
key: string;
|
||||
options: Record<string, any>;
|
||||
}) => {
|
||||
const operationConfiguration = props.flow.operations.find((operationConfig) => operationConfig.id === operation);
|
||||
|
||||
const operationType = operations.value.find((operation) => operation.id === operationConfiguration?.type);
|
||||
|
||||
return {
|
||||
id: operation,
|
||||
name: operationConfiguration?.name ?? key,
|
||||
data: unref(previewing).data?.data?.[key] ?? null,
|
||||
options: options ?? null,
|
||||
operationType: operationType?.name ?? operationConfiguration?.type ?? '--',
|
||||
key,
|
||||
status,
|
||||
};
|
||||
}
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.content {
|
||||
padding: var(--content-padding);
|
||||
}
|
||||
|
||||
.log {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
button {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
z-index: 1;
|
||||
width: calc(100% + 8px);
|
||||
height: calc(100% + 8px);
|
||||
background-color: var(--background-normal-alt);
|
||||
border-radius: var(--border-radius);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--transition);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
|
||||
.header {
|
||||
.dot {
|
||||
border-color: var(--background-normal-alt);
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
& + & {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.json {
|
||||
background-color: var(--background-subdued);
|
||||
font-family: var(--family-monospace);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.steps {
|
||||
position: relative;
|
||||
|
||||
.step {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: var(--border-width);
|
||||
left: -11px;
|
||||
top: 0;
|
||||
background-color: var(--border-subdued);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&:first-child::after {
|
||||
top: 8px;
|
||||
height: calc(100% - 8px);
|
||||
}
|
||||
|
||||
&:last-child::after {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.inset {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 32px;
|
||||
|
||||
.v-detail + .v-detail {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.subdued {
|
||||
color: var(--foreground-subdued);
|
||||
}
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--family-monospace);
|
||||
color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
.dot {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: -16px;
|
||||
z-index: 2;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--primary);
|
||||
border: 2px solid var(--background-page);
|
||||
border-radius: 8px;
|
||||
|
||||
&.resolve {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
&.reject {
|
||||
background-color: var(--secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin-left: 2px;
|
||||
color: var(--foreground-subdued);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<v-drawer
|
||||
:model-value="isOpen"
|
||||
:title="t(operationId === '+' ? 'create_operation' : 'edit_operation')"
|
||||
:subtitle="flow.name"
|
||||
icon="offline_bolt"
|
||||
persistent
|
||||
@cancel="$emit('cancel')"
|
||||
>
|
||||
<template #actions>
|
||||
<v-button v-tooltip.bottom="t('done')" icon rounded :disabled="saveDisabled" @click="saveOperation">
|
||||
<v-icon name="check" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<div class="content">
|
||||
<div class="grid">
|
||||
<div class="field half">
|
||||
<div class="type-label">
|
||||
{{ t('name') }}
|
||||
</div>
|
||||
<v-input v-model="operationName" autofocus :placeholder="generatedName">
|
||||
<template #append>
|
||||
<v-icon name="title" />
|
||||
</template>
|
||||
</v-input>
|
||||
</div>
|
||||
<div class="field half">
|
||||
<div class="type-label">
|
||||
{{ t('key') }}
|
||||
</div>
|
||||
<v-input v-model="operationKey" db-safe :placeholder="generatedKey">
|
||||
<template #append>
|
||||
<v-icon name="vpn_key" />
|
||||
</template>
|
||||
</v-input>
|
||||
<small v-if="!isOperationKeyUnique" class="error selectable">{{ t('operation_key_unique_error') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-fancy-select v-model="operationType" class="select" :items="displayOperations" />
|
||||
|
||||
<v-notice v-if="operationType && !selectedOperation" class="not-found" type="danger">
|
||||
{{ t('operation_not_found', { operation: operationType }) }}
|
||||
<div class="spacer" />
|
||||
<button @click="operationType = undefined">{{ t('reset_interface') }}</button>
|
||||
</v-notice>
|
||||
|
||||
<extension-options
|
||||
v-if="operationType && selectedOperation && operationOptions"
|
||||
v-model="options"
|
||||
:extension="operationType"
|
||||
:options="operationOptions"
|
||||
type="operation"
|
||||
></extension-options>
|
||||
<component
|
||||
:is="`operation-options-${operationType}`"
|
||||
v-else-if="operationType && selectedOperation"
|
||||
:options="operation"
|
||||
/>
|
||||
</div>
|
||||
</v-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDialogRoute } from '@/composables/use-dialog-route';
|
||||
import ExtensionOptions from '@/modules/settings/routes/data-model/field-detail/shared/extension-options.vue';
|
||||
import { getOperation, getOperations } from '@/operations';
|
||||
import { translate } from '@/utils/translate-object-values';
|
||||
import { FlowRaw } from '@directus/shared/types';
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
const generateSuffix = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 5);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
primaryKey: string;
|
||||
operationId: string;
|
||||
operation?: Record<string, any>;
|
||||
existingOperationKeys?: string[];
|
||||
flow: FlowRaw;
|
||||
}>(),
|
||||
{
|
||||
operation: undefined,
|
||||
existingOperationKeys: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits(['save', 'cancel']);
|
||||
|
||||
const isOpen = useDialogRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
const options = ref<Record<string, any>>(props.operation?.options ?? {});
|
||||
const operationType = ref<string | undefined>(props.operation?.type);
|
||||
const operationKey = ref<string | null>(props.operation?.key ?? null);
|
||||
const operationName = ref<string | null>(props.operation?.name ?? null);
|
||||
const saving = ref(false);
|
||||
|
||||
const isOperationKeyUnique = computed(
|
||||
() =>
|
||||
saving.value ||
|
||||
operationKey.value === null ||
|
||||
!(props.operation?.key !== operationKey.value && props.existingOperationKeys?.includes(operationKey.value))
|
||||
);
|
||||
|
||||
const saveDisabled = computed(() => {
|
||||
return !operationType.value || !isOperationKeyUnique.value;
|
||||
});
|
||||
|
||||
watch(operationType, () => {
|
||||
options.value = {};
|
||||
});
|
||||
|
||||
watch(
|
||||
operationName,
|
||||
(newName, oldName) => {
|
||||
if (
|
||||
newName === null ||
|
||||
operationKey.value ===
|
||||
slugify(oldName ?? '', {
|
||||
separator: '_',
|
||||
})
|
||||
)
|
||||
operationKey.value = slugify(newName ?? '', {
|
||||
separator: '_',
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const selectedOperation = computed(() => getOperation(operationType.value));
|
||||
|
||||
const generatedName = computed(() => (selectedOperation.value ? selectedOperation.value?.name : t('operation_name')));
|
||||
|
||||
const generatedKey = computed(() =>
|
||||
selectedOperation.value ? selectedOperation.value?.id + '_' + generateSuffix() : t('operation_key')
|
||||
);
|
||||
|
||||
const { operations } = getOperations();
|
||||
|
||||
const displayOperations = computed(() => {
|
||||
return operations.value.map((operation) => ({
|
||||
value: operation.id,
|
||||
icon: operation.icon,
|
||||
text: operation.name,
|
||||
description: operation.description,
|
||||
}));
|
||||
});
|
||||
|
||||
const operationOptions = computed(() => {
|
||||
if (typeof selectedOperation.value?.options === 'function') {
|
||||
return translate(selectedOperation.value.options(options.value));
|
||||
} else if (typeof selectedOperation.value?.options === 'object') {
|
||||
return selectedOperation.value.options;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
function saveOperation() {
|
||||
saving.value = true;
|
||||
emit('save', {
|
||||
flow: props.primaryKey,
|
||||
name: operationName.value || generatedName.value,
|
||||
key: operationKey.value || generatedKey.value,
|
||||
type: operationType.value,
|
||||
options: options.value,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/form-grid';
|
||||
|
||||
.content {
|
||||
padding: var(--content-padding);
|
||||
padding-top: 0;
|
||||
padding-bottom: var(--content-padding-bottom);
|
||||
|
||||
.grid {
|
||||
@include form-grid;
|
||||
}
|
||||
}
|
||||
|
||||
.v-divider {
|
||||
margin: 52px 0;
|
||||
}
|
||||
.type-label {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.type-title,
|
||||
.select {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.not-found {
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.v-notice {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.required {
|
||||
--v-icon-color: var(--primary);
|
||||
|
||||
margin-top: -12px;
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--danger);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
525
app/src/modules/settings/routes/flows/components/operation.vue
Normal file
525
app/src/modules/settings/routes/flows/components/operation.vue
Normal file
@@ -0,0 +1,525 @@
|
||||
<template>
|
||||
<v-workspace-tile
|
||||
v-bind="panel"
|
||||
:name="panel.panel_name"
|
||||
:icon="type === 'trigger' ? panel.icon : currentOperation?.icon"
|
||||
class="block-container"
|
||||
:class="[
|
||||
type,
|
||||
{
|
||||
'edit-mode': editMode,
|
||||
subdued: subdued || ((parent === undefined || parent.loner) && type === 'operation'),
|
||||
reject: isReject,
|
||||
},
|
||||
]"
|
||||
:edit-mode="editMode"
|
||||
:resizable="false"
|
||||
:show-options="type !== 'trigger'"
|
||||
:style="styleVars"
|
||||
always-update-position
|
||||
@edit="$emit('edit', panel)"
|
||||
@update="$emit('update', { edits: $event, id: panel.id })"
|
||||
@move="$emit('move', panel.id)"
|
||||
@delete="$emit('delete', panel.id)"
|
||||
@duplicate="$emit('duplicate', panel)"
|
||||
@pointerenter="pointerEnter"
|
||||
@pointerleave="pointerLeave"
|
||||
>
|
||||
<template #body>
|
||||
<div
|
||||
v-if="editMode || panel?.resolve"
|
||||
class="button add-resolve"
|
||||
x-small
|
||||
icon
|
||||
rounded
|
||||
@pointerdown.stop="pointerdown('resolve')"
|
||||
>
|
||||
<v-icon v-tooltip="editMode && t('operation_handle_resolve')" name="check_circle" />
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="editMode && !panel?.resolve && !moving && (panel.id === '$trigger' || isHovered)"
|
||||
class="hint resolve-hint"
|
||||
>
|
||||
<div x-small icon rounded class="button-hint" @pointerdown.stop="pointerdown('resolve')">
|
||||
<v-icon v-tooltip="t('operation_handle_resolve')" name="add_circle_outline" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<div
|
||||
v-if="panel.id !== '$trigger' && (editMode || panel?.reject)"
|
||||
x-small
|
||||
icon
|
||||
rounded
|
||||
class="button add-reject"
|
||||
@pointerdown.stop="pointerdown('reject')"
|
||||
>
|
||||
<v-icon v-tooltip="editMode && t('operation_handle_reject')" name="cancel" />
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="editMode && !panel?.reject && !moving && panel.id !== '$trigger' && isHovered"
|
||||
class="hint reject-hint"
|
||||
>
|
||||
<div x-small icon rounded class="button-hint" @pointerdown.stop="pointerdown('reject')">
|
||||
<v-icon v-tooltip="t('operation_handle_reject')" name="add_circle_outline" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div
|
||||
v-if="panel.id !== '$trigger'"
|
||||
x-small
|
||||
icon
|
||||
rounded
|
||||
class="button attachment"
|
||||
:class="{ reject: parent?.type === 'reject' }"
|
||||
@pointerdown.stop="pointerdown('parent')"
|
||||
>
|
||||
<v-icon name="adjust" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="typeof currentOperation?.overview === 'function'" class="block">
|
||||
<div v-tooltip="panel.key" class="name">
|
||||
{{ panel.id === '$trigger' ? t(`triggers.${panel.type}.name`) : panel.name }}
|
||||
</div>
|
||||
<dl class="options-overview selectable">
|
||||
<div
|
||||
v-for="{ label, text, copyable } of translate(currentOperation?.overview(panel.options ?? {}, { flow }))"
|
||||
:key="label"
|
||||
>
|
||||
<dt>{{ label }}</dt>
|
||||
<dd>{{ text }}</dd>
|
||||
<v-icon
|
||||
v-if="isCopySupported && copyable"
|
||||
name="copy"
|
||||
small
|
||||
clickable
|
||||
class="clipboard-icon"
|
||||
@click="copyToClipboard(text)"
|
||||
/>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<component
|
||||
:is="`operation-overview-${currentOperation.id}`"
|
||||
v-else-if="currentOperation && 'id' in currentOperation"
|
||||
:options="currentOperation"
|
||||
/>
|
||||
<template v-if="panel.id === '$trigger'" #footer>
|
||||
<div class="status-footer" :class="flowStatus">
|
||||
<display-color
|
||||
v-tooltip="flowStatus === 'active' ? t('active') : t('inactive')"
|
||||
class="status-dot"
|
||||
:value="flowStatus === 'active' ? 'var(--primary)' : 'var(--foreground-subdued)'"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-if="editMode"
|
||||
class="flow-status-select"
|
||||
inline
|
||||
:model-value="flowStatus"
|
||||
:items="[
|
||||
{ text: t('active'), value: 'active' },
|
||||
{ text: t('inactive'), value: 'inactive' },
|
||||
]"
|
||||
@update:model-value="flowStatus = $event"
|
||||
/>
|
||||
|
||||
<span v-else>
|
||||
{{ flowStatus === 'active' ? t('active') : t('inactive') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</v-workspace-tile>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import useClipboard from '@/composables/use-clipboard';
|
||||
import { getOperations } from '@/operations';
|
||||
import { translate } from '@/utils/translate-object-values';
|
||||
import { Vector2 } from '@/utils/vector2';
|
||||
import { FlowRaw } from '@directus/shared/types';
|
||||
import { computed, ref, toRefs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ATTACHMENT_OFFSET, REJECT_OFFSET, RESOLVE_OFFSET } from '../constants';
|
||||
import { getTriggers } from '../triggers';
|
||||
|
||||
export type Target = 'resolve' | 'reject';
|
||||
export type ArrowInfo = {
|
||||
id: string;
|
||||
pos: Vector2;
|
||||
type: Target;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
panel: Record<string, any>;
|
||||
type?: 'trigger' | 'operation';
|
||||
editMode?: boolean;
|
||||
parent?: { id: string; type: Target; loner: boolean };
|
||||
flow: FlowRaw;
|
||||
panelsToBeDeleted: string[];
|
||||
isHovered: boolean;
|
||||
subdued?: boolean;
|
||||
}>(),
|
||||
{
|
||||
type: 'operation',
|
||||
editMode: false,
|
||||
parent: undefined,
|
||||
isHovered: false,
|
||||
subdued: false,
|
||||
}
|
||||
);
|
||||
|
||||
const { panelsToBeDeleted } = toRefs(props);
|
||||
|
||||
const { operations } = getOperations();
|
||||
const { triggers } = getTriggers();
|
||||
|
||||
const emit = defineEmits([
|
||||
'create',
|
||||
'preview',
|
||||
'edit',
|
||||
'update',
|
||||
'delete',
|
||||
'move',
|
||||
'duplicate',
|
||||
'arrow-move',
|
||||
'arrow-stop',
|
||||
'show-hint',
|
||||
'hide-hint',
|
||||
'flow-status',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { isCopySupported, copyToClipboard } = useClipboard();
|
||||
|
||||
const styleVars = {
|
||||
'--reject-left': REJECT_OFFSET.x + 'px',
|
||||
'--reject-top': REJECT_OFFSET.y + 'px',
|
||||
'--resolve-left': RESOLVE_OFFSET.x + 'px',
|
||||
'--resolve-top': RESOLVE_OFFSET.y + 'px',
|
||||
'--attachment-x': ATTACHMENT_OFFSET.x + 'px',
|
||||
'--attachment-y': ATTACHMENT_OFFSET.y + 'px',
|
||||
};
|
||||
|
||||
const currentOperation = computed(() => {
|
||||
if (props.type === 'operation') return operations.value.find((operation) => operation.id === props.panel.type);
|
||||
else return triggers.find((trigger) => trigger.id === props.panel.type);
|
||||
});
|
||||
|
||||
let down: Target | 'parent' | undefined = undefined;
|
||||
let rafId: number | null = null;
|
||||
let moving = ref(false);
|
||||
let workspaceOffset: Vector2 = new Vector2(0, 0);
|
||||
|
||||
const isReject = computed(() => props.parent?.type === 'reject');
|
||||
|
||||
function pointerdown(target: Target | 'parent') {
|
||||
if (!props.editMode || (target === 'parent' && props.parent === undefined)) return;
|
||||
|
||||
down = target;
|
||||
|
||||
const rect = document.getElementsByClassName('workspace').item(0)?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
workspaceOffset = new Vector2(rect.left, rect.top);
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', pointermove);
|
||||
window.addEventListener('pointerup', pointerup);
|
||||
}
|
||||
|
||||
const pointermove = (event: PointerEvent) => {
|
||||
rafId = window.requestAnimationFrame(() => {
|
||||
moving.value = true;
|
||||
if (!down) return;
|
||||
const arrowInfo: ArrowInfo =
|
||||
down === 'parent'
|
||||
? {
|
||||
id: props.parent?.id,
|
||||
type: props.parent?.type as Target,
|
||||
pos: new Vector2(
|
||||
Math.round((event.pageX - workspaceOffset.x) / 20) * 20,
|
||||
Math.round((event.pageY - workspaceOffset.y) / 20) * 20
|
||||
),
|
||||
}
|
||||
: {
|
||||
id: props.panel.id,
|
||||
type: down,
|
||||
pos: new Vector2(
|
||||
Math.round((event.pageX - workspaceOffset.x) / 20) * 20,
|
||||
Math.round((event.pageY - workspaceOffset.y) / 20) * 20
|
||||
),
|
||||
};
|
||||
|
||||
emit('arrow-move', arrowInfo);
|
||||
});
|
||||
};
|
||||
|
||||
function pointerup() {
|
||||
if (
|
||||
!moving.value &&
|
||||
((down === 'reject' && (!props.panel.reject || panelsToBeDeleted.value.includes(props.panel.reject))) ||
|
||||
(down === 'resolve' && (!props.panel.resolve || panelsToBeDeleted.value.includes(props.panel.resolve))))
|
||||
)
|
||||
emit('create', props.panel.id, down);
|
||||
moving.value = false;
|
||||
down = undefined;
|
||||
if (rafId) window.cancelAnimationFrame(rafId);
|
||||
|
||||
emit('arrow-stop');
|
||||
|
||||
window.removeEventListener('pointermove', pointermove);
|
||||
window.removeEventListener('pointerup', pointerup);
|
||||
}
|
||||
|
||||
const flowStatus = computed({
|
||||
get() {
|
||||
return props.flow.status;
|
||||
},
|
||||
set(newVal: string) {
|
||||
emit('flow-status', newVal);
|
||||
},
|
||||
});
|
||||
|
||||
/* show hint buttons */
|
||||
function pointerEnter() {
|
||||
if (!props.editMode) return;
|
||||
emit('show-hint', props.panel.id);
|
||||
}
|
||||
function pointerLeave() {
|
||||
if (!props.editMode) return;
|
||||
emit('hide-hint');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-workspace-tile.block-container {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
padding: 4px;
|
||||
|
||||
:deep(.header .name) {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.flow-status-select {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.block {
|
||||
padding: 0 12px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
.name {
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
color: var(--foreground-normal-alt);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&.trigger {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 1px var(--primary);
|
||||
transition: var(--fast) var(--transition);
|
||||
transition-property: border-color, box-shadow;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-radius: 4px;
|
||||
z-index: -1;
|
||||
opacity: 0.2;
|
||||
box-shadow: 0 0 0 10px var(--primary);
|
||||
|
||||
animation-name: floating;
|
||||
animation-duration: 3s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: cubic-bezier(0.5, 0, 0.5, 1);
|
||||
@keyframes floating {
|
||||
0% {
|
||||
box-shadow: 0 0 0 10px var(--primary);
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px var(--primary);
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 10px var(--primary);
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.subdued {
|
||||
border-color: var(--border-subdued);
|
||||
box-shadow: 0 0 0 1px var(--border-subdued);
|
||||
|
||||
&::before {
|
||||
box-shadow: 0 0 0 7px var(--background-subdued);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.edit-mode) .button {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.button-hint,
|
||||
.button {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.button-hint {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px 20px 20px 60px;
|
||||
transform: translate(-1px, calc(-50% - 2.5px));
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--background-page);
|
||||
transform: translate(calc(-50% - 1px), calc(-50% - 1px));
|
||||
|
||||
--v-icon-color: var(--primary);
|
||||
}
|
||||
|
||||
.add-resolve,
|
||||
.resolve-hint {
|
||||
top: var(--resolve-top);
|
||||
left: var(--resolve-left);
|
||||
|
||||
.button-hint {
|
||||
--v-icon-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.add-reject,
|
||||
.reject-hint {
|
||||
top: var(--reject-top);
|
||||
left: var(--reject-left);
|
||||
|
||||
--v-icon-color: var(--secondary);
|
||||
|
||||
.button-hint {
|
||||
--v-icon-color: var(--secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.attachment {
|
||||
top: var(--attachment-y);
|
||||
left: var(--attachment-x);
|
||||
}
|
||||
|
||||
&.reject {
|
||||
:deep(.header) {
|
||||
.v-icon {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.name {
|
||||
color: var(--secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.attachment {
|
||||
--v-icon-color: var(--secondary);
|
||||
}
|
||||
}
|
||||
|
||||
&.subdued {
|
||||
color: var(--foreground-subdued);
|
||||
|
||||
:deep(.header) {
|
||||
.v-icon {
|
||||
color: var(--foreground-subdued);
|
||||
}
|
||||
.name {
|
||||
color: var(--foreground-subdued);
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
border-color: var(--foreground-subdued);
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
|
||||
.dot {
|
||||
background-color: var(--foreground-subdued);
|
||||
}
|
||||
}
|
||||
|
||||
.button-hint {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.options-overview {
|
||||
> div {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
dt {
|
||||
flex-basis: 100%;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
dd {
|
||||
font-family: var(--family-monospace);
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.clipboard-icon {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
--v-icon-color-hover: var(--foreground-normal);
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity var(--fast) var(--transition);
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<v-drawer
|
||||
:model-value="open"
|
||||
:title="t('change_trigger')"
|
||||
:subtitle="t('trigger_options')"
|
||||
icon="offline_bolt"
|
||||
persistent
|
||||
@cancel="$emit('update:open', false)"
|
||||
>
|
||||
<template #actions>
|
||||
<v-button v-tooltip.bottom="t('done')" icon rounded :disabled="!currentTrigger" @click="saveTrigger">
|
||||
<v-icon name="check" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<div class="content">
|
||||
<v-fancy-select v-model="flowEdits.trigger" class="select" :items="triggers" item-text="name" item-value="id" />
|
||||
|
||||
<v-form
|
||||
v-if="flowEdits.trigger"
|
||||
v-model="flowEdits.options"
|
||||
class="extension-options"
|
||||
:fields="currentTriggerOptionFields"
|
||||
:initial-values="flow?.options"
|
||||
primary-key="+"
|
||||
/>
|
||||
</div>
|
||||
</v-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FlowRaw, TriggerType } from '@directus/shared/types';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getTriggers } from '../triggers';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
flow?: FlowRaw;
|
||||
preview?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['update:open', 'update:flow', 'first-save']);
|
||||
|
||||
const flowEdits = ref<{
|
||||
trigger?: TriggerType;
|
||||
options: Record<string, any>;
|
||||
}>({
|
||||
trigger: props.flow?.trigger ?? undefined,
|
||||
options: props.flow?.options ?? {
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
function saveTrigger() {
|
||||
if (!currentTrigger.value) return;
|
||||
|
||||
emit('update:flow', {
|
||||
...(props.flow ?? {}),
|
||||
...flowEdits.value,
|
||||
});
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
const { triggers } = getTriggers();
|
||||
|
||||
const currentTrigger = computed(() => triggers.find((trigger) => trigger.id === flowEdits.value.trigger));
|
||||
|
||||
const currentTriggerOptionFields = computed(() => {
|
||||
if (!currentTrigger.value) return [];
|
||||
|
||||
if (typeof currentTrigger.value.options === 'function') {
|
||||
return currentTrigger.value.options(flowEdits.value.options);
|
||||
}
|
||||
|
||||
return currentTrigger.value.options;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/styles/mixins/form-grid';
|
||||
|
||||
.content {
|
||||
padding: var(--content-padding);
|
||||
padding-top: 0;
|
||||
padding-bottom: var(--content-padding-bottom);
|
||||
|
||||
.grid {
|
||||
@include form-grid;
|
||||
}
|
||||
}
|
||||
|
||||
.v-divider {
|
||||
margin: 52px 0;
|
||||
}
|
||||
.type-label {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.type-title,
|
||||
.select {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.not-found {
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.v-notice {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
</style>
|
||||
8
app/src/modules/settings/routes/flows/constants.ts
Normal file
8
app/src/modules/settings/routes/flows/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Vector2 } from '@/utils/vector2';
|
||||
|
||||
const PANEL_WIDTH = 14;
|
||||
const PANEL_HEIGHT = 14;
|
||||
const ATTACHMENT_OFFSET = new Vector2(0, 3 * 20);
|
||||
const RESOLVE_OFFSET = new Vector2(PANEL_WIDTH * 20, 10 * 20);
|
||||
const REJECT_OFFSET = new Vector2(PANEL_WIDTH * 20, 12 * 20);
|
||||
export { PANEL_HEIGHT, PANEL_WIDTH, ATTACHMENT_OFFSET, RESOLVE_OFFSET, REJECT_OFFSET };
|
||||
276
app/src/modules/settings/routes/flows/flow-drawer.vue
Normal file
276
app/src/modules/settings/routes/flows/flow-drawer.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<v-drawer
|
||||
:title="isNew ? t('creating_new_flow') : t('updating_flow')"
|
||||
class="new-flow"
|
||||
persistent
|
||||
:model-value="active"
|
||||
:sidebar-label="t(currentTab[0])"
|
||||
@cancel="$emit('cancel')"
|
||||
@esc="$emit('cancel')"
|
||||
>
|
||||
<template #sidebar>
|
||||
<v-tabs v-model="currentTab" vertical>
|
||||
<v-tab value="flow_setup">{{ t('flow_setup') }}</v-tab>
|
||||
<v-tab value="trigger_setup" :disabled="!values.name">
|
||||
{{ t('trigger_setup') }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
</template>
|
||||
|
||||
<v-tabs-items v-model="currentTab" class="content">
|
||||
<v-tab-item value="flow_setup">
|
||||
<div class="fields">
|
||||
<div class="field half">
|
||||
<div class="type-label">
|
||||
{{ t('flow_name') }}
|
||||
<v-icon v-tooltip="t('required')" class="required" name="star" sup />
|
||||
</div>
|
||||
<v-input v-model="values.name" autofocus :placeholder="t('flow_name')" />
|
||||
</div>
|
||||
<div class="field half">
|
||||
<div class="type-label">{{ t('status') }}</div>
|
||||
<v-select
|
||||
v-model="values.status"
|
||||
:items="[
|
||||
{
|
||||
text: t('active'),
|
||||
value: 'active',
|
||||
},
|
||||
{
|
||||
text: t('inactive'),
|
||||
value: 'inactive',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<div class="type-label">{{ t('description') }}</div>
|
||||
<v-input v-model="values.description" :placeholder="t('description')" />
|
||||
</div>
|
||||
<div class="field half">
|
||||
<div class="type-label">{{ t('icon') }}</div>
|
||||
<interface-select-icon :value="values.icon" @input="values.icon = $event" />
|
||||
</div>
|
||||
<div class="field half">
|
||||
<div class="type-label">{{ t('color') }}</div>
|
||||
<interface-select-color width="half" :value="values.color" @input="values.color = $event" />
|
||||
</div>
|
||||
<v-divider class="full" />
|
||||
<div class="field full">
|
||||
<div class="type-label">{{ t('flow_tracking') }}</div>
|
||||
<v-select
|
||||
v-model="values.accountability"
|
||||
:items="[
|
||||
{
|
||||
text: t('flow_tracking_all'),
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
text: t('flow_tracking_activity'),
|
||||
value: 'activity',
|
||||
},
|
||||
{
|
||||
text: t('flow_tracking_null'),
|
||||
value: null,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</v-tab-item>
|
||||
<v-tab-item value="trigger_setup">
|
||||
<v-fancy-select v-model="values.trigger" class="select" :items="triggers" item-text="name" item-value="id" />
|
||||
|
||||
<v-form
|
||||
v-if="values.trigger"
|
||||
v-model="values.options"
|
||||
class="extension-options"
|
||||
:fields="currentTriggerOptionFields"
|
||||
primary-key="+"
|
||||
/>
|
||||
</v-tab-item>
|
||||
</v-tabs-items>
|
||||
|
||||
<template #actions>
|
||||
<v-button
|
||||
v-if="currentTab[0] === 'flow_setup'"
|
||||
v-tooltip.bottom="t('next')"
|
||||
:disabled="!values.name || values.name.length === 0"
|
||||
icon
|
||||
rounded
|
||||
@click="currentTab = ['trigger_setup']"
|
||||
>
|
||||
<v-icon name="arrow_forward" />
|
||||
</v-button>
|
||||
<v-button
|
||||
v-if="currentTab[0] === 'trigger_setup'"
|
||||
v-tooltip.bottom="t('finish_setup')"
|
||||
:disabled="!values.trigger"
|
||||
:loading="saving"
|
||||
icon
|
||||
rounded
|
||||
@click="save"
|
||||
>
|
||||
<v-icon name="check" />
|
||||
</v-button>
|
||||
</template>
|
||||
</v-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api';
|
||||
import { useFlowsStore } from '@/stores';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { TriggerType } from '@directus/shared/types';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getTriggers } from './triggers';
|
||||
|
||||
interface Values {
|
||||
name: string | null;
|
||||
icon: string | null;
|
||||
color: string | null;
|
||||
description: string | null;
|
||||
status: string;
|
||||
accountability: string | null;
|
||||
trigger?: TriggerType | null;
|
||||
options: Record<string, any>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
primaryKey?: string;
|
||||
active: boolean;
|
||||
startTab?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { primaryKey: '+', startTab: 'flow_setup' });
|
||||
|
||||
const emit = defineEmits(['cancel', 'done']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const flowsStore = useFlowsStore();
|
||||
|
||||
const currentTab = ref(['flow_setup']);
|
||||
|
||||
const isNew = computed(() => props.primaryKey === '+');
|
||||
|
||||
const values: Values = reactive({
|
||||
name: null,
|
||||
icon: 'bolt',
|
||||
color: null,
|
||||
description: null,
|
||||
status: 'active',
|
||||
accountability: 'all',
|
||||
trigger: undefined,
|
||||
options: {},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.primaryKey,
|
||||
(newKey) => {
|
||||
currentTab.value = [props.startTab];
|
||||
|
||||
if (newKey === '+') {
|
||||
values.name = null;
|
||||
values.icon = 'bolt';
|
||||
values.color = null;
|
||||
values.description = null;
|
||||
values.status = 'active';
|
||||
values.accountability = 'all';
|
||||
values.trigger = undefined;
|
||||
values.options = {};
|
||||
} else {
|
||||
const existing = flowsStore.flows.find((existingFlow) => existingFlow.id === newKey)!;
|
||||
|
||||
values.name = existing.name;
|
||||
values.icon = existing.icon;
|
||||
values.color = existing.color;
|
||||
values.description = existing.description;
|
||||
values.status = existing.status;
|
||||
values.accountability = existing.accountability;
|
||||
values.trigger = existing.trigger;
|
||||
values.options = existing.options;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => values.trigger,
|
||||
() => {
|
||||
values.options = {};
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => values.options?.type,
|
||||
(type) => {
|
||||
values.options = {
|
||||
type,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const { triggers } = getTriggers();
|
||||
|
||||
const currentTrigger = computed(() => triggers.find((trigger) => trigger.id === values.trigger));
|
||||
|
||||
const currentTriggerOptionFields = computed(() => {
|
||||
if (!currentTrigger.value) return [];
|
||||
|
||||
if (typeof currentTrigger.value.options === 'function') {
|
||||
return currentTrigger.value.options(values.options);
|
||||
}
|
||||
|
||||
return currentTrigger.value.options;
|
||||
});
|
||||
|
||||
const saving = ref(false);
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
let id: string;
|
||||
|
||||
if (isNew.value) {
|
||||
id = await api.post('/flows', values, { params: { fields: ['id'] } }).then((res) => res.data.data.id);
|
||||
} else {
|
||||
id = await api
|
||||
.patch(`/flows/${props.primaryKey}`, values, { params: { fields: ['id'] } })
|
||||
.then((res) => res.data.data.id);
|
||||
}
|
||||
|
||||
await flowsStore.hydrate();
|
||||
|
||||
emit('done', id);
|
||||
} catch (err: any) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/form-grid';
|
||||
|
||||
.fields {
|
||||
@include form-grid;
|
||||
}
|
||||
|
||||
.v-icon.required {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--content-padding);
|
||||
padding-top: 0;
|
||||
padding-bottom: var(--content-padding);
|
||||
}
|
||||
|
||||
.select {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
</style>
|
||||
716
app/src/modules/settings/routes/flows/flow.vue
Normal file
716
app/src/modules/settings/routes/flows/flow.vue
Normal file
@@ -0,0 +1,716 @@
|
||||
<template>
|
||||
<settings-not-found v-if="!flow" />
|
||||
<private-view v-else :title="flow?.name ?? t('loading')">
|
||||
<template #title-outer:prepend>
|
||||
<v-button class="header-icon" rounded icon exact to="/settings/flows">
|
||||
<v-icon name="arrow_back" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template #headline>
|
||||
<v-breadcrumb :items="[{ name: t('flows'), to: '/settings/flows' }]" />
|
||||
</template>
|
||||
|
||||
<template #title:append>
|
||||
<display-color
|
||||
v-tooltip="flow.status === 'active' ? t('active') : t('inactive')"
|
||||
class="status-dot"
|
||||
:value="flow.status === 'active' ? 'var(--primary)' : 'var(--foreground-subdued)'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<template v-if="editMode">
|
||||
<v-button
|
||||
v-tooltip.bottom="t('clear_changes')"
|
||||
class="clear-changes"
|
||||
rounded
|
||||
icon
|
||||
outlined
|
||||
@click="attemptCancelChanges"
|
||||
>
|
||||
<v-icon name="clear" />
|
||||
</v-button>
|
||||
|
||||
<v-button v-tooltip.bottom="t('save')" rounded icon :loading="saving" @click="saveChanges">
|
||||
<v-icon name="check" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<v-button
|
||||
v-tooltip.bottom="t('delete_flow')"
|
||||
class="delete-flow"
|
||||
rounded
|
||||
icon
|
||||
secondary
|
||||
@click="confirmDelete = true"
|
||||
>
|
||||
<v-icon name="delete" />
|
||||
</v-button>
|
||||
|
||||
<v-button v-tooltip.bottom="t('edit_flow')" rounded icon outlined @click="editMode = !editMode">
|
||||
<v-icon name="edit" />
|
||||
</v-button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #sidebar>
|
||||
<sidebar-detail icon="info_outline" :title="t('information')" close>
|
||||
<div v-md="t('page_help_settings_flows_item')" class="page-description" />
|
||||
</sidebar-detail>
|
||||
|
||||
<logs-sidebar-detail :flow="flow" />
|
||||
</template>
|
||||
|
||||
<template #navigation>
|
||||
<settings-navigation />
|
||||
</template>
|
||||
|
||||
<div class="container">
|
||||
<arrows
|
||||
:panels="panels"
|
||||
:arrow-info="arrowInfo"
|
||||
:parent-panels="parentPanels"
|
||||
:edit-mode="editMode"
|
||||
:hovered-panel="hoveredPanelID"
|
||||
:subdued="flow.status === 'inactive'"
|
||||
/>
|
||||
<v-workspace :panels="panels" :edit-mode="editMode">
|
||||
<template #panel="{ panel }">
|
||||
<operation
|
||||
v-if="flow"
|
||||
:edit-mode="editMode"
|
||||
:panel="panel"
|
||||
:type="panel.id === '$trigger' ? 'trigger' : 'operation'"
|
||||
:parent="parentPanels[panel.id]"
|
||||
:flow="flow"
|
||||
:panels-to-be-deleted="panelsToBeDeleted"
|
||||
:is-hovered="hoveredPanelID === panel.id"
|
||||
:subdued="flow.status === 'inactive'"
|
||||
@create="createPanel"
|
||||
@edit="editPanel"
|
||||
@move="movePanelID = $event"
|
||||
@update="stageOperationEdits"
|
||||
@delete="deletePanel"
|
||||
@duplicate="duplicatePanel"
|
||||
@arrow-move="arrowMove"
|
||||
@arrow-stop="arrowStop"
|
||||
@show-hint="hoveredPanelID = $event"
|
||||
@hide-hint="hoveredPanelID = null"
|
||||
@flow-status="stagedFlow.status = $event"
|
||||
/>
|
||||
</template>
|
||||
</v-workspace>
|
||||
</div>
|
||||
|
||||
<flow-drawer
|
||||
:active="triggerDetailOpen"
|
||||
:primary-key="flow.id"
|
||||
:start-tab="'trigger_setup'"
|
||||
@cancel="triggerDetailOpen = false"
|
||||
@done="triggerDetailOpen = false"
|
||||
/>
|
||||
|
||||
<v-dialog v-model="confirmLeave" @esc="confirmLeave = false">
|
||||
<v-card>
|
||||
<v-card-title>{{ t('unsaved_changes') }}</v-card-title>
|
||||
<v-card-text>{{ t('unsaved_changes_copy') }}</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="discardAndLeave">{{ t('discard_changes') }}</v-button>
|
||||
<v-button @click="confirmLeave = false">{{ t('keep_editing') }}</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="confirmCancel" @esc="confirmCancel = false">
|
||||
<v-card>
|
||||
<v-card-title>{{ t('unsaved_changes') }}</v-card-title>
|
||||
<v-card-text>{{ t('discard_changes_copy') }}</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="cancelChanges">{{ t('discard_changes') }}</v-button>
|
||||
<v-button @click="confirmCancel = false">{{ t('keep_editing') }}</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog :model-value="confirmDelete" @esc="confirmDelete = false">
|
||||
<v-card>
|
||||
<v-card-title>{{ t('flow_delete_confirm', { flow: flow.name }) }}</v-card-title>
|
||||
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="confirmDelete = false">{{ t('cancel') }}</v-button>
|
||||
<v-button danger :loading="deleting" @click="deleteFlow">{{ t('delete_label') }}</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog :model-value="!!movePanelID" @update:model-value="movePanelID = undefined" @esc="movePanelID = undefined">
|
||||
<v-card>
|
||||
<v-card-title>{{ t('copy_to') }}</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-notice v-if="movePanelChoices.length === 0">
|
||||
{{ t('no_other_flows_copy') }}
|
||||
</v-notice>
|
||||
<v-select v-else v-model="movePanelTo" :items="movePanelChoices" item-text="name" item-value="id" />
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="movePanelID = undefined">
|
||||
{{ t('cancel') }}
|
||||
</v-button>
|
||||
<v-button :loading="movePanelLoading" :disabled="movePanelChoices.length === 0" @click="movePanel">
|
||||
{{ t('copy') }}
|
||||
</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<router-view
|
||||
:operation="panels.find((panel) => panel.id === props.operationId)"
|
||||
:existing-operation-keys="exitingOperationKeys"
|
||||
:flow="flow"
|
||||
@save="stageOperation"
|
||||
@cancel="cancelOperation"
|
||||
/>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FlowRaw, OperationRaw } from '@directus/shared/types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { useFlowsStore } from '@/stores';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import api from '@/api';
|
||||
import useEditsGuard from '@/composables/use-edits-guard';
|
||||
import useShortcut from '@/composables/use-shortcut';
|
||||
import { isEmpty, merge, omit, cloneDeep } from 'lodash';
|
||||
import { router } from '@/router';
|
||||
import { nanoid, customAlphabet } from 'nanoid';
|
||||
|
||||
import SettingsNotFound from '../not-found.vue';
|
||||
import SettingsNavigation from '../../components/navigation.vue';
|
||||
import Operation, { ArrowInfo, Target } from './components/operation.vue';
|
||||
import { AppTile } from '@/components/v-workspace-tile.vue';
|
||||
import { ATTACHMENT_OFFSET, PANEL_HEIGHT, PANEL_WIDTH } from './constants';
|
||||
import Arrows from './components/arrows.vue';
|
||||
import { Vector2 } from '@/utils/vector2';
|
||||
import FlowDrawer from './flow-drawer.vue';
|
||||
|
||||
import LogsSidebarDetail from './components/logs-sidebar-detail.vue';
|
||||
|
||||
// Maps the x and y coordinates of attachments of panels to their id
|
||||
export type Attachments = Record<number, Record<number, string>>;
|
||||
export type ParentInfo = { id: string; type: Target; loner: boolean };
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
primaryKey: string;
|
||||
operationId?: string;
|
||||
}>();
|
||||
|
||||
const saving = ref(false);
|
||||
|
||||
useShortcut('meta+s', () => {
|
||||
saveChanges();
|
||||
});
|
||||
|
||||
// ------------- Manage Current Flow ------------- //
|
||||
|
||||
const flowsStore = useFlowsStore();
|
||||
const stagedFlow = ref<Partial<FlowRaw>>({});
|
||||
|
||||
const flow = computed<FlowRaw | undefined>({
|
||||
get() {
|
||||
const existing = flowsStore.flows.find((flow) => flow.id === props.primaryKey);
|
||||
return merge({}, existing, stagedFlow.value);
|
||||
},
|
||||
set(newFlow) {
|
||||
stagedFlow.value = newFlow ?? {};
|
||||
},
|
||||
});
|
||||
|
||||
const exitingOperationKeys = computed(() => [
|
||||
...(flow.value?.operations || []).map((operation) => operation.key),
|
||||
...stagedPanels.value.filter((stagedPanel) => stagedPanel.key !== undefined).map((stagedPanel) => stagedPanel.key!),
|
||||
]);
|
||||
|
||||
const editMode = ref(flow.value?.operations.length === 0 || props.operationId !== undefined);
|
||||
|
||||
const confirmDelete = ref(false);
|
||||
const deleting = ref(false);
|
||||
|
||||
async function deleteFlow() {
|
||||
if (!flow.value?.id) return;
|
||||
|
||||
deleting.value = true;
|
||||
|
||||
try {
|
||||
await api.delete(`/flows/${flow.value.id}`);
|
||||
await flowsStore.hydrate();
|
||||
} catch (err: any) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
router.push('/settings/flows');
|
||||
}
|
||||
}
|
||||
|
||||
// ------------- Manage Panels ------------- //
|
||||
|
||||
const triggerDetailOpen = ref(false);
|
||||
const stagedPanels = ref<Partial<OperationRaw & { borderRadius: [boolean, boolean, boolean, boolean] }>[]>([]);
|
||||
const panelsToBeDeleted = ref<string[]>([]);
|
||||
const hoveredPanelID = ref<string | null>(null);
|
||||
|
||||
const panels = computed(() => {
|
||||
const savedPanels = (flow.value?.operations || []).filter(
|
||||
(panel) => panelsToBeDeleted.value.includes(panel.id) === false
|
||||
);
|
||||
|
||||
const raw = [
|
||||
...savedPanels.map((panel) => {
|
||||
const updates = stagedPanels.value.find((updatedPanel) => updatedPanel.id === panel.id);
|
||||
|
||||
if (updates) {
|
||||
return merge({}, panel, updates);
|
||||
}
|
||||
|
||||
return panel;
|
||||
}),
|
||||
...stagedPanels.value.filter((panel) => panel.id?.startsWith('_')),
|
||||
];
|
||||
|
||||
const panels: Record<string, any>[] = raw.map((panel) => ({
|
||||
...panel,
|
||||
width: PANEL_WIDTH,
|
||||
height: PANEL_HEIGHT,
|
||||
x: panel.position_x,
|
||||
y: panel.position_y,
|
||||
panel_name: t(`operations.${panel.type}.name`),
|
||||
}));
|
||||
|
||||
const trigger: Record<string, any> = {
|
||||
id: '$trigger',
|
||||
panel_name: t('trigger'),
|
||||
icon: 'offline_bolt',
|
||||
x: 1,
|
||||
y: 1,
|
||||
width: PANEL_WIDTH,
|
||||
height: PANEL_HEIGHT,
|
||||
showHeader: true,
|
||||
draggable: false,
|
||||
flow: props.primaryKey,
|
||||
type: flow.value?.trigger,
|
||||
options: flow.value?.options,
|
||||
};
|
||||
|
||||
if (flow.value?.operation) trigger.resolve = flow.value.operation;
|
||||
|
||||
panels.push(trigger);
|
||||
|
||||
return panels;
|
||||
});
|
||||
|
||||
const parentPanels = computed(() => {
|
||||
const parents = panels.value.reduce<Record<string, ParentInfo>>((acc, panel) => {
|
||||
if (panel.resolve)
|
||||
acc[panel.resolve] = {
|
||||
id: panel.id,
|
||||
type: 'resolve',
|
||||
loner: true,
|
||||
};
|
||||
if (panel.reject)
|
||||
acc[panel.reject] = {
|
||||
id: panel.id,
|
||||
type: 'reject',
|
||||
loner: true,
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(parents).map(([key, value]) => {
|
||||
return [key, { ...value, loner: !connectedToTrigger(key) }];
|
||||
})
|
||||
);
|
||||
|
||||
function connectedToTrigger(id: string) {
|
||||
let parent = parents[id];
|
||||
while (parent?.id !== '$trigger') {
|
||||
if (parent === undefined) return false;
|
||||
parent = parents[parent.id];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
let parentId: string | undefined = undefined;
|
||||
let attachType: 'resolve' | 'reject' | undefined = undefined;
|
||||
|
||||
function stageOperationEdits(event: { edits: Partial<OperationRaw>; id?: string }) {
|
||||
const key = event.id ?? props.operationId;
|
||||
|
||||
if (key === '+') {
|
||||
const attach: Record<string, any> = {};
|
||||
const tempId = `_${nanoid()}`;
|
||||
|
||||
if (parentId !== undefined && attachType !== undefined) {
|
||||
const parent = panels.value.find((panel) => panel.id === parentId);
|
||||
|
||||
if (parent) {
|
||||
if (parentId === '$trigger') {
|
||||
stagedFlow.value = { ...stagedFlow.value, operation: tempId };
|
||||
} else {
|
||||
stageOperationEdits({ edits: { [attachType]: tempId }, id: parentId });
|
||||
}
|
||||
|
||||
if (attachType === 'resolve') {
|
||||
attach.position_x = parent.x + PANEL_WIDTH + 4;
|
||||
attach.position_y = parent.y;
|
||||
} else {
|
||||
attach.position_x = parent.x + PANEL_WIDTH + 4;
|
||||
attach.position_y = parent.y + PANEL_HEIGHT + 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stagedPanels.value = [
|
||||
...stagedPanels.value,
|
||||
{
|
||||
id: tempId,
|
||||
flow: props.primaryKey,
|
||||
position_x: 15,
|
||||
position_y: 15,
|
||||
...event.edits,
|
||||
...attach,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
if (stagedPanels.value.some((panel) => panel.id === key)) {
|
||||
stagedPanels.value = stagedPanels.value.map((panel) => {
|
||||
if (panel.id === key) {
|
||||
return merge({ id: key, flow: props.primaryKey }, panel, event.edits);
|
||||
}
|
||||
|
||||
return panel;
|
||||
});
|
||||
} else {
|
||||
stagedPanels.value = [...stagedPanels.value, { id: key, flow: props.primaryKey, ...event.edits }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stageOperation(edits: Partial<OperationRaw>) {
|
||||
stageOperationEdits({ edits });
|
||||
parentId = undefined;
|
||||
attachType = undefined;
|
||||
router.replace(`/settings/flows/${props.primaryKey}`);
|
||||
}
|
||||
|
||||
function cancelOperation() {
|
||||
parentId = undefined;
|
||||
attachType = undefined;
|
||||
router.replace(`/settings/flows/${props.primaryKey}`);
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
const trees = getTrees().map(addChangesToTree);
|
||||
|
||||
if (!flow.value) return;
|
||||
|
||||
if (stagedPanels.value.length === 0 && panelsToBeDeleted.value.length === 0 && isEmpty(stagedFlow.value)) {
|
||||
editMode.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
if (trees.length > 0) {
|
||||
const changes: Record<string, any> = {
|
||||
...stagedFlow.value,
|
||||
operations: {
|
||||
create: trees.filter((tree) => !('id' in tree)),
|
||||
update: trees.filter((tree) => 'id' in tree && tree.id !== '$trigger'),
|
||||
delete: panelsToBeDeleted.value,
|
||||
},
|
||||
};
|
||||
|
||||
const trigger = trees.find((tree) => tree.id === '$trigger');
|
||||
|
||||
if (trigger && trigger.resolve !== undefined) changes.operation = trigger.resolve;
|
||||
|
||||
await api.patch(`/flows/${props.primaryKey}`, changes);
|
||||
}
|
||||
|
||||
await flowsStore.hydrate();
|
||||
|
||||
stagedPanels.value = [];
|
||||
panelsToBeDeleted.value = [];
|
||||
stagedFlow.value = {};
|
||||
editMode.value = false;
|
||||
} catch (error) {
|
||||
unexpectedError(error as Error);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
type Tree = {
|
||||
id: string;
|
||||
reject?: Tree;
|
||||
resolve?: Tree;
|
||||
};
|
||||
|
||||
function getTrees() {
|
||||
const rejectResolveIds = panels.value.reduce<Set<string>>((acc, panel) => {
|
||||
if (panel.resolve) acc.add(panel.resolve);
|
||||
if (panel.reject) acc.add(panel.reject);
|
||||
return acc;
|
||||
}, new Set());
|
||||
|
||||
const topOperations = panels.value.filter((panel) => !rejectResolveIds.has(panel.id));
|
||||
const trees = topOperations.map(constructTree);
|
||||
|
||||
return trees;
|
||||
|
||||
function constructTree(root: Record<string, any>): Tree {
|
||||
const resolve = panels.value.find((panel) => panel.id === root.resolve);
|
||||
const reject = panels.value.find((panel) => panel.id === root.reject);
|
||||
|
||||
return {
|
||||
id: root.id,
|
||||
reject: reject ? constructTree(reject) : undefined,
|
||||
resolve: resolve ? constructTree(resolve) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function addChangesToTree(tree: Tree): Record<string, any> {
|
||||
const edits = stagedPanels.value.find((panel) => panel.id === tree.id);
|
||||
|
||||
const newTree = edits ? cloneDeep(edits) : ({ id: tree.id } as Record<string, any>);
|
||||
|
||||
if (tree.reject) newTree.reject = addChangesToTree(tree.reject);
|
||||
if (tree.resolve) newTree.resolve = addChangesToTree(tree.resolve);
|
||||
if (tree.id.startsWith('_')) delete newTree.id;
|
||||
newTree.flow = props.primaryKey;
|
||||
|
||||
return newTree;
|
||||
}
|
||||
|
||||
async function deletePanel(id: string) {
|
||||
if (!flow.value) return;
|
||||
|
||||
stagedPanels.value = stagedPanels.value.filter((panel) => panel.id !== id);
|
||||
|
||||
if (!id.startsWith('_')) {
|
||||
panelsToBeDeleted.value.push(id);
|
||||
}
|
||||
|
||||
if (flow.value.operation === id) {
|
||||
stagedFlow.value = { operation: null };
|
||||
} else {
|
||||
const parent = parentPanels.value[id];
|
||||
|
||||
if (parent) {
|
||||
stageOperationEdits({ edits: { [parent.type]: null }, id: parent.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPanel(parent: string, type: 'resolve' | 'reject') {
|
||||
parentId = parent;
|
||||
attachType = type;
|
||||
router.push(`/settings/flows/${props.primaryKey}/+`);
|
||||
}
|
||||
|
||||
function duplicatePanel(panel: OperationRaw) {
|
||||
const newPanel = omit(merge({}, panel), 'id', 'resolve', 'reject');
|
||||
const newKey = customAlphabet('abcdefghijklmnopqrstuvwxyz', 5)();
|
||||
newPanel.position_x = newPanel.position_x + 2;
|
||||
newPanel.position_y = newPanel.position_y + 2;
|
||||
newPanel.key = `${newPanel.key}_${newKey}`;
|
||||
stageOperationEdits({ edits: newPanel, id: '+' });
|
||||
}
|
||||
|
||||
function editPanel(panel: AppTile) {
|
||||
if (panel.id === '$trigger') triggerDetailOpen.value = true;
|
||||
else router.push(`/settings/flows/${props.primaryKey}/${panel.id}`);
|
||||
}
|
||||
|
||||
// ------------- Move Panel To ------------- //
|
||||
|
||||
const movePanelID = ref<string | undefined>();
|
||||
const movePanelTo = ref<string | undefined>();
|
||||
const movePanelLoading = ref(false);
|
||||
|
||||
const movePanelChoices = computed(() => flowsStore.flows.filter((flow) => flow.id !== props.primaryKey));
|
||||
|
||||
async function movePanel() {
|
||||
movePanelLoading.value = true;
|
||||
|
||||
const currentPanel = panels.value.find((panel) => panel.id === movePanelID.value);
|
||||
|
||||
try {
|
||||
await api.post(`/operations`, {
|
||||
...omit(currentPanel, ['id']),
|
||||
flow: movePanelTo.value,
|
||||
});
|
||||
|
||||
await flowsStore.hydrate();
|
||||
|
||||
movePanelID.value = undefined;
|
||||
} catch (err: any) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
movePanelLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------- Drag&Drop Arrows ------------- //
|
||||
|
||||
const arrowInfo = ref<ArrowInfo | undefined>();
|
||||
|
||||
function arrowMove(info: ArrowInfo) {
|
||||
arrowInfo.value = info;
|
||||
}
|
||||
|
||||
function arrowStop() {
|
||||
if (!arrowInfo.value) {
|
||||
arrowInfo.value = undefined;
|
||||
return;
|
||||
}
|
||||
const nearPanel = getNearAttachment(arrowInfo.value?.pos);
|
||||
|
||||
if (nearPanel && isLoop(arrowInfo.value.id, nearPanel)) {
|
||||
arrowInfo.value = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure only one arrow can be connected to an attachment
|
||||
if (nearPanel && parentPanels.value[nearPanel]) {
|
||||
const currentlyConnected = parentPanels.value[nearPanel];
|
||||
|
||||
if (currentlyConnected.id === '$trigger') {
|
||||
flow.value = merge({}, flow.value, { operation: null });
|
||||
} else {
|
||||
stageOperationEdits({
|
||||
edits: {
|
||||
[currentlyConnected.type]: null,
|
||||
},
|
||||
id: currentlyConnected.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (arrowInfo.value.id === '$trigger') {
|
||||
flow.value = merge({}, flow.value, { operation: nearPanel ?? null });
|
||||
} else {
|
||||
stageOperationEdits({
|
||||
edits: {
|
||||
[arrowInfo.value.type]: nearPanel ?? null,
|
||||
},
|
||||
id: arrowInfo.value.id,
|
||||
});
|
||||
}
|
||||
|
||||
arrowInfo.value = undefined;
|
||||
}
|
||||
|
||||
function isLoop(currentId: string, attachTo: string) {
|
||||
let parent = currentId;
|
||||
while (parent !== undefined) {
|
||||
if (parent === attachTo) return true;
|
||||
parent = parentPanels.value[parent]?.id ?? undefined;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getNearAttachment(pos: Vector2) {
|
||||
for (const panel of panels.value) {
|
||||
const attachmentPos = new Vector2(
|
||||
(panel.x - 1) * 20 + ATTACHMENT_OFFSET.x,
|
||||
(panel.y - 1) * 20 + ATTACHMENT_OFFSET.y
|
||||
);
|
||||
if (attachmentPos.distanceTo(pos) <= 40) return panel.id as string;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ------------- Navigation Guard ------------- //
|
||||
|
||||
const hasEdits = computed(() => stagedPanels.value.length > 0 || panelsToBeDeleted.value.length > 0);
|
||||
|
||||
const { confirmLeave, leaveTo } = useEditsGuard(hasEdits);
|
||||
|
||||
const confirmCancel = ref(false);
|
||||
|
||||
function attemptCancelChanges(): void {
|
||||
if (hasEdits.value) {
|
||||
confirmCancel.value = true;
|
||||
} else {
|
||||
cancelChanges();
|
||||
}
|
||||
}
|
||||
|
||||
function cancelChanges() {
|
||||
confirmCancel.value = false;
|
||||
stagedPanels.value = [];
|
||||
stagedFlow.value = {};
|
||||
panelsToBeDeleted.value = [];
|
||||
editMode.value = false;
|
||||
}
|
||||
|
||||
function discardAndLeave() {
|
||||
if (!leaveTo.value) return;
|
||||
cancelChanges();
|
||||
confirmLeave.value = false;
|
||||
router.push(leaveTo.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header-icon {
|
||||
--v-button-background-color: var(--primary-10);
|
||||
--v-button-color: var(--primary);
|
||||
--v-button-background-color-hover: var(--primary-25);
|
||||
--v-button-color-hover: var(--primary);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.container {
|
||||
--column-size: 200px;
|
||||
--row-size: 100px;
|
||||
--gap-size: 40px;
|
||||
}
|
||||
|
||||
.clear-changes {
|
||||
--v-button-background-color: var(--foreground-subdued);
|
||||
--v-button-background-color-hover: var(--foreground-normal);
|
||||
}
|
||||
|
||||
.delete-flow {
|
||||
--v-button-background-color-hover: var(--danger) !important;
|
||||
--v-button-color-hover: var(--white) !important;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(auto-fit, var(--row-size));
|
||||
grid-template-columns: repeat(auto-fit, var(--column-size));
|
||||
gap: var(--gap-size);
|
||||
min-width: calc(var(--column-size) * 2);
|
||||
min-height: calc(var(--row-size) * 2);
|
||||
}
|
||||
</style>
|
||||
264
app/src/modules/settings/routes/flows/overview.vue
Normal file
264
app/src/modules/settings/routes/flows/overview.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<private-view :title="t('flows')">
|
||||
<template #title-outer:prepend>
|
||||
<v-button class="header-icon" rounded disabled icon>
|
||||
<v-icon name="bolt" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template #headline>
|
||||
<v-breadcrumb :items="[{ name: t('settings'), to: '/settings' }]" />
|
||||
</template>
|
||||
|
||||
<template #navigation>
|
||||
<settings-navigation />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<v-button
|
||||
v-tooltip.bottom="createAllowed ? t('create_flow') : t('not_allowed')"
|
||||
rounded
|
||||
icon
|
||||
:disabled="createAllowed === false"
|
||||
@click="editFlow = '+'"
|
||||
>
|
||||
<v-icon name="add" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template #sidebar>
|
||||
<sidebar-detail icon="info_outline" :title="t('information')" close>
|
||||
<div v-md="t('page_help_settings_flows_collection')" class="page-description" />
|
||||
</sidebar-detail>
|
||||
</template>
|
||||
|
||||
<v-info v-if="flows.length === 0" icon="bolt" :title="t('no_flows')" center>
|
||||
{{ t('no_flows_copy') }}
|
||||
|
||||
<template v-if="createAllowed" #append>
|
||||
<v-button @click="editFlow = '+'">{{ t('create_flow') }}</v-button>
|
||||
</template>
|
||||
</v-info>
|
||||
|
||||
<v-table
|
||||
v-else
|
||||
v-model:headers="tableHeaders"
|
||||
:items="flows"
|
||||
:sort="internalSort"
|
||||
show-resize
|
||||
fixed-header
|
||||
@click:row="navigateToFlow"
|
||||
@update:sort="internalSort = $event"
|
||||
>
|
||||
<template #[`item.icon`]="{ item }">
|
||||
<v-icon class="icon" :name="item.icon ?? 'bolt'" :color="item.color ?? 'var(--primary)'" />
|
||||
</template>
|
||||
|
||||
<template #[`item.status`]="{ item }">
|
||||
<display-formatted-value
|
||||
type="string"
|
||||
:item="item"
|
||||
:value="item.status"
|
||||
:conditional-formatting="conditionalFormatting"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item-append="{ item }">
|
||||
<v-menu placement="left-start" show-arrow>
|
||||
<template #activator="{ toggle }">
|
||||
<v-icon name="more_vert" class="ctx-toggle" @click="toggle" />
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item clickable @click="toggleFlowStatusById(item.id, item.status)">
|
||||
<template v-if="item.status === 'active'">
|
||||
<v-list-item-icon><v-icon name="block" /></v-list-item-icon>
|
||||
<v-list-item-content>{{ t('set_flow_inactive') }}</v-list-item-content>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-list-item-icon><v-icon name="check" /></v-list-item-icon>
|
||||
<v-list-item-content>{{ t('set_flow_active') }}</v-list-item-content>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item clickable @click="editFlow = item.id">
|
||||
<v-list-item-icon>
|
||||
<v-icon name="edit" outline />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
{{ t('edit_flow') }}
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="danger" clickable @click="confirmDelete = item">
|
||||
<v-list-item-icon>
|
||||
<v-icon name="delete" outline />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
{{ t('delete_flow') }}
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
</v-table>
|
||||
|
||||
<v-dialog :model-value="!!confirmDelete" @esc="confirmDelete = null">
|
||||
<v-card>
|
||||
<v-card-title>{{ t('flow_delete_confirm', { flow: confirmDelete!.name }) }}</v-card-title>
|
||||
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="confirmDelete = null">
|
||||
{{ t('cancel') }}
|
||||
</v-button>
|
||||
<v-button danger :loading="deletingFlow" @click="deleteFlow">
|
||||
{{ t('delete_label') }}
|
||||
</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<flow-drawer
|
||||
:active="editFlow !== undefined"
|
||||
:primary-key="editFlow"
|
||||
@cancel="editFlow = undefined"
|
||||
@done="onFlowDrawerCompletion"
|
||||
/>
|
||||
|
||||
<router-view name="add" />
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api';
|
||||
import { Sort } from '@/components/v-table/types';
|
||||
import { router } from '@/router';
|
||||
import { useFlowsStore, usePermissionsStore } from '@/stores';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { FlowRaw } from '@directus/shared/types';
|
||||
import { computed, ref, Ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import SettingsNavigation from '../../components/navigation.vue';
|
||||
import FlowDrawer from './flow-drawer.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const permissionsStore = usePermissionsStore();
|
||||
|
||||
const confirmDelete = ref<FlowRaw | null>(null);
|
||||
const deletingFlow = ref(false);
|
||||
const editFlow = ref<string | undefined>();
|
||||
|
||||
const createAllowed = computed<boolean>(() => {
|
||||
return permissionsStore.hasPermission('directus_flows', 'create');
|
||||
});
|
||||
|
||||
const conditionalFormatting = ref([
|
||||
{
|
||||
operator: 'eq',
|
||||
value: 'active',
|
||||
text: t('active'),
|
||||
color: 'var(--foreground-inverted)',
|
||||
background: 'var(--primary)',
|
||||
},
|
||||
{
|
||||
operator: 'eq',
|
||||
value: 'inactive',
|
||||
text: t('inactive'),
|
||||
color: 'var(--foreground-subdued)',
|
||||
background: 'var(--background-normal)',
|
||||
},
|
||||
]);
|
||||
|
||||
const tableHeaders = [
|
||||
{
|
||||
text: '',
|
||||
value: 'icon',
|
||||
width: 42,
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
text: t('status'),
|
||||
value: 'status',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
text: t('name'),
|
||||
value: 'name',
|
||||
width: 240,
|
||||
},
|
||||
{
|
||||
text: t('description'),
|
||||
value: 'description',
|
||||
width: 360,
|
||||
},
|
||||
];
|
||||
|
||||
const internalSort: Ref<Sort> = ref({ by: 'name', desc: false });
|
||||
|
||||
const flowsStore = useFlowsStore();
|
||||
|
||||
const flows = computed(() => flowsStore.flows);
|
||||
|
||||
function navigateToFlow({ item: flow }: { item: FlowRaw }) {
|
||||
router.push(`/settings/flows/${flow.id}`);
|
||||
}
|
||||
|
||||
async function deleteFlow() {
|
||||
if (!confirmDelete.value) return;
|
||||
|
||||
deletingFlow.value = true;
|
||||
|
||||
try {
|
||||
await api.delete(`/flows/${confirmDelete.value.id}`);
|
||||
await flowsStore.hydrate();
|
||||
confirmDelete.value = null;
|
||||
} catch (err: any) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
deletingFlow.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFlowStatusById(id: string, value: string) {
|
||||
try {
|
||||
await api.patch(`/flows/${id}`, {
|
||||
status: value === 'active' ? 'inactive' : 'active',
|
||||
});
|
||||
await flowsStore.hydrate();
|
||||
} catch (error) {
|
||||
unexpectedError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
function onFlowDrawerCompletion(id: string) {
|
||||
if (editFlow.value === '+') {
|
||||
router.push(`/settings/flows/${id}`);
|
||||
}
|
||||
|
||||
editFlow.value = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-table {
|
||||
padding: var(--content-padding);
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.ctx-toggle {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
--v-icon-color-hover: var(--foreground-normal);
|
||||
}
|
||||
|
||||
.v-list-item.danger {
|
||||
--v-list-item-color: var(--danger);
|
||||
--v-list-item-color-hover: var(--danger);
|
||||
--v-list-item-icon-color: var(--danger);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
--v-button-color-disabled: var(--primary);
|
||||
--v-button-background-color-disabled: var(--primary-10);
|
||||
}
|
||||
</style>
|
||||
391
app/src/modules/settings/routes/flows/triggers.ts
Normal file
391
app/src/modules/settings/routes/flows/triggers.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { DeepPartial, Field, FlowRaw, TriggerType, Width } from '@directus/shared/types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getPublicURL } from '../../../../utils/get-root-path';
|
||||
|
||||
export type Trigger = {
|
||||
name: string;
|
||||
id: TriggerType;
|
||||
icon: string;
|
||||
description: string;
|
||||
overview: (
|
||||
options: Record<string, any>,
|
||||
{ flow }: { flow: FlowRaw }
|
||||
) => { text: string; label: string; copyable?: boolean }[];
|
||||
options: DeepPartial<Field>[] | ((options: Record<string, any>) => DeepPartial<Field>[]);
|
||||
};
|
||||
|
||||
export function getTriggers() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const triggers: Trigger[] = [
|
||||
{
|
||||
id: 'event',
|
||||
name: t('triggers.event.name'),
|
||||
icon: 'anchor',
|
||||
description: t('triggers.event.description'),
|
||||
overview: ({ type, scope, collections }) => {
|
||||
const labels = [
|
||||
{
|
||||
label: t('type'),
|
||||
text: type,
|
||||
},
|
||||
];
|
||||
|
||||
labels.push({
|
||||
label: t('scope'),
|
||||
text: scope.join(', '),
|
||||
});
|
||||
|
||||
if (collections?.length) {
|
||||
labels.push({
|
||||
label: t('collections'),
|
||||
text: collections.join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
return labels;
|
||||
},
|
||||
options: ({ type, scope }) => {
|
||||
const fields = [
|
||||
{
|
||||
field: 'type',
|
||||
name: t('type'),
|
||||
meta: {
|
||||
interface: 'select-radio',
|
||||
options: {
|
||||
choices: [
|
||||
{
|
||||
text: t('triggers.event.filter'),
|
||||
value: 'filter',
|
||||
},
|
||||
{
|
||||
text: t('triggers.event.action'),
|
||||
value: 'action',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const actionFields = [
|
||||
{
|
||||
field: 'scope',
|
||||
name: t('scope'),
|
||||
meta: {
|
||||
interface: 'select-multiple-dropdown',
|
||||
options: {
|
||||
placeholder: t('scope'),
|
||||
choices: [
|
||||
'items.create',
|
||||
'items.update',
|
||||
'items.delete',
|
||||
{ divider: true },
|
||||
'server.start',
|
||||
'server.stop',
|
||||
'response',
|
||||
'auth.login',
|
||||
'files.upload',
|
||||
],
|
||||
font: 'monospace',
|
||||
},
|
||||
width: 'full' as Width,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'collections',
|
||||
name: t('collections'),
|
||||
meta: {
|
||||
interface: 'system-collections',
|
||||
width: 'full' as Width,
|
||||
readonly:
|
||||
!scope || ['items.create', 'items.update', 'items.delete'].every((t) => scope?.includes(t) === false),
|
||||
options: {
|
||||
includeSystem: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const filterFields = [
|
||||
{
|
||||
field: 'scope',
|
||||
name: t('scope'),
|
||||
meta: {
|
||||
interface: 'select-multiple-dropdown',
|
||||
options: {
|
||||
placeholder: t('scope'),
|
||||
choices: [
|
||||
'items.create',
|
||||
'items.update',
|
||||
'items.delete',
|
||||
{ divider: true },
|
||||
'request.not_found',
|
||||
'request.error',
|
||||
'database.error',
|
||||
'auth.login',
|
||||
'auth.jwt',
|
||||
'authenticate',
|
||||
],
|
||||
font: 'monospace',
|
||||
},
|
||||
width: 'full' as Width,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'collections',
|
||||
name: t('collections'),
|
||||
meta: {
|
||||
interface: 'system-collections',
|
||||
width: 'full' as Width,
|
||||
readonly:
|
||||
!scope || ['items.create', 'items.update', 'items.delete'].every((t) => scope?.includes(t) === false),
|
||||
options: {
|
||||
includeSystem: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'return',
|
||||
name: t('triggers.common.response_body'),
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'full',
|
||||
interface: 'select-radio',
|
||||
options: {
|
||||
choices: [
|
||||
{
|
||||
text: '$t:triggers.common.response_body_last',
|
||||
value: '$last',
|
||||
},
|
||||
{
|
||||
text: '$t:triggers.common.response_body_all',
|
||||
value: '$all',
|
||||
},
|
||||
],
|
||||
allowOther: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (type === 'action') {
|
||||
return [...fields, ...actionFields];
|
||||
}
|
||||
|
||||
if (type === 'filter') {
|
||||
return [...fields, ...filterFields];
|
||||
}
|
||||
|
||||
return fields;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'webhook',
|
||||
name: t('triggers.webhook.name'),
|
||||
icon: 'link',
|
||||
description: t('triggers.webhook.description'),
|
||||
overview: ({ method }, { flow }) => [
|
||||
{
|
||||
label: t('method'),
|
||||
text: `${method ?? 'GET'}`,
|
||||
},
|
||||
{
|
||||
label: t('url'),
|
||||
text: `${getPublicURL()}flows/trigger/${flow.id}`,
|
||||
copyable: true,
|
||||
},
|
||||
],
|
||||
options: ({ async }) => [
|
||||
{
|
||||
field: 'method',
|
||||
name: t('triggers.webhook.method'),
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'select-dropdown',
|
||||
options: {
|
||||
choices: [
|
||||
{ text: 'GET', value: 'GET' },
|
||||
{ text: 'POST', value: 'POST' },
|
||||
],
|
||||
},
|
||||
},
|
||||
schema: {
|
||||
default_value: 'GET',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'async',
|
||||
name: t('triggers.webhook.async'),
|
||||
type: 'boolean',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'toggle',
|
||||
required: true,
|
||||
},
|
||||
schema: {
|
||||
default_value: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'return',
|
||||
name: t('triggers.common.response_body'),
|
||||
type: 'string',
|
||||
schema: {
|
||||
default_value: '$last',
|
||||
},
|
||||
meta: {
|
||||
width: 'full',
|
||||
interface: 'select-radio',
|
||||
options: {
|
||||
choices: [
|
||||
{
|
||||
text: '$t:triggers.common.response_body_last',
|
||||
value: '$last',
|
||||
},
|
||||
{
|
||||
text: '$t:triggers.common.response_body_all',
|
||||
value: '$all',
|
||||
},
|
||||
],
|
||||
allowOther: true,
|
||||
},
|
||||
hidden: async,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'schedule',
|
||||
name: t('triggers.schedule.name'),
|
||||
icon: 'schedule',
|
||||
description: t('triggers.schedule.description'),
|
||||
overview: ({ cron }) => [
|
||||
{
|
||||
label: t('triggers.schedule.cron'),
|
||||
text: cron,
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
field: 'cron',
|
||||
name: t('triggers.schedule.cron'),
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'full',
|
||||
interface: 'input',
|
||||
options: {
|
||||
placeholder: '* * 1 * * *',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'operation',
|
||||
name: t('triggers.operation.name'),
|
||||
icon: 'bolt',
|
||||
description: t('triggers.operation.description'),
|
||||
overview: () => [],
|
||||
options: [
|
||||
{
|
||||
field: 'return',
|
||||
name: t('triggers.common.response_body'),
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'full',
|
||||
interface: 'select-radio',
|
||||
options: {
|
||||
choices: [
|
||||
{
|
||||
text: '$t:triggers.common.response_body_last',
|
||||
value: '$last',
|
||||
},
|
||||
{
|
||||
text: '$t:triggers.common.response_body_all',
|
||||
value: '$all',
|
||||
},
|
||||
],
|
||||
allowOther: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'manual',
|
||||
name: t('triggers.manual.name'),
|
||||
icon: 'touch_app',
|
||||
description: t('triggers.manual.description'),
|
||||
overview: ({ collections }) => {
|
||||
const labels = [
|
||||
{
|
||||
label: t('triggers.manual.description'),
|
||||
text: '',
|
||||
},
|
||||
];
|
||||
|
||||
if (collections?.length) {
|
||||
labels.push({
|
||||
label: t('collections'),
|
||||
text: collections.join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
return labels;
|
||||
},
|
||||
options: [
|
||||
{
|
||||
field: 'collections',
|
||||
name: t('collections'),
|
||||
meta: {
|
||||
interface: 'system-collections',
|
||||
width: 'full' as Width,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'location',
|
||||
name: t('location'),
|
||||
meta: {
|
||||
interface: 'select-dropdown',
|
||||
width: 'half' as Width,
|
||||
options: {
|
||||
choices: [
|
||||
{
|
||||
text: t('triggers.manual.collection_and_item'),
|
||||
value: 'both',
|
||||
},
|
||||
{
|
||||
text: t('triggers.manual.collection_only'),
|
||||
value: 'collection',
|
||||
},
|
||||
{
|
||||
text: t('triggers.manual.item_only'),
|
||||
value: 'item',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
schema: {
|
||||
default_value: 'both',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'async',
|
||||
name: t('triggers.webhook.async'),
|
||||
type: 'boolean',
|
||||
meta: {
|
||||
width: 'half' as Width,
|
||||
interface: 'toggle',
|
||||
},
|
||||
schema: {
|
||||
default_value: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return { triggers };
|
||||
}
|
||||
@@ -174,6 +174,16 @@ export const appRecommendedPermissions: Partial<Permission>[] = [
|
||||
},
|
||||
fields: ['*'],
|
||||
},
|
||||
{
|
||||
collection: 'directus_flows',
|
||||
action: 'read',
|
||||
permissions: {
|
||||
trigger: {
|
||||
_eq: 'manual',
|
||||
},
|
||||
},
|
||||
fields: ['id', 'name', 'icon', 'color', 'options', 'trigger'],
|
||||
},
|
||||
];
|
||||
|
||||
export const appMinimalPermissions: Partial<Permission>[] = [
|
||||
|
||||
Reference in New Issue
Block a user