Skip to content

Commit

Permalink
Merge pull request galaxyproject#14892 from ElectronicBlueberry/reord…
Browse files Browse the repository at this point in the history
…er-repeat

Make form repeat blocks reordarable
  • Loading branch information
dannon authored Aug 14, 2023
2 parents 3a91143 + ebfbe50 commit b7380c2
Show file tree
Hide file tree
Showing 13 changed files with 342 additions and 54 deletions.
3 changes: 2 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
],
"browserslist": [
"defaults",
"not op_mini all"
"not op_mini all",
"not ios_saf <= 15.0"
],
"resolutions": {
"chokidar": "3.5.3",
Expand Down
1 change: 0 additions & 1 deletion client/src/components/Form/FormDisplay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,6 @@ export default {
});
},
onChangeForm() {
this.formInputs = JSON.parse(JSON.stringify(this.formInputs));
this.onChange(true);
},
onCloneInputs() {
Expand Down
73 changes: 28 additions & 45 deletions client/src/components/Form/FormInputs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,35 +26,14 @@
</div>
</div>
<div v-else-if="input.type == 'repeat'">
<div v-if="!sustainRepeats || (input.cache && input.cache.length > 0)">
<div class="font-weight-bold mb-2">{{ input.title }}</div>
<div v-if="input.help" class="mb-2" data-description="repeat help">{{ input.help }}</div>
</div>
<FormCard
v-for="(cache, cacheId) in input.cache"
:key="cacheId"
data-description="repeat block"
:title="repeatTitle(cacheId, input.title)">
<template v-slot:operations>
<b-button
v-if="!sustainRepeats"
v-b-tooltip.hover.bottom
role="button"
variant="link"
size="sm"
class="float-right"
@click="repeatDelete(input, cacheId)">
<FontAwesomeIcon icon="trash-alt" />
</b-button>
</template>
<template v-slot:body>
<FormNode v-bind="$props" :inputs="cache" :prefix="getPrefix(input.name, cacheId)" />
</template>
</FormCard>
<b-button v-if="!sustainRepeats" @click="repeatInsert(input)">
<FontAwesomeIcon icon="plus" class="mr-1" />
<span data-description="repeat insert">Insert {{ input.title || "Repeat" }}</span>
</b-button>
<FormRepeat
:input="input"
:sustain-repeats="sustainRepeats"
:passthrough-props="$props"
:prefix="prefix"
@insert="() => repeatInsert(input)"
@delete="(id) => repeatDelete(input, id)"
@swap="(a, b) => repeatSwap(input, a, b)" />
</div>
<div v-else-if="input.type == 'section'">
<FormCard :title="input.title || input.name" :expanded.sync="input.expanded" :collapsible="true">
Expand Down Expand Up @@ -87,21 +66,20 @@
</template>

<script>
import { library } from "@fortawesome/fontawesome-svg-core";
import { faPlus, faTrashAlt } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import FormCard from "components/Form/FormCard";
import FormElement from "components/Form/FormElement";
import { matchCase } from "components/Form/utilities";
import { set } from "vue";
import { matchCase } from "@/components/Form/utilities";
library.add(faPlus, faTrashAlt);
import FormCard from "./FormCard.vue";
import FormRepeat from "./FormRepeat.vue";
import FormElement from "@/components/Form/FormElement.vue";
export default {
name: "FormNode",
components: {
FontAwesomeIcon,
FormCard,
FormElement,
FormRepeat,
},
props: {
inputs: {
Expand Down Expand Up @@ -151,9 +129,6 @@ export default {
},
methods: {
getPrefix(name, index) {
if (index !== undefined) {
name = `${name}_${index}`;
}
if (this.prefix) {
return `${this.prefix}|${name}`;
} else {
Expand All @@ -166,19 +141,27 @@ export default {
conditionalMatch(input, caseId) {
return matchCase(input, input.test_param.value) == caseId;
},
repeatTitle(index, title) {
return `${parseInt(index) + 1}: ${title}`;
},
repeatInsert(input) {
const newInputs = JSON.parse(JSON.stringify(input.inputs));
input.cache = input.cache || [];
const newInputs = structuredClone(input.inputs);
set(input, "cache", input.cache ?? []);
input.cache.push(newInputs);
this.onChangeForm();
},
repeatDelete(input, cacheId) {
input.cache.splice(cacheId, 1);
this.onChangeForm();
},
repeatSwap(input, a, b) {
const tmpA = input.cache[a];
const tmpB = input.cache[b];
input.cache.splice(a, 1, tmpB);
input.cache.splice(b, 1, tmpA);
this.onChangeForm();
},
},
};
</script>
154 changes: 154 additions & 0 deletions client/src/components/Form/FormRepeat.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { library } from "@fortawesome/fontawesome-svg-core";
import { faCaretDown, faCaretUp, faPlus, faTrashAlt } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { defineAsyncComponent, nextTick, type PropType } from "vue";
import { useKeyedObjects } from "@/composables/keyedObjects";
import FormCard from "./FormCard.vue";
const FormNode = defineAsyncComponent(() => import("./FormInputs.vue"));
interface Input {
name: string;
title: string;
help?: string;
cache: Array<Record<string, unknown>>;
}
const props = defineProps({
input: {
type: Object as PropType<Input>,
required: true,
},
sustainRepeats: {
type: Boolean,
default: false,
},
passthroughProps: {
type: Object,
required: true,
},
prefix: {
type: String,
default: null,
},
});
const emit = defineEmits<{
(e: "insert"): void;
(e: "delete", index: number): void;
(e: "swap", a: number, b: number): void;
}>();
// @ts-ignore: bad library types
library.add(faPlus, faTrashAlt, faCaretUp, faCaretDown);
function onInsert() {
emit("insert");
}
function onDelete(index: number) {
emit("delete", index);
}
function getPrefix(index: number) {
const name = `${props.input.name}_${index}`;
if (props.prefix) {
return `${props.prefix}|${name}`;
} else {
return name;
}
}
function getTitle(index: number) {
return `${index + 1}: ${props.input.title}`;
}
/** swap blocks if possible */
async function swap(index: number, swapWith: number, direction: "up" | "down") {
if (swapWith >= 0 && swapWith < props.input.cache?.length) {
emit("swap", index, swapWith);
await nextTick();
const buttonId = getButtonId(swapWith, direction);
document.getElementById(buttonId)?.focus();
}
}
/** get a uid for the up/down button */
function getButtonId(index: number, direction: "up" | "down") {
const prefix = getPrefix(index);
return `${prefix}_${direction}`;
}
const { keyObject } = useKeyedObjects();
</script>

<template>
<div>
<div v-if="!props.sustainRepeats || props.input.cache?.length > 0">
<div class="font-weight-bold mb-2">{{ props.input.title }}</div>
<div v-if="props.input.help" class="mb-2" data-description="repeat help">{{ props.input.help }}</div>
</div>
<FormCard
v-for="(cache, cacheId) in props.input.cache"
:key="keyObject(cache)"
data-description="repeat block"
class="card"
:title="getTitle(cacheId)">
<template v-slot:operations>
<span v-if="!props.sustainRepeats" class="float-right">
<b-button-group>
<b-button
:id="getButtonId(cacheId, 'up')"
v-b-tooltip.hover.bottom
title="move up"
role="button"
variant="link"
size="sm"
class="ml-0"
@click="() => swap(cacheId, cacheId - 1, 'up')">
<FontAwesomeIcon icon="caret-up" />
</b-button>
<b-button
:id="getButtonId(cacheId, 'down')"
v-b-tooltip.hover.bottom
title="move down"
role="button"
variant="link"
size="sm"
class="ml-0"
@click="() => swap(cacheId, cacheId + 1, 'down')">
<FontAwesomeIcon icon="caret-down" />
</b-button>
</b-button-group>
<b-button
v-b-tooltip.hover.bottom
title="delete"
role="button"
variant="link"
size="sm"
class="ml-0"
@click="() => onDelete(cacheId)">
<FontAwesomeIcon icon="trash-alt" />
</b-button>
</span>
</template>
<template v-slot:body>
<FormNode v-bind="props.passthroughProps" :inputs="cache" :prefix="getPrefix(cacheId)" />
</template>
</FormCard>
<b-button v-if="!props.sustainRepeats" @click="onInsert">
<FontAwesomeIcon icon="plus" class="mr-1" />
<span data-description="repeat insert">Insert {{ props.input.title || "Repeat" }}</span>
</b-button>
</div>
</template>
50 changes: 50 additions & 0 deletions client/src/composables/keyedObjects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useKeyedObjects } from "./keyedObjects";

describe("useKeyedObjects", () => {
it("returns the same id for the same object", () => {
const { keyObject } = useKeyedObjects();

const obj = {
a: 1,
b: 2,
} as {
a: number;
b: number;
c?: number;
};

const keyA = keyObject(obj);
expect(keyObject(obj)).toBe(keyA);

obj.a += 5;
obj["c"] = 6;

expect(keyObject(obj)).toBe(keyA);
});

it("returns different ids for different objects", () => {
const { keyObject } = useKeyedObjects();

const objA = {
a: 1,
};

const objB = {
b: 2,
};

const keyA = keyObject(objA);
const keyB = keyObject(objB);
expect(keyA).not.toBe(keyB);

const objD = {
d: 3,
};
const objE = structuredClone(objD);
const keyD = keyObject(objD);
const keyE = keyObject(objE);
expect(keyD).not.toBe(keyE);

expect(keyObject({})).not.toBe(keyObject({}));
});
});
34 changes: 34 additions & 0 deletions client/src/composables/keyedObjects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { unref } from "vue";

import { useUid } from "./utils/uid";

/**
* Allows for keying objects by their internal ids.
* Returns a function which takes an object and returns a string uid.
* Passing the same object to this function twice, will return the same id.
*
* Passing the same object to a function created by another
* `useKeyedObjects` composable will not produce the same ids.
*
* A cloned object will not have the same id as the object it was cloned from.
* Modifying an objects properties will not affect its id.
*
* @returns A function which allows for keying objects
*/
export function useKeyedObjects() {
const keyCache = new WeakMap<object, string>();

function keyObject(object: object) {
const cachedKey = keyCache.get(object);

if (cachedKey) {
return cachedKey;
} else {
const key = unref(useUid("object-"));
keyCache.set(object, key);
return key;
}
}

return { keyObject };
}
1 change: 1 addition & 0 deletions client/src/style/scss/theme/blue.scss
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ $panel_footer_height: 25px;

// Portlets
$portlet-bg-color: $gray-200;
$portlet-focus-color: $brand-info;

// Borders
$border-radius-base: 0.1875rem;
Expand Down
Loading

0 comments on commit b7380c2

Please sign in to comment.