diff --git a/src/App.vue b/src/App.vue index 504d712f7..ccfeeaccf 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,8 +1,8 @@ - + - + @@ -49,60 +49,33 @@ - - - - {{ $t("Calcs") }} - - + + - - {{ $t("CSS") }} - - - - {{ $t("Watchers") }} - - - - - - - - + + + + + + + + {{ $t("Undo") }} + + + + {{ $t("Redo") }} + + + + {{ $t("Calcs") }} + + + + {{ $t("CSS") }} + + + + {{ $t("Watchers") }} + + + + + + {{ $t("Options") }} + + + + + + + + + + + + + diff --git a/src/components/sortable/Sortable.vue b/src/components/sortable/Sortable.vue new file mode 100644 index 000000000..04fac213c --- /dev/null +++ b/src/components/sortable/Sortable.vue @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/sortable/sortable.scss b/src/components/sortable/sortable.scss new file mode 100644 index 000000000..e31ef7323 --- /dev/null +++ b/src/components/sortable/sortable.scss @@ -0,0 +1,25 @@ +.sortable { + &-box { + font-family: "Open Sans", sans-serif !important; + } + + &-search-box { + display: flex; + align-items: center; + border-color: #cdddee !important; + } + + &-search-icon { + margin: { + left: 16px; + right: 8px; + } + color: #6A7888; + } + + &-btn-new { + background: #1572C2; + color: #ffffff; + } +} + diff --git a/src/components/sortable/sortableList/SortableList.vue b/src/components/sortable/sortableList/SortableList.vue new file mode 100644 index 000000000..182ea0576 --- /dev/null +++ b/src/components/sortable/sortableList/SortableList.vue @@ -0,0 +1,136 @@ + + + + + + PAGE NAME + + + dragStart(event, item.order)" + @dragenter="(event) => dragEnter(event, item.order)" + @dragend="dragEnd" + class="sortable-item sortable-draggable" + > + + + + + + {{ item.name }} {{ item.order }} + + + + + + + + + + + + + + + + + + + diff --git a/src/components/sortable/sortableList/sortableList.scss b/src/components/sortable/sortableList/sortableList.scss new file mode 100644 index 000000000..7e276f3c0 --- /dev/null +++ b/src/components/sortable/sortableList/sortableList.scss @@ -0,0 +1,72 @@ +$border-color: #cdddee; + +.sortable { + &-list { + display: flex; + flex-direction: column; + border: 1px solid $border-color !important; + + &-header { + display: flex; + align-items: center; + } + + &-title { + padding-left: 16px; + font-size: 14px; + font-weight: bold; + color: #566877; + } + } + + &-container { + display: flex; + flex-direction: column; + width: 100%; + height: 340px; + overflow-x: auto; + } + + &-item { + display: flex; + align-items: center; + height: 56px; + border-top: 1px solid $border-color; + cursor: move; + + &-icon { + display: flex; + justify-content: center; + align-items: center; + width: 64px; + height: 56px; + } + + & .fas { + color: #6A7888; + } + + &-name { + flex-grow: 1; + padding: 8px 16px; + font-size: 15px; + color: #556271; + } + + &-action { + display: flex; + margin: 0 16px; + border-color: $border-color !important; + } + + &-vr { + height: 22px; + margin: 9px 0; + border-right: 1px solid $border-color; + } + } +} + +.dragging { + box-shadow: 0 1px 5px 0 rgba(86, 104, 119, 0.4); +} diff --git a/src/components/vue-form-builder.vue b/src/components/vue-form-builder.vue index 47b059856..01ae0ddeb 100644 --- a/src/components/vue-form-builder.vue +++ b/src/components/vue-form-builder.vue @@ -149,15 +149,6 @@ - - - {{ - $t("Undo") - }} - {{ - $t("Redo") - }} - @@ -412,6 +403,26 @@ + + {}" + @item-delete="confirmDelete" + @add-page="$bvModal.show('addPageModal')" + /> + + 1; }, @@ -791,10 +798,6 @@ export default { this.setGroupOrder(defaultGroupOrder); }, methods: { - onSeeAllPages() { - // TODO open the all pages modal - console.log("onSeeAllPages"); - }, onClick(page) { this.currentPage = page; this.$refs.tabsBar.openPageByIndex(page); @@ -804,7 +807,6 @@ export default { .map((config) => config.name) .filter((name) => name !== this.originalPageName); return pageNames.includes(value) ? this.$t("Must be unique.") : ""; - }, getGroupOrder(groupName) { let order = _.get(this.groupOrder, groupName, Number.POSITIVE_INFINITY); @@ -909,7 +911,10 @@ export default { config.forEach((page) => this.removeDataVariableFromNestedScreens(page.items) ); - config.forEach((page, index) => { /* add order property to page if not has */ }); + // add order attribute + config.forEach((page, index) => { + page.order = page.order || index + 1; + }); }, updateFieldNameValidation(items) { items.forEach((item) => { @@ -1123,12 +1128,17 @@ export default { }); }); }, - confirmDelete() { + confirmDelete(item = this.config[this.currentPage]) { this.confirmMessage = this.$t( "Are you sure you want to delete {{item}}?", - { item: this.config[this.currentPage].name } + { item: item.name } ); - this.pageDelete = this.currentPage; + const isLastPage = this.config.length === 1; + if (isLastPage) { + // can not delete the last page + return; + } + this.pageDelete = this.config.indexOf(item); this.$refs.confirm.show(); }, hideConfirmModal() { @@ -1167,14 +1177,21 @@ export default { e.preventDefault(); return; } - this.config.push({ name: this.addPageName, items: [] }); - this.currentPage = this.config.length - 1; + + const maxOrder = this.config.reduce((max, page) => { + return page.order > max ? page.order : max; + }, 0); + + this.config.push({ + name: this.addPageName, + order: maxOrder + 1, + items: [] + }); this.addPageName = ""; this.updateState(); }, deletePage() { this.config.splice(this.pageDelete, 1); - this.currentPage = this.pageDelete - 1 >= 0 ? this.pageDelete - 1 : 0; this.$store.dispatch("undoRedoModule/pushState", { config: JSON.stringify(this.config), currentPage: this.currentPage, @@ -1314,7 +1331,7 @@ export default { this.config[this.currentPage].items.push(clone); this.updateState(); this.inspect(clone); - } + }, } }; diff --git a/src/stories/ScreenToolbar.stories.js b/src/stories/ScreenToolbar.stories.js new file mode 100644 index 000000000..eff31cac9 --- /dev/null +++ b/src/stories/ScreenToolbar.stories.js @@ -0,0 +1,189 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { within, userEvent, fireEvent, expect, waitFor } from "@storybook/test"; +import "../bootstrap"; +// b-tabs from bootstrap-vue +import ScreenToolbar from "../components/ScreenToolbar.vue"; + +const options = [ + { + id: "group_design", + type: "group", + section: "left", + items: [ + { + id: "button_design", + type: "button", + title: "Design Screen", + name: "Design", + variant: "secondary", + icon: "fas fa-drafting-compass pr-1", + action: "changeMode(\"editor\")", + }, + { + id: "button_preview", + type: "button", + title: "Preview Screen", + name: "Preview", + variant: "outline-secondary", + icon: "fas fa-cogs pr-1", + action: "changeMode(\"preview\")", + }, + ], + }, + { + id: "group_preview", + type: "group", + section: "left", + displayCondition: "displayPreview", + items: [ + { + id: "button_preview_desktop", + type: "button", + title: "Preview Desktop", + variant: "secondary", + icon: "fas fa-desktop", + action: "changeDeviceScreen(\"desktop\")", + }, + { + id: "button_preview_mobile", + type: "button", + title: "Preview Mobile", + variant: "outline-secondary", + icon: "fas fa-mobile pr-1", + action: "changeDeviceScreen(\"mobile\")", + }, + ], + }, + { + id: "group_properties", + type: "group", + section: "right", + items: [ + { + id: "button_calcs", + type: "button", + title: "Calculated Properties", + name: "Calcs", + variant: "secondary", + icon: "fas fa-flask", + action: "openComputedProperties()", + }, + { + id: "button_custom_css", + type: "button", + title: "Custom CSS", + name: "CSS", + variant: "secondary", + icon: "fab fa-css3", + action: "openCustomCSS()", + }, + { + id: "button_watchers", + type: "button", + title: "Watchers", + name: "Watchers", + variant: "secondary", + icon: "fas fa-mask", + action: "openWatchersPopup()", + }, + ], + }, + { + id: "button_export", + section: "right", + type: "button", + title: "Export Screen", + name: "", + variant: "secondary", + icon: "fas fa-file-export", + action: "beforeExportScreen()", + }, + { + id: "button_save", + section: "right", + type: "button", + title: "Save Screen", + name: "", + variant: "secondary", + icon: "fas fa-save", + action: () => { + ProcessMaker.EventBus.$emit("save-screen", false); + }, + }, +]; + +export default { + title: "Components/Public/ScreenToolbar", + component: ScreenToolbar, + tags: ["autodocs"], + render: (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { Toolbar: ScreenToolbar }, + template: ` + + + + + + + + + `, + data() { + return {}; + }, + methods: { + sorted(orderedArray) { + console.log("edit", orderedArray); + }, + editPage(page) { + console.log("edit", page); + }, + deletePage(page) { + this.items.splice(this.items.indexOf(page), 1); + }, + openPage(index) { + this.$refs.tabsBar.openPageByIndex(index); + } + } + }), + decorators: [ + () => ({ + template: ` + + + {{ $t("Design") }} + + + {{ $t("Preview") }} + + + + ` + }) + ] +}; + +/** + * Stories of the component + */ +// Preview the component +export const Preview = { + args: { + options + }, +}; diff --git a/src/stories/Sortable.stories.js b/src/stories/Sortable.stories.js new file mode 100644 index 000000000..77520c341 --- /dev/null +++ b/src/stories/Sortable.stories.js @@ -0,0 +1,225 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { within, userEvent, fireEvent, expect, waitFor } from "@storybook/test"; +import "../bootstrap"; +// b-tabs from bootstrap-vue +import Sortable from "../components/sortable/Sortable.vue"; + +/** + * Simulates a drag and drop action from one element to another. + * + * @param {HTMLElement} sourceElement - The element to drag. + * @param {HTMLElement} targetElement - The element to drop onto. + */ +async function dragAndDrop(sourceElement, targetElement) { + await fireEvent.dragStart(sourceElement); + await fireEvent.dragEnter(targetElement); + await fireEvent.dragOver(targetElement); + await fireEvent.drop(targetElement); + await fireEvent.dragEnd(sourceElement); +} + +export default { + title: "Components/Sortable", + component: Sortable, + tags: ["autodocs"], + render: (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { Sortable }, + template: ` + + `, + data() { + return {}; + }, + methods: { + sorted(orderedArray) { + console.log("edit", orderedArray); + }, + editPage(page) { + console.log("edit", page); + }, + deletePage(page) { + this.items.splice(this.items.indexOf(page), 1); + }, + openPage(index) { + this.$refs.tabsBar.openPageByIndex(index); + } + } + }) +}; + +/** + * Stories of the component + */ +// Preview the component +export const Preview = { + args: { + filterKey: "name", + items: [ + { name: "Page 1", order: 1 }, + { name: "Page 2", order: 2 }, + { name: "Page 3", order: 3 }, + { name: "Page 4", order: 4 }, + { name: "Page 5", order: 5 } + ] + } +}; + +// User can reorder items +export const UserCanReorderItems = { + args: { + filterKey: "name", + items: [ + { name: "Page 1", order: 1 }, + { name: "Page 2", order: 2 }, + { name: "Page 3", order: 3 }, + { name: "Page 4", order: 4 }, + { name: "Page 5", order: 5 } + ] + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Drag item-1 item-5 position + await dragAndDrop( + canvas.getByTestId("item-1"), + canvas.getByTestId("item-5") + ); + + // Drag item-1 item-4 position + await dragAndDrop( + canvas.getByTestId("item-1"), + canvas.getByTestId("item-4") + ); + + // Drag item-1 item-3 position + await dragAndDrop( + canvas.getByTestId("item-1"), + canvas.getByTestId("item-3") + ); + + // Drag item-1 item-2 position + await dragAndDrop( + canvas.getByTestId("item-1"), + canvas.getByTestId("item-2") + ); + + // Check the new order + const items = canvas.getAllByTestId(/item-\d+/); + expect(items[0]).toHaveTextContent("Page 5"); + expect(items[1]).toHaveTextContent("Page 4"); + expect(items[2]).toHaveTextContent("Page 3"); + expect(items[3]).toHaveTextContent("Page 2"); + expect(items[4]).toHaveTextContent("Page 1"); + } +}; + +// User can filter by text +export const UserCanFilterByText = { + args: { + filterKey: "name", + items: [ + { name: "Zeus", order: 1 }, + { name: "Hera", order: 2 }, + { name: "Poseidon", order: 3 }, + { name: "Athena", order: 4 }, + { name: "Hephaïstus", order: 5 } + ] + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const search = canvas.getByTestId("search"); + + // Type "Zeus" + await step("Type 'Zeus'", async () => { + await userEvent.clear(search); + await userEvent.type(search, "Zeus"); + const items = canvas.getAllByTestId(/item-\d+/); + await waitFor(() => { + expect(items).toHaveLength(1); + }); + }); + + // Type "a" should be case insensitive + await step("Type 'a'", async () => { + await userEvent.clear(search); + await userEvent.type(search, "a"); + const items = canvas.getAllByTestId(/item-\d+/); + await waitFor(() => { + expect(items).toHaveLength(3); + }); + }); + + // Type "Ï" support unicode + await step("Type 'Ï'", async () => { + await userEvent.clear(search); + await userEvent.type(search, "Ï"); + const items = canvas.getAllByTestId(/item-\d+/); + await waitFor(() => { + expect(items).toHaveLength(1); + }); + }); + } +}; + +// User can sort with filter by text +export const UserCanSortWithFilterByText = { + args: { + filterKey: "name", + items: [ + { name: "Zeus", order: 1 }, + { name: "Hera", order: 2 }, + { name: "Poseidon", order: 3 }, + { name: "Athena", order: 4 }, + { name: "Hephaïstus", order: 5 } + ] + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const search = canvas.getByTestId("search"); + + // Type "A" + await step("Type 'A'", async () => { + await userEvent.clear(search); + await userEvent.type(search, "A"); + const items = canvas.getAllByTestId(/item-\d+/); + await waitFor(() => { + expect(items).toHaveLength(3); + }); + }); + + // Drag "Hera" to "Hephaïstus" position + await dragAndDrop( + canvas.getByTitle("Hera"), + canvas.getByTitle("Hephaïstus") + ); + + // Drag "Athena" to "Hephaïstus" position + await dragAndDrop( + canvas.getByTitle("Athena"), + canvas.getByTitle("Hephaïstus") + ); + + // Clean search + await step("Clean search", async () => { + await userEvent.clear(search); + const items = canvas.getAllByTestId(/item-\d+/); + await waitFor(() => { + expect(items).toHaveLength(5); + }); + }); + + // Check the new order + const items = canvas.getAllByTestId(/item-\d+/); + expect(items[0]).toHaveTextContent("Zeus"); + expect(items[1]).toHaveTextContent("Poseidon"); + expect(items[2]).toHaveTextContent("Hephaïstus"); + expect(items[3]).toHaveTextContent("Athena"); + expect(items[4]).toHaveTextContent("Hera"); + } +};