mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2025-04-26 13:34:45 +00:00
Show secrets from org and global level (#2873)
Co-authored-by: qwerty287 <80460567+qwerty287@users.noreply.github.com>
This commit is contained in:
parent
60a3922e02
commit
16803d6217
13 changed files with 191 additions and 94 deletions
|
@ -4233,6 +4233,12 @@ const docTemplate = `{
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"org_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"repo_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"value": {
|
"value": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|
|
@ -70,8 +70,8 @@ type SecretStore interface {
|
||||||
// Secret represents a secret variable, such as a password or token.
|
// Secret represents a secret variable, such as a password or token.
|
||||||
type Secret struct {
|
type Secret struct {
|
||||||
ID int64 `json:"id" xorm:"pk autoincr 'secret_id'"`
|
ID int64 `json:"id" xorm:"pk autoincr 'secret_id'"`
|
||||||
OrgID int64 `json:"-" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_org_id'"`
|
OrgID int64 `json:"org_id" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_org_id'"`
|
||||||
RepoID int64 `json:"-" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_repo_id'"`
|
RepoID int64 `json:"repo_id" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_repo_id'"`
|
||||||
Name string `json:"name" xorm:"NOT NULL UNIQUE(s) INDEX 'secret_name'"`
|
Name string `json:"name" xorm:"NOT NULL UNIQUE(s) INDEX 'secret_name'"`
|
||||||
Value string `json:"value,omitempty" xorm:"TEXT 'secret_value'"`
|
Value string `json:"value,omitempty" xorm:"TEXT 'secret_value'"`
|
||||||
Images []string `json:"images" xorm:"json 'secret_images'"`
|
Images []string `json:"images" xorm:"json 'secret_images'"`
|
||||||
|
@ -89,15 +89,20 @@ func (s *Secret) BeforeInsert() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global secret.
|
// Global secret.
|
||||||
func (s Secret) Global() bool {
|
func (s Secret) IsGlobal() bool {
|
||||||
return s.RepoID == 0 && s.OrgID == 0
|
return s.RepoID == 0 && s.OrgID == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Organization secret.
|
// Organization secret.
|
||||||
func (s Secret) Organization() bool {
|
func (s Secret) IsOrganization() bool {
|
||||||
return s.RepoID == 0 && s.OrgID != 0
|
return s.RepoID == 0 && s.OrgID != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repository secret.
|
||||||
|
func (s Secret) IsRepository() bool {
|
||||||
|
return s.RepoID != 0 && s.OrgID == 0
|
||||||
|
}
|
||||||
|
|
||||||
// Match returns true if an image and event match the restricted list.
|
// Match returns true if an image and event match the restricted list.
|
||||||
func (s *Secret) Match(event WebhookEvent) bool {
|
func (s *Secret) Match(event WebhookEvent) bool {
|
||||||
if len(s.Events) == 0 {
|
if len(s.Events) == 0 {
|
||||||
|
|
|
@ -48,16 +48,17 @@ func (b *builtin) SecretListPipeline(repo *model.Repo, _ *model.Pipeline, p *mod
|
||||||
// Priority order in case of duplicate names are repository, user/organization, global
|
// Priority order in case of duplicate names are repository, user/organization, global
|
||||||
secrets := make([]*model.Secret, 0, len(s))
|
secrets := make([]*model.Secret, 0, len(s))
|
||||||
uniq := make(map[string]struct{})
|
uniq := make(map[string]struct{})
|
||||||
for _, cond := range []struct {
|
for _, condition := range []struct {
|
||||||
Global bool
|
IsRepository bool
|
||||||
Organization bool
|
IsOrganization bool
|
||||||
|
IsGlobal bool
|
||||||
}{
|
}{
|
||||||
{},
|
{IsRepository: true},
|
||||||
{Organization: true},
|
{IsOrganization: true},
|
||||||
{Global: true},
|
{IsGlobal: true},
|
||||||
} {
|
} {
|
||||||
for _, secret := range s {
|
for _, secret := range s {
|
||||||
if secret.Global() != cond.Global || secret.Organization() != cond.Organization {
|
if secret.IsRepository() != condition.IsRepository || secret.IsOrganization() != condition.IsOrganization || secret.IsGlobal() != condition.IsGlobal {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := uniq[secret.Name]; ok {
|
if _, ok := uniq[secret.Name]; ok {
|
||||||
|
|
|
@ -51,7 +51,7 @@ func TestSecretListPipeline(t *testing.T) {
|
||||||
// repo secret
|
// repo secret
|
||||||
repoSecret := &model.Secret{
|
repoSecret := &model.Secret{
|
||||||
ID: 3,
|
ID: 3,
|
||||||
OrgID: 1,
|
OrgID: 0,
|
||||||
RepoID: 1,
|
RepoID: 1,
|
||||||
Name: "secret",
|
Name: "secret",
|
||||||
Value: "value-repo",
|
Value: "value-repo",
|
||||||
|
|
|
@ -253,7 +253,7 @@ func TestOrgSecretList(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, list, 1)
|
assert.Len(t, list, 1)
|
||||||
|
|
||||||
assert.True(t, list[0].Organization())
|
assert.True(t, list[0].IsOrganization())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGlobalSecretFind(t *testing.T) {
|
func TestGlobalSecretFind(t *testing.T) {
|
||||||
|
@ -306,5 +306,5 @@ func TestGlobalSecretList(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, list, 1)
|
assert.Len(t, list, 1)
|
||||||
|
|
||||||
assert.True(t, list[0].Global())
|
assert.True(t, list[0].IsGlobal())
|
||||||
}
|
}
|
||||||
|
|
|
@ -140,7 +140,7 @@
|
||||||
"saved": "Secret saved",
|
"saved": "Secret saved",
|
||||||
"images": {
|
"images": {
|
||||||
"images": "Available for following images",
|
"images": "Available for following images",
|
||||||
"desc": "Comma separated list of images where this secret is available, leave empty to allow all images"
|
"desc": "List of images where this secret is available, leave empty to allow all images"
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"events": "Available at following events",
|
"events": "Available at following events",
|
||||||
|
@ -305,7 +305,7 @@
|
||||||
"saved": "Organization secret saved",
|
"saved": "Organization secret saved",
|
||||||
"images": {
|
"images": {
|
||||||
"images": "Available for following images",
|
"images": "Available for following images",
|
||||||
"desc": "Comma separated list of images where this secret is available, leave empty to allow all images"
|
"desc": "List of images where this secret is available, leave empty to allow all images"
|
||||||
},
|
},
|
||||||
"plugins_only": "Only available for plugins",
|
"plugins_only": "Only available for plugins",
|
||||||
"events": {
|
"events": {
|
||||||
|
@ -334,7 +334,7 @@
|
||||||
"saved": "Global secret saved",
|
"saved": "Global secret saved",
|
||||||
"images": {
|
"images": {
|
||||||
"images": "Available for following images",
|
"images": "Available for following images",
|
||||||
"desc": "Comma separated list of images where this secret is available, leave empty to allow all images"
|
"desc": "List of images where this secret is available, leave empty to allow all images"
|
||||||
},
|
},
|
||||||
"plugins_only": "Only available for plugins",
|
"plugins_only": "Only available for plugins",
|
||||||
"events": {
|
"events": {
|
||||||
|
@ -476,7 +476,7 @@
|
||||||
"saved": "User secret saved",
|
"saved": "User secret saved",
|
||||||
"images": {
|
"images": {
|
||||||
"images": "Available for following images",
|
"images": "Available for following images",
|
||||||
"desc": "Comma separated list of images where this secret is available, leave empty to allow all images"
|
"desc": "List of images where this secret is available, leave empty to allow all images"
|
||||||
},
|
},
|
||||||
"plugins_only": "Only available for plugins",
|
"plugins_only": "Only available for plugins",
|
||||||
"events": {
|
"events": {
|
||||||
|
@ -504,5 +504,7 @@
|
||||||
"default": "default",
|
"default": "default",
|
||||||
"info": "Info",
|
"info": "Info",
|
||||||
"running_version": "You are running Woodpecker {0}",
|
"running_version": "You are running Woodpecker {0}",
|
||||||
"update_woodpecker": "Please update your Woodpecker instance to {0}"
|
"update_woodpecker": "Please update your Woodpecker instance to {0}",
|
||||||
|
"global_level_secret": "global secret",
|
||||||
|
"org_level_secret": "organization secret"
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
>
|
>
|
||||||
<slot>
|
<slot>
|
||||||
<Icon v-if="startIcon" :name="startIcon" class="!w-6 !h-6" :class="{ invisible: isLoading, 'mr-1': text }" />
|
<Icon v-if="startIcon" :name="startIcon" class="!w-6 !h-6" :class="{ invisible: isLoading, 'mr-1': text }" />
|
||||||
<span :class="{ invisible: isLoading }">{{ text }}</span>
|
<span :class="{ invisible: isLoading }" class="flex-shrink-0">{{ text }}</span>
|
||||||
<Icon v-if="endIcon" :name="endIcon" class="ml-2 w-6 h-6" :class="{ invisible: isLoading }" />
|
<Icon v-if="endIcon" :name="endIcon" class="ml-2 w-6 h-6" :class="{ invisible: isLoading }" />
|
||||||
<div
|
<div
|
||||||
v-if="isLoading"
|
v-if="isLoading"
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
<SecretList
|
<SecretList
|
||||||
v-if="!selectedSecret"
|
v-if="!selectedSecret"
|
||||||
v-model="secrets"
|
:model-value="secrets"
|
||||||
i18n-prefix="repo.settings.secrets."
|
i18n-prefix="repo.settings.secrets."
|
||||||
:is-deleting="isDeleting"
|
:is-deleting="isDeleting"
|
||||||
@edit="editSecret"
|
@edit="editSecret"
|
||||||
|
@ -64,15 +64,54 @@ const repo = inject<Ref<Repo>>('repo');
|
||||||
const selectedSecret = ref<Partial<Secret>>();
|
const selectedSecret = ref<Partial<Secret>>();
|
||||||
const isEditingSecret = computed(() => !!selectedSecret.value?.id);
|
const isEditingSecret = computed(() => !!selectedSecret.value?.id);
|
||||||
|
|
||||||
async function loadSecrets(page: number): Promise<Secret[] | null> {
|
async function loadSecrets(page: number, level: 'repo' | 'org' | 'global'): Promise<Secret[] | null> {
|
||||||
if (!repo?.value) {
|
if (!repo?.value) {
|
||||||
throw new Error("Unexpected: Can't load repo");
|
throw new Error("Unexpected: Can't load repo");
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiClient.getSecretList(repo.value.id, page);
|
switch (level) {
|
||||||
|
case 'repo':
|
||||||
|
return apiClient.getSecretList(repo.value.id, page);
|
||||||
|
case 'org':
|
||||||
|
return apiClient.getOrgSecretList(repo.value.org_id, page);
|
||||||
|
case 'global':
|
||||||
|
return apiClient.getGlobalSecretList(page);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unexpected level: ${level}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { resetPage, data: secrets } = usePagination(loadSecrets, () => !selectedSecret.value);
|
const { resetPage, data: _secrets } = usePagination(loadSecrets, () => !selectedSecret.value, {
|
||||||
|
each: ['repo', 'org', 'global'],
|
||||||
|
});
|
||||||
|
const secrets = computed(() => {
|
||||||
|
const secretsList: Record<string, Secret & { edit?: boolean; level: 'repo' | 'org' | 'global' }> = {};
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for (const level of ['repo', 'org', 'global']) {
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for (const secret of _secrets.value) {
|
||||||
|
if (
|
||||||
|
((level === 'repo' && secret.repo_id !== 0 && secret.org_id === 0) ||
|
||||||
|
(level === 'org' && secret.repo_id === 0 && secret.org_id !== 0) ||
|
||||||
|
(level === 'global' && secret.repo_id === 0 && secret.org_id === 0)) &&
|
||||||
|
!secretsList[secret.name]
|
||||||
|
) {
|
||||||
|
secretsList[secret.name] = { ...secret, edit: secret.repo_id !== 0, level };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelsOrder = {
|
||||||
|
global: 0,
|
||||||
|
org: 1,
|
||||||
|
repo: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.values(secretsList)
|
||||||
|
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.toSorted((a, b) => levelsOrder[b.level] - levelsOrder[a.level]);
|
||||||
|
});
|
||||||
|
|
||||||
const { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async () => {
|
const { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async () => {
|
||||||
if (!repo?.value) {
|
if (!repo?.value) {
|
||||||
|
@ -93,7 +132,7 @@ const { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async ()
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
selectedSecret.value = undefined;
|
selectedSecret.value = undefined;
|
||||||
resetPage();
|
await resetPage();
|
||||||
});
|
});
|
||||||
|
|
||||||
const { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (_secret: Secret) => {
|
const { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (_secret: Secret) => {
|
||||||
|
@ -103,7 +142,7 @@ const { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (
|
||||||
|
|
||||||
await apiClient.deleteSecret(repo.value.id, _secret.name);
|
await apiClient.deleteSecret(repo.value.id, _secret.name);
|
||||||
notifications.notify({ title: i18n.t('repo.settings.secrets.deleted'), type: 'success' });
|
notifications.notify({ title: i18n.t('repo.settings.secrets.deleted'), type: 'success' });
|
||||||
resetPage();
|
await resetPage();
|
||||||
});
|
});
|
||||||
|
|
||||||
function editSecret(secret: Secret) {
|
function editSecret(secret: Secret) {
|
||||||
|
|
|
@ -11,11 +11,27 @@
|
||||||
</InputField>
|
</InputField>
|
||||||
|
|
||||||
<InputField :label="$t(i18nPrefix + 'value')">
|
<InputField :label="$t(i18nPrefix + 'value')">
|
||||||
<TextField v-model="innerValue.value" :placeholder="$t(i18nPrefix + 'value')" :lines="5" />
|
<TextField
|
||||||
|
v-model="innerValue.value"
|
||||||
|
:placeholder="$t(i18nPrefix + 'value')"
|
||||||
|
:lines="5"
|
||||||
|
:required="!isEditingSecret"
|
||||||
|
/>
|
||||||
</InputField>
|
</InputField>
|
||||||
|
|
||||||
<InputField :label="$t(i18nPrefix + 'images.images')">
|
<InputField :label="$t(i18nPrefix + 'images.images')">
|
||||||
<TextField v-model="images" :placeholder="$t(i18nPrefix + 'images.desc')" />
|
<span class="ml-1 mb-2 text-wp-text-alt-100">{{ $t(i18nPrefix + 'images.desc') }}</span>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div v-for="image in innerValue.images" :key="image" class="flex gap-2">
|
||||||
|
<TextField :model-value="image" disabled />
|
||||||
|
<Button type="button" color="gray" start-icon="trash" @click="removeImage(image)" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<TextField v-model="newImage" @keydown.enter.prevent="addNewImage" />
|
||||||
|
<Button type="button" color="gray" start-icon="plus" @click="addNewImage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</InputField>
|
</InputField>
|
||||||
|
|
||||||
<InputField :label="$t(i18nPrefix + 'events.events')">
|
<InputField :label="$t(i18nPrefix + 'events.events')">
|
||||||
|
@ -36,7 +52,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, toRef } from 'vue';
|
import { computed, ref, toRef } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import Button from '~/components/atomic/Button.vue';
|
import Button from '~/components/atomic/Button.vue';
|
||||||
|
@ -67,21 +83,20 @@ const innerValue = computed({
|
||||||
emit('update:modelValue', value);
|
emit('update:modelValue', value);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const images = computed<string>({
|
|
||||||
get() {
|
|
||||||
return innerValue.value?.images?.join(',') || '';
|
|
||||||
},
|
|
||||||
set(value) {
|
|
||||||
if (innerValue.value) {
|
|
||||||
innerValue.value.images = value
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter((s) => s !== '');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const isEditingSecret = computed(() => !!innerValue.value?.id);
|
const isEditingSecret = computed(() => !!innerValue.value?.id);
|
||||||
|
|
||||||
|
const newImage = ref('');
|
||||||
|
function addNewImage() {
|
||||||
|
if (!newImage.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
innerValue.value.images?.push(newImage.value);
|
||||||
|
newImage.value = '';
|
||||||
|
}
|
||||||
|
function removeImage(image: string) {
|
||||||
|
innerValue.value.images = innerValue.value.images?.filter((i) => i !== image);
|
||||||
|
}
|
||||||
|
|
||||||
const secretEventsOptions: CheckboxOption[] = [
|
const secretEventsOptions: CheckboxOption[] = [
|
||||||
{ value: WebhookEvents.Push, text: i18n.t('repo.pipeline.event.push') },
|
{ value: WebhookEvents.Push, text: i18n.t('repo.pipeline.event.push') },
|
||||||
{ value: WebhookEvents.Tag, text: i18n.t('repo.pipeline.event.tag') },
|
{ value: WebhookEvents.Tag, text: i18n.t('repo.pipeline.event.tag') },
|
||||||
|
@ -99,6 +114,11 @@ function save() {
|
||||||
if (!innerValue.value) {
|
if (!innerValue.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (newImage.value) {
|
||||||
|
innerValue.value.images?.push(newImage.value);
|
||||||
|
}
|
||||||
|
|
||||||
emit('save', innerValue.value);
|
emit('save', innerValue.value);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -6,22 +6,29 @@
|
||||||
class="items-center !bg-wp-background-200 !dark:bg-wp-background-100"
|
class="items-center !bg-wp-background-200 !dark:bg-wp-background-100"
|
||||||
>
|
>
|
||||||
<span>{{ secret.name }}</span>
|
<span>{{ secret.name }}</span>
|
||||||
|
<Badge
|
||||||
|
v-if="secret.edit === false"
|
||||||
|
class="ml-2"
|
||||||
|
:label="secret.org_id === 0 ? $t('global_level_secret') : $t('org_level_secret')"
|
||||||
|
/>
|
||||||
<div class="ml-auto space-x-2 <md:hidden">
|
<div class="ml-auto space-x-2 <md:hidden">
|
||||||
<Badge v-for="event in secret.events" :key="event" :label="event" />
|
<Badge v-for="event in secret.events" :key="event" :label="event" />
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<template v-if="secret.edit !== false">
|
||||||
icon="edit"
|
<IconButton
|
||||||
class="ml-2 <md:ml-auto w-8 h-8"
|
icon="edit"
|
||||||
:title="$t('repo.settings.secrets.edit')"
|
class="ml-2 <md:ml-auto w-8 h-8"
|
||||||
@click="editSecret(secret)"
|
:title="$t('repo.settings.secrets.edit')"
|
||||||
/>
|
@click="editSecret(secret)"
|
||||||
<IconButton
|
/>
|
||||||
icon="trash"
|
<IconButton
|
||||||
class="ml-2 w-8 h-8 hover:text-wp-control-error-100"
|
icon="trash"
|
||||||
:is-loading="isDeleting"
|
class="ml-2 w-8 h-8 hover:text-wp-control-error-100"
|
||||||
:title="$t('repo.settings.secrets.delete')"
|
:is-loading="isDeleting"
|
||||||
@click="deleteSecret(secret)"
|
:title="$t('repo.settings.secrets.delete')"
|
||||||
/>
|
@click="deleteSecret(secret)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<div v-if="secrets?.length === 0" class="ml-2">{{ $t(i18nPrefix + 'none') }}</div>
|
<div v-if="secrets?.length === 0" class="ml-2">{{ $t(i18nPrefix + 'none') }}</div>
|
||||||
|
@ -32,12 +39,13 @@
|
||||||
import { toRef } from 'vue';
|
import { toRef } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import Badge from '~/components/atomic/Badge.vue';
|
||||||
import IconButton from '~/components/atomic/IconButton.vue';
|
import IconButton from '~/components/atomic/IconButton.vue';
|
||||||
import ListItem from '~/components/atomic/ListItem.vue';
|
import ListItem from '~/components/atomic/ListItem.vue';
|
||||||
import { Secret } from '~/lib/api/types';
|
import { Secret } from '~/lib/api/types';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: Secret[];
|
modelValue: (Secret & { edit?: boolean })[];
|
||||||
isDeleting: boolean;
|
isDeleting: boolean;
|
||||||
i18nPrefix: string;
|
i18nPrefix: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { useInfiniteScroll } from '@vueuse/core';
|
import { useInfiniteScroll } from '@vueuse/core';
|
||||||
import { onMounted, Ref, ref, watch } from 'vue';
|
import { onMounted, Ref, ref, UnwrapRef, watch } from 'vue';
|
||||||
|
|
||||||
export async function usePaginate<T>(getSingle: (page: number) => Promise<T[]>): Promise<T[]> {
|
export async function usePaginate<T>(getSingle: (page: number) => Promise<T[]>): Promise<T[]> {
|
||||||
let hasMore = true;
|
let hasMore = true;
|
||||||
|
@ -15,59 +15,71 @@ export async function usePaginate<T>(getSingle: (page: number) => Promise<T[]>):
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePagination<T>(
|
export function usePagination<T, S = unknown>(
|
||||||
_loadData: (page: number) => Promise<T[] | null>,
|
_loadData: (page: number, arg: S) => Promise<T[] | null>,
|
||||||
isActive: () => boolean = () => true,
|
isActive: () => boolean = () => true,
|
||||||
scrollElement = ref(document.getElementById('scroll-component')),
|
{ scrollElement: _scrollElement, each: _each }: { scrollElement?: Ref<HTMLElement | null>; each?: S[] } = {},
|
||||||
) {
|
) {
|
||||||
|
const scrollElement = _scrollElement ?? ref(document.getElementById('scroll-component'));
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(0);
|
const pageSize = ref(0);
|
||||||
const hasMore = ref(true);
|
const hasMore = ref(true);
|
||||||
const data = ref<T[]>([]) as Ref<T[]>;
|
const data = ref<T[]>([]) as Ref<T[]>;
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const each = ref(_each ?? []);
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true;
|
if (loading.value === true || hasMore.value === false) {
|
||||||
const newData = await _loadData(page.value);
|
return;
|
||||||
hasMore.value = newData !== null && newData.length >= pageSize.value;
|
|
||||||
if (newData !== null && newData.length !== 0) {
|
|
||||||
if (page.value === 1) {
|
|
||||||
pageSize.value = newData.length;
|
|
||||||
data.value = newData;
|
|
||||||
} else {
|
|
||||||
data.value.push(...newData);
|
|
||||||
}
|
|
||||||
} else if (page.value === 1) {
|
|
||||||
data.value = [];
|
|
||||||
} else {
|
|
||||||
hasMore.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
const newData = (await _loadData(page.value, each.value?.[0] as S)) ?? [];
|
||||||
|
hasMore.value = newData.length >= pageSize.value && newData.length > 0;
|
||||||
|
if (newData.length > 0) {
|
||||||
|
data.value.push(...newData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// last page and each has more
|
||||||
|
if (!hasMore.value && each.value.length > 0) {
|
||||||
|
// use next each element
|
||||||
|
each.value.shift();
|
||||||
|
page.value = 1;
|
||||||
|
pageSize.value = 0;
|
||||||
|
hasMore.value = each.value.length > 0;
|
||||||
|
if (hasMore.value) {
|
||||||
|
loading.value = false;
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pageSize.value = newData.length;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadData);
|
onMounted(loadData);
|
||||||
watch(page, loadData);
|
watch(page, loadData);
|
||||||
|
|
||||||
useInfiniteScroll(
|
function nextPage() {
|
||||||
scrollElement,
|
if (isActive() && !loading.value && hasMore.value) {
|
||||||
() => {
|
page.value += 1;
|
||||||
if (isActive() && !loading.value && hasMore.value) {
|
|
||||||
// load more
|
|
||||||
page.value += 1;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ distance: 10 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetPage = () => {
|
|
||||||
if (page.value !== 1) {
|
|
||||||
// just set page = 1, will be handled by watcher
|
|
||||||
page.value = 1;
|
|
||||||
} else {
|
|
||||||
// we need to reload, but page is already 1, so changing won't trigger watcher
|
|
||||||
loadData();
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
return { resetPage, data };
|
useInfiniteScroll(scrollElement, nextPage, { distance: 10 });
|
||||||
|
|
||||||
|
async function resetPage() {
|
||||||
|
const _page = page.value;
|
||||||
|
|
||||||
|
hasMore.value = true;
|
||||||
|
data.value = [];
|
||||||
|
each.value = (_each ?? []) as UnwrapRef<S[]>;
|
||||||
|
page.value = 1;
|
||||||
|
|
||||||
|
if (_page === 1) {
|
||||||
|
// we need to reload manually as the page is already 1, so changing won't trigger watcher
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resetPage, nextPage, data, hasMore, loading };
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,8 @@ import { WebhookEvents } from './webhook';
|
||||||
|
|
||||||
export type Secret = {
|
export type Secret = {
|
||||||
id: string;
|
id: string;
|
||||||
|
repo_id: number;
|
||||||
|
org_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
events: WebhookEvents[];
|
events: WebhookEvents[];
|
||||||
|
|
|
@ -141,6 +141,8 @@ type (
|
||||||
// Secret represents a secret variable, such as a password or token.
|
// Secret represents a secret variable, such as a password or token.
|
||||||
Secret struct {
|
Secret struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
OrgID int64 `json:"org_id"`
|
||||||
|
RepoID int64 `json:"repo_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Value string `json:"value,omitempty"`
|
Value string `json:"value,omitempty"`
|
||||||
Images []string `json:"images"`
|
Images []string `json:"images"`
|
||||||
|
|
Loading…
Reference in a new issue