mirror of
https://github.com/directus/directus.git
synced 2026-02-06 18:04:57 -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:
@@ -54,6 +54,8 @@ import VTextarea from './v-textarea';
|
||||
import VUpload from './v-upload';
|
||||
import VDatePicker from './v-date-picker';
|
||||
import VEmojiPicker from './v-emoji-picker.vue';
|
||||
import VWorkspace from './v-workspace.vue';
|
||||
import VWorkspaceTile from './v-workspace-tile.vue';
|
||||
|
||||
export function registerComponents(app: App): void {
|
||||
app.component('VAvatar', VAvatar);
|
||||
@@ -114,6 +116,8 @@ export function registerComponents(app: App): void {
|
||||
app.component('VUpload', VUpload);
|
||||
app.component('VDatePicker', VDatePicker);
|
||||
app.component('VEmojiPicker', VEmojiPicker);
|
||||
app.component('VWorkspace', VWorkspace);
|
||||
app.component('VWorkspaceTile', VWorkspaceTile);
|
||||
|
||||
app.component('TransitionBounce', TransitionBounce);
|
||||
app.component('TransitionDialog', TransitionDialog);
|
||||
|
||||
@@ -199,7 +199,7 @@ export default defineComponent({
|
||||
--v-button-background-color: var(--primary);
|
||||
--v-button-background-color-hover: var(--primary-125);
|
||||
--v-button-background-color-active: var(--primary);
|
||||
--v-button-background-color-disabled: var(--background-subdued);
|
||||
--v-button-background-color-disabled: var(--background-normal);
|
||||
--v-button-font-size: 16px;
|
||||
--v-button-font-weight: 600;
|
||||
--v-button-line-height: 22px;
|
||||
|
||||
@@ -215,11 +215,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
&:focus:not(:disabled) {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 16px -8px var(--primary);
|
||||
}
|
||||
|
||||
&:not(:disabled):not(.indeterminate) {
|
||||
.label {
|
||||
color: var(--foreground-normal);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</v-divider>
|
||||
</slot>
|
||||
<transition-expand>
|
||||
<div v-if="internalActive">
|
||||
<div v-if="internalActive" class="content">
|
||||
<slot />
|
||||
</div>
|
||||
</transition-expand>
|
||||
@@ -73,7 +73,6 @@ export default defineComponent({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-divider {
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -90,4 +89,8 @@ export default defineComponent({
|
||||
.v-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-dialog v-model="internalActive" :persistent="persistent" placement="right" @esc="$emit('cancel')">
|
||||
<v-dialog v-model="internalActive" :persistent="persistent" placement="right" @esc="cancelable && $emit('cancel')">
|
||||
<template #activator="{ on }">
|
||||
<slot name="activator" v-bind="{ on }" />
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type FancySelectItem = {
|
||||
icon: string;
|
||||
value: string | number;
|
||||
value?: string | number;
|
||||
text: string;
|
||||
description?: string;
|
||||
divider?: boolean;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div
|
||||
v-else
|
||||
class="v-fancy-select-option"
|
||||
:class="{ active: item.value === modelValue, disabled }"
|
||||
:class="{ active: item[itemValue] === modelValue, disabled }"
|
||||
:style="{
|
||||
'--index': index,
|
||||
}"
|
||||
@@ -17,11 +17,15 @@
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="text">{{ item.text }}</div>
|
||||
<div class="description">{{ item.description }}</div>
|
||||
<div class="text">{{ item[itemText] }}</div>
|
||||
<div class="description">{{ item[itemDescription] }}</div>
|
||||
</div>
|
||||
|
||||
<v-icon v-if="modelValue === item.value && disabled === false" name="cancel" @click.stop="toggle(item)" />
|
||||
<v-icon
|
||||
v-if="modelValue === item[itemValue] && disabled === false"
|
||||
name="cancel"
|
||||
@click.stop="toggle(item)"
|
||||
/>
|
||||
<v-icon v-else-if="item.iconRight" class="icon-right" :name="item.iconRight" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -29,44 +33,41 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed } from 'vue';
|
||||
import { FancySelectItem } from './types';
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array as PropType<FancySelectItem[]>,
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const visibleItems = computed(() => {
|
||||
if (props.modelValue === null) return props.items;
|
||||
interface Props {
|
||||
items: Record<string, any>[];
|
||||
modelValue?: string | number | null;
|
||||
disabled?: boolean;
|
||||
itemText?: string;
|
||||
itemValue?: string;
|
||||
itemDescription?: string;
|
||||
}
|
||||
|
||||
return props.items.filter((item) => {
|
||||
return item.value === props.modelValue;
|
||||
});
|
||||
});
|
||||
|
||||
return { toggle, visibleItems };
|
||||
|
||||
function toggle(item: FancySelectItem) {
|
||||
if (props.disabled === true) return;
|
||||
if (props.modelValue === item.value) emit('update:modelValue', null);
|
||||
else emit('update:modelValue', item.value);
|
||||
}
|
||||
},
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => null,
|
||||
disabled: false,
|
||||
itemText: 'text',
|
||||
itemValue: 'value',
|
||||
itemDescription: 'description',
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
if (props.modelValue === null) return props.items;
|
||||
|
||||
return props.items.filter((item) => {
|
||||
return item[props.itemValue] === props.modelValue;
|
||||
});
|
||||
});
|
||||
|
||||
function toggle(item: Record<string, any>) {
|
||||
if (props.disabled === true) return;
|
||||
if (props.modelValue === item[props.itemValue]) emit('update:modelValue', null);
|
||||
else emit('update:modelValue', item[props.itemValue]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
530
app/src/components/v-workspace-tile.vue
Normal file
530
app/src/components/v-workspace-tile.vue
Normal file
@@ -0,0 +1,530 @@
|
||||
<template>
|
||||
<div
|
||||
class="v-workspace-tile"
|
||||
:style="positionStyling"
|
||||
:class="{
|
||||
editing: editMode,
|
||||
draggable,
|
||||
dragging,
|
||||
'br-tl': dragging || borderRadius[0],
|
||||
'br-tr': dragging || borderRadius[1],
|
||||
'br-br': dragging || borderRadius[2],
|
||||
'br-bl': dragging || borderRadius[3],
|
||||
}"
|
||||
data-move
|
||||
@pointerdown="onPointerDown('move', $event)"
|
||||
>
|
||||
<div v-if="showHeader" class="header">
|
||||
<v-icon class="icon" :style="iconColor" :name="icon" small />
|
||||
<v-text-overflow class="name" :text="name || ''" />
|
||||
<div class="spacer" />
|
||||
<v-icon v-if="note" v-tooltip="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="$emit('edit')" />
|
||||
|
||||
<v-menu v-if="showOptions" 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="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') }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<div class="resize-details">
|
||||
({{ positioning.x - 1 }}:{{ positioning.y - 1 }})
|
||||
<template v-if="resizable">{{ positioning.width }}×{{ positioning.height }}</template>
|
||||
</div>
|
||||
|
||||
<div v-if="editMode && resizable" 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="tile-content" :class="{ 'has-header': showHeader }">
|
||||
<slot></slot>
|
||||
<div v-if="$slots.footer" class="footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="body"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Panel } from '@directus/shared/types';
|
||||
import { computed, ref, reactive, StyleValue } from 'vue';
|
||||
import { throttle } from 'lodash';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export type AppTile = {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
note?: string;
|
||||
showHeader?: boolean;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
draggable?: boolean;
|
||||
borderRadius?: [boolean, boolean, boolean, boolean];
|
||||
};
|
||||
|
||||
// Right now, it is not possible to do type Props = AppTile & {resizable?: boolean; editMode?: boolean}
|
||||
type Props = {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
note?: string;
|
||||
showHeader?: boolean;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
draggable?: boolean;
|
||||
borderRadius?: [boolean, boolean, boolean, boolean];
|
||||
resizable?: boolean;
|
||||
editMode?: boolean;
|
||||
showOptions?: boolean;
|
||||
alwaysUpdatePosition?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
name: undefined,
|
||||
icon: 'space_dashboard',
|
||||
color: 'var(--primary)',
|
||||
note: undefined,
|
||||
showHeader: true,
|
||||
minWidth: 8,
|
||||
minHeight: 6,
|
||||
resizable: true,
|
||||
editMode: false,
|
||||
draggable: true,
|
||||
borderRadius: () => [true, true, true, true],
|
||||
showOptions: true,
|
||||
alwaysUpdatePosition: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'move', 'duplicate', 'delete', 'edit', 'preview']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
/**
|
||||
* 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, dragging } = useDragDrop();
|
||||
|
||||
const positioning = computed(() => {
|
||||
if (dragging.value) {
|
||||
return {
|
||||
x: editedPosition.position_x ?? props.x,
|
||||
y: editedPosition.position_y ?? props.y,
|
||||
width: editedPosition.width ?? props.width,
|
||||
height: editedPosition.height ?? props.height,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x: props.x,
|
||||
y: props.y,
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
};
|
||||
});
|
||||
|
||||
const positionStyling = computed(() => {
|
||||
if (dragging.value) {
|
||||
return {
|
||||
'--pos-x': editedPosition.position_x ?? props.x,
|
||||
'--pos-y': editedPosition.position_y ?? props.y,
|
||||
'--width': editedPosition.width ?? props.width,
|
||||
'--height': editedPosition.height ?? props.height,
|
||||
} as StyleValue;
|
||||
}
|
||||
|
||||
return {
|
||||
'--pos-x': props.x,
|
||||
'--pos-y': props.y,
|
||||
'--width': props.width,
|
||||
'--height': props.height,
|
||||
} as StyleValue;
|
||||
});
|
||||
|
||||
const iconColor = computed(() => ({
|
||||
'--v-icon-color': props.color,
|
||||
}));
|
||||
|
||||
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 || dragging.value === false || props.draggable === 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 = props.minWidth;
|
||||
const minHeight = props.minHeight;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (props.alwaysUpdatePosition) emit('update', editedPosition);
|
||||
}, 20);
|
||||
|
||||
return { dragging, onPointerDown, onPointerUp, onPointerMove };
|
||||
|
||||
function onPointerDown(op: Operation, event: PointerEvent) {
|
||||
if (props.editMode === false || props.draggable === false) return;
|
||||
|
||||
operation = op;
|
||||
|
||||
dragging.value = true;
|
||||
|
||||
pointerStartPosX = event.pageX;
|
||||
pointerStartPosY = event.pageY;
|
||||
|
||||
panelStartPosX = props.x;
|
||||
panelStartPosY = props.y;
|
||||
|
||||
panelStartWidth = props.width;
|
||||
panelStartHeight = props.height;
|
||||
|
||||
window.addEventListener('pointerup', onPointerUp);
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging.value = false;
|
||||
if (props.editMode === false || props.draggable === 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 lang="scss">
|
||||
.v-workspace-tile {
|
||||
--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);
|
||||
z-index: 1;
|
||||
transition: border var(--fast) var(--transition);
|
||||
|
||||
&:hover {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
&.editing {
|
||||
&.draggable {
|
||||
border-color: var(--border-normal);
|
||||
box-shadow: 0 0 0 1px var(--border-normal);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
&.draggable:hover {
|
||||
border-color: var(--border-normal-alt);
|
||||
box-shadow: 0 0 0 1px var(--border-normal-alt);
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
z-index: 3 !important;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 1px var(--primary);
|
||||
}
|
||||
|
||||
&.dragging .resize-details {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
& .tile-content {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.tile-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tile-content.has-header {
|
||||
height: calc(100% - 48px);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 42px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 0 12px;
|
||||
border-top: 2px solid var(--border-subdued);
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.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>
|
||||
169
app/src/components/v-workspace.vue
Normal file
169
app/src/components/v-workspace.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div
|
||||
class="v-workspace"
|
||||
: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',
|
||||
}"
|
||||
>
|
||||
<template v-if="!$slots.panel">
|
||||
<v-workspace-tile
|
||||
v-for="panel in panels"
|
||||
:key="panel.id"
|
||||
v-bind="panel"
|
||||
:edit-mode="editMode"
|
||||
:resizable="resizable"
|
||||
@preview="$emit('preview', panel)"
|
||||
@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)"
|
||||
>
|
||||
<slot :panel="panel"></slot>
|
||||
</v-workspace-tile>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-for="panel in panels" :key="panel.id">
|
||||
<slot name="panel" :panel="panel"></slot>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import { useElementSize } from '@/composables/use-element-size';
|
||||
import { AppTile } from './v-workspace-tile.vue';
|
||||
import { cssVar } from '@directus/shared/utils/browser';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
panels: AppTile[];
|
||||
editMode?: boolean;
|
||||
zoomToFit?: boolean;
|
||||
resizable?: boolean;
|
||||
}>(),
|
||||
{
|
||||
editMode: false,
|
||||
zoomToFit: false,
|
||||
resizable: true,
|
||||
}
|
||||
);
|
||||
|
||||
defineEmits(['update', 'move', 'delete', 'duplicate', 'edit', 'preview']);
|
||||
|
||||
const mainElement = inject('main-element', ref<Element>());
|
||||
const mainElementSize = useElementSize(mainElement);
|
||||
|
||||
const paddingSize = computed(() => Number(cssVar('--content-padding', mainElement.value)?.slice(0, -2) || 0));
|
||||
|
||||
const workspaceSize = computed(() => {
|
||||
const furthestPanelX = props.panels.reduce(
|
||||
(aggr, panel) => {
|
||||
if (panel.x! > aggr.x!) {
|
||||
aggr.x = panel.x!;
|
||||
aggr.width = panel.width!;
|
||||
}
|
||||
|
||||
return aggr;
|
||||
},
|
||||
{ x: 0, width: 0 }
|
||||
);
|
||||
|
||||
const furthestPanelY = props.panels.reduce(
|
||||
(aggr, panel) => {
|
||||
if (panel.y! > aggr.y!) {
|
||||
aggr.y = panel.y!;
|
||||
aggr.height = panel.height!;
|
||||
}
|
||||
|
||||
return aggr;
|
||||
},
|
||||
{ y: 0, height: 0 }
|
||||
);
|
||||
|
||||
if (props.editMode === true) {
|
||||
return {
|
||||
width: (furthestPanelX.x! + furthestPanelX.width! + 25) * 20,
|
||||
height: (furthestPanelY.y! + furthestPanelY.height! + 25) * 20,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: (furthestPanelX.x! + furthestPanelX.width! - 1) * 20,
|
||||
height: (furthestPanelY.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,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-workspace {
|
||||
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;
|
||||
}
|
||||
|
||||
.v-workspace.editing .workspace::before {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user