Skip to content

Commit

Permalink
Merge branch 'master' into sekhmet/prepare-api-bump
Browse files Browse the repository at this point in the history
  • Loading branch information
bonustrack authored Sep 30, 2024
2 parents a46fc30 + 7da2267 commit af728c5
Show file tree
Hide file tree
Showing 25 changed files with 1,511 additions and 220 deletions.
2 changes: 2 additions & 0 deletions apps/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@
"@ethersproject/strings": "^5.7.0",
"@ethersproject/units": "^5.7.0",
"@ethersproject/wallet": "^5.7.0",
"@headlessui-float/vue": "^0.15.0",
"@headlessui/vue": "^1.7.16",
"@openzeppelin/merkle-tree": "^1.0.5",
"@snapshot-labs/eslint-config-vue": "^0.1.0-beta.18",
"@snapshot-labs/highlight": "^0.1.0-beta.2",
"@snapshot-labs/lock": "^0.2.7",
"@snapshot-labs/pineapple": "^1.1.0",
"@snapshot-labs/prettier-config": "^0.1.0-beta.18",
"@snapshot-labs/snapshot.js": "^0.12.13",
"@snapshot-labs/sx": "^0.1.0",
"@vueuse/core": "^10.4.1",
"@walletconnect/core": "^2.11.0",
Expand Down
126 changes: 126 additions & 0 deletions apps/ui/src/components/Combobox.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<script setup lang="ts" generic="T extends string | number, U extends Item<T>">
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions
} from '@headlessui/vue';
import { Float } from '@headlessui-float/vue';
import { VNode } from 'vue';
export type Item<T extends string | number> = {
id: T;
name: string;
icon?: VNode;
};
defineOptions({ inheritAttrs: false });
const model = defineModel<T>({ required: true });
const props = defineProps<{
error?: string;
definition: {
options: U[];
default?: T;
examples?: string[];
} & any;
}>();
const dirty = ref(false);
const query = ref('');
const filteredOptions = computed(() => {
return props.definition.options.filter(option =>
option.name.toLowerCase().includes(query.value.toLowerCase())
);
});
const inputValue = computed({
get() {
if (!model.value && !dirty.value && props.definition.default) {
return props.definition.default;
}
return model.value;
},
set(newValue: T) {
dirty.value = true;
model.value = newValue;
}
});
function handleFocus(event: FocusEvent, open: boolean) {
if (!event.target || open) return;
(event.target as HTMLInputElement).select();
}
function getDisplayValue(value: T) {
const option = props.definition.options.find(option => option.id === value);
return option ? option.name : '';
}
watch(model, () => {
dirty.value = true;
});
</script>

<template>
<UiWrapperInput
:definition="definition"
:error="error"
:dirty="dirty"
class="relative mb-[14px]"
>
<Combobox v-slot="{ open }" v-model="inputValue" as="div">
<Float adaptive-width strategy="fixed" placement="bottom-end">
<div>
<ComboboxButton class="w-full">
<ComboboxInput
class="s-input !flex items-center justify-between !mb-0"
:class="{
'!rounded-b-none': open
}"
autocomplete="off"
:placeholder="definition.examples?.[0]"
:display-value="item => getDisplayValue(item as T)"
@change="e => (query = e.target.value)"
@focus="event => handleFocus(event, open)"
/>
</ComboboxButton>
<ComboboxButton class="absolute right-3 bottom-[14px]">
<IH-chevron-up v-if="open" />
<IH-chevron-down v-else />
</ComboboxButton>
</div>
<ComboboxOptions
class="w-full bg-skin-border rounded-b-lg border-t-skin-text/10 border shadow-xl"
>
<div class="max-h-[208px] px-3 overflow-y-auto">
<ComboboxOption
v-for="item in filteredOptions"
v-slot="{ selected, disabled }"
:key="item.id"
:value="item.id"
class="flex items-center justify-between"
>
<component :is="item.icon" class="size-[20px] mr-2" />
<span
class="w-full py-2 text-skin-link"
:class="{
'opacity-40': disabled,
'cursor-pointer': !disabled
}"
>
{{ item.name }}
</span>
<IH-check v-if="selected" class="text-skin-success" />
</ComboboxOption>
</div>
</ComboboxOptions>
</Float>
</Combobox>
</UiWrapperInput>
</template>
2 changes: 1 addition & 1 deletion apps/ui/src/components/EditorVotingType.vue
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ watch(
:open="modalOpen"
:voting-types="votingTypes"
:initial-state="proposal.type"
@save="handleVoteTypeSelected"
@save="voteType => handleVoteTypeSelected(voteType as VoteType)"
@close="modalOpen = false"
/>
</teleport>
Expand Down
173 changes: 173 additions & 0 deletions apps/ui/src/components/FormSpaceStrategies.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<script setup lang="ts">
import objectHash from 'object-hash';
import { MAX_STRATEGIES } from '@/helpers/turbo';
import { StrategyConfig, StrategyTemplate } from '@/networks/types';
import { NetworkID, Space } from '@/types';
const snapshotChainId = defineModel<string>('snapshotChainId', {
required: true
});
const strategies = defineModel<StrategyConfig[]>('strategies', {
required: true
});
const props = defineProps<{
networkId: NetworkID;
space: Space;
}>();
const emit = defineEmits<{
(e: 'updateValidity', valid: boolean): void;
}>();
const isStrategiesModalOpen = ref(false);
const isEditStrategyModalOpen = ref(false);
const editedStrategy: Ref<StrategyConfig | null> = ref(null);
const strategiesLimit = computed(() => {
const spaceType = props.space.turbo
? 'turbo'
: props.space.verified
? 'verified'
: 'default';
return MAX_STRATEGIES[spaceType];
});
const isTicketValid = computed(() => {
return !(
strategies.value.some(s => s.address === 'ticket') &&
props.space.additionalRawData?.voteValidation.name === 'any'
);
});
function handleAddStrategy(strategy: StrategyTemplate) {
editedStrategy.value = {
id: crypto.randomUUID(),
params: strategy.defaultParams || {},
...strategy
};
isEditStrategyModalOpen.value = true;
}
async function handleEditStrategy(strategy: StrategyConfig) {
editedStrategy.value = strategy;
isEditStrategyModalOpen.value = true;
}
function handleSaveStrategy(params: Record<string, any>, network: string) {
const editedStrategyValue = editedStrategy.value;
if (editedStrategyValue === null) return;
isEditStrategyModalOpen.value = false;
let allStrategies = [...strategies.value];
if (!allStrategies.find(s => s.id === editedStrategyValue.id)) {
allStrategies.push(editedStrategyValue);
}
strategies.value = allStrategies.map(strategy => {
if (strategy.id !== editedStrategyValue.id) return strategy;
return {
...strategy,
chainId: network,
params
};
});
}
function validateStrategy(params: Record<string, any>, network: string) {
const editedStrategyValue = editedStrategy.value;
if (editedStrategyValue === null) return;
const otherStrategies = strategies.value.filter(
s => s.id !== editedStrategyValue.id
);
const hasDuplicates = otherStrategies.some(
s =>
s.address === editedStrategyValue.address &&
s.chainId === network &&
objectHash(s.params) === objectHash(params)
);
if (hasDuplicates) return 'Two identical strategies are not allowed.';
}
function handleRemoveStrategy(strategy: StrategyConfig) {
strategies.value = strategies.value.filter(s => s.id !== strategy.id);
}
watchEffect(() => {
emit('updateValidity', isTicketValid.value);
});
</script>

<template>
<h4 class="eyebrow mb-2 font-medium">Strategies</h4>
<div class="s-box mb-4">
<UiSelectorNetwork
v-model="snapshotChainId"
:network-id="networkId"
tooltip="The default network used for this space. Networks can also be specified in individual strategies"
/>
</div>
<UiContainerSettings
:title="`Select up to ${strategiesLimit} strategies`"
description="(Voting power is cumulative)"
>
<div class="space-y-3 mb-3">
<div v-if="strategies.length === 0">No strategies selected</div>
<UiMessage
v-else-if="!isTicketValid"
type="danger"
learn-more-link="https://snapshot.mirror.xyz/-uSylOUP82hGAyWUlVn4lCg9ESzKX9QCvsUgvv-ng84"
>
In order to use the "ticket" strategy you are required to set a voting
validation strategy. This combination reduces the risk of spam and sybil
attacks.
</UiMessage>
<FormStrategiesStrategyActive
v-for="strategy in strategies"
:key="strategy.id"
class="mb-3"
:network-id="networkId"
:strategy="strategy"
@edit-strategy="handleEditStrategy"
@delete-strategy="handleRemoveStrategy"
/>
</div>
<UiButton
v-if="strategies.length < strategiesLimit"
class="w-full flex items-center justify-center gap-1"
@click="isStrategiesModalOpen = true"
>
<IH-plus class="shrink-0 size-[16px]" />
Add strategy
</UiButton>
</UiContainerSettings>
<teleport to="#modal">
<ModalStrategies
:open="isStrategiesModalOpen"
:network-id="networkId"
@add-strategy="handleAddStrategy"
@close="isStrategiesModalOpen = false"
/>
<ModalEditStrategy
v-if="editedStrategy"
with-network-selector
:open="isEditStrategyModalOpen"
:network-id="networkId"
:initial-network="editedStrategy.chainId ?? snapshotChainId"
:strategy-address="editedStrategy.address"
:definition="editedStrategy.paramsDefinition"
:initial-state="editedStrategy.params"
:custom-error-validation="validateStrategy"
@save="handleSaveStrategy"
@close="isEditStrategyModalOpen = false"
/>
</teleport>
</template>
Loading

0 comments on commit af728c5

Please sign in to comment.