Skip to content

Vue UI Guide ​

SA plugins use Vue 3 Single File Components (SFCs) with PrimeVue v3 for UI components and PrimeFlex for CSS utility classes. All PrimeVue components are registered globally — no imports needed in templates.

Vue Files in a Plugin ​

FilePurpose
widget.vueDashboard widget (registered via addWidget)
setting.vueSettings page (registered via addPage(..., "setting"))
Any other .vueAdditional pages registered via addPage()

Import other .vue files from your own plugin using the vue: prefix:

js
import MyDialog from "vue:my-plugin/dialogs/confirm.vue";

export default {
  components: { MyDialog }
}

Widget Layout ​

Widgets must wrap their root in the SA dashboard grid column — this is required for correct placement on the dashboard:

vue
<template>
    <div class="col-12 lg:col-6 xl:col-3 p-1">
        <div class="card h-full">
            <h5>{{ $t("my-plugin.name") }}</h5>
            <p>{{ value }}</p>
        </div>
    </div>
</template>

<script>
import api from 'api';

export default {
    data() {
        return { value: null }
    },
    created() {
        this.value = api.database("my-plugin").readJson(["data.json"]);
    }
}
</script>

Omitting the col-12 lg:col-6 xl:col-3 p-1 wrapper will break the dashboard grid layout.

Page Layout ​

Standard pages use the card class for panels:

vue
<template>
    <div>
        <!-- header card -->
        <div class="card flex align-items-center justify-content-between">
            <h5 class="m-0">{{ $t("my-plugin.name") }}</h5>
            <div>
                <Button icon="pi pi-save" class="p-button-text" @click="save()" />
                <Button icon="pi pi-refresh" class="p-button-text" @click="init()" />
            </div>
        </div>

        <!-- content card -->
        <div class="card">
            <!-- content here -->
        </div>
    </div>
</template>

Forms ​

Use the p-fluid formgrid grid pattern for responsive forms:

vue
<template>
    <div class="card">
        <div class="p-fluid formgrid grid">
            <div class="field col-12 md:col-6">
                <label for="name" class="block mb-2">Name</label>
                <InputText id="name" v-model="form.name" />
            </div>

            <div class="field col-12 md:col-6">
                <label for="type" class="block mb-2">Type</label>
                <Dropdown
                    id="type"
                    v-model="form.type"
                    :options="[{ label: 'Admin', value: 'admin' }, { label: 'User', value: 'user' }]"
                    optionLabel="label"
                    optionValue="value"
                    class="w-full"
                />
            </div>

            <div class="field col-12 md:col-6">
                <label for="count" class="block mb-2">Count</label>
                <InputNumber id="count" v-model="form.count" :min="0" class="w-full" />
            </div>

            <div class="field col-12">
                <label for="notes" class="block mb-2">Notes</label>
                <Textarea id="notes" v-model="form.notes" rows="4" />
            </div>

            <div class="field col-12 md:col-4">
                <label for="active" class="block mb-2">Active</label>
                <InputSwitch id="active" v-model="form.active" />
            </div>
        </div>

        <div class="flex justify-content-end gap-2">
            <Button label="Cancel" icon="pi pi-times" class="p-button-text" @click="cancel" />
            <Button label="Save" icon="pi pi-save" :loading="saving" @click="save" />
        </div>
    </div>
</template>

Inline Editable Tables ​

SA plugins frequently use DataTable with editMode="row" for inline editing — ideal for settings lists:

vue
<template>
    <DataTable
        :value="items"
        v-model:editingRows="editingRows"
        editMode="row"
        @row-edit-save="onRowEditSave"
        responsiveLayout="scroll"
        class="card"
        :rows="20"
        :paginator="items.length > 20"
    >
        <template #header>
            <div class="flex align-items-center justify-content-between">
                {{ $t("my-plugin.name") }}
                <div>
                    <Button icon="pi pi-save" class="p-button-text" @click="save()" />
                    <Button icon="pi pi-plus" class="p-button-text" @click="items.push({})" />
                    <Button icon="pi pi-refresh" class="p-button-text" @click="init()" />
                </div>
            </div>
        </template>

        <Column field="name" header="Name" :sortable="true">
            <template #editor="{ data, field }">
                <InputText v-model="data[field]" class="w-full" />
            </template>
        </Column>

        <Column field="icon" header="Icon" :sortable="true">
            <template #editor="{ data, field }">
                <div class="p-inputgroup flex-1">
                    <InputText v-model="data[field]" readonly />
                    <Button icon="pi pi-search" @click="openIcons(data, field)" />
                </div>
            </template>
        </Column>

        <Column headerStyle="width:0px;" bodyStyle="text-align:end">
            <template #body="{ index }">
                <Button icon="pi pi-trash" class="p-button-text p-button-danger" @click="remove(index)" />
            </template>
        </Column>
        <Column :rowEditor="true" headerStyle="width: 110px;" bodyStyle="text-align:end" />
    </DataTable>
</template>

<script>
import api from 'api';
import { EventBus } from 'global';

export default {
    data() {
        return { items: [], editingRows: [] };
    },
    created() {
        this.init();
    },
    methods: {
        init() {
            this.items = api.database("my-plugin").readJson(["data.json"]);
        },
        onRowEditSave({ newData, index }) {
            this.items[index] = newData;
        },
        openIcons(data, field) {
            EventBus.emit("openIcons", icon => {
                data[field] = icon;
            });
        },
        remove(index) {
            this.$confirm.require({
                header: this.$t("text.confirmation"),
                message: this.$t("text.areSure"),
                acceptClass: "p-button-danger",
                accept: () => this.items.splice(index, 1)
            });
        },
        save() {
            api.database("my-plugin").writeJson(["data.json"], this.items);
            this.$toast.add({ severity: "success", summary: this.$t("my-plugin.name"), detail: this.$t("text.done") });
        }
    }
}
</script>

Standard DataTable (read-only list) ​

vue
<template>
    <DataTable
        :value="items"
        :rows="20"
        :paginator="items.length > 20"
        v-model:filters="filters"
        filterDisplay="row"
        dataKey="id"
        responsiveLayout="scroll"
        class="card mb-2"
    >
        <template #header>
            <div class="flex align-items-center justify-content-between">
                Items
                <div>
                    <Button icon="pi pi-plus" class="p-button-text" @click="open('add')" />
                    <Button icon="pi pi-refresh" class="p-button-text" @click="init()" />
                </div>
            </div>
        </template>

        <Column field="name" header="Name" :sortable="true">
            <template #filter="{ filterModel, filterCallback }">
                <InputText v-model="filterModel.value" @input="filterCallback()" placeholder="Search" />
            </template>
        </Column>
        <Column field="type" header="Type" :sortable="true" :showFilterMenu="false">
            <template #body="{ data }">
                <Chip :label="data.type" />
            </template>
        </Column>
        <Column header="Actions">
            <template #body="{ data }">
                <div class="flex gap-2">
                    <Button icon="pi pi-pencil" v-tooltip.top="'Edit'" class="p-button-text" @click="open(data.id)" />
                    <Button icon="pi pi-trash" v-tooltip.top="'Delete'" class="p-button-text p-button-danger" @click="remove(data)" />
                </div>
            </template>
        </Column>
    </DataTable>
</template>

Dialogs ​

vue
<template>
    <Dialog
        header="Edit Item"
        v-model:visible="dialog"
        modal
        :draggable="false"
        :style="{ width: '600px' }"
    >
        <div class="p-fluid formgrid grid">
            <div class="field col-12">
                <label>Title</label>
                <InputText v-model="form.title" />
            </div>
        </div>
        <template #footer>
            <Button label="Cancel" icon="pi pi-times" class="p-button-text" severity="danger" @click="dialog = false" />
            <Button label="Save" icon="pi pi-check" @click="save" autofocus />
        </template>
    </Dialog>
</template>

Notifications (Toast) ​

js
this.$toast.add({ severity: 'success', summary: 'Saved', detail: 'Data saved successfully' });
this.$toast.add({ severity: 'error', summary: 'Error', detail: 'Something went wrong' });
this.$toast.add({ severity: 'warn', summary: 'Warning', detail: 'Check your input' });

Confirm Dialog ​

js
this.$confirm.require({
    header: this.$t("text.confirmation"),
    message: this.$t("text.areSure"),
    acceptLabel: this.$t("text.yes"),
    rejectLabel: this.$t("text.no"),
    acceptClass: "p-button-danger",
    accept: () => {
        // delete logic
    }
});

Routing ​

SA routes are registered via addPage() in script.js. Navigate with $router and read params with $route:

js
// Navigate to a page
this.$router.push(`/app/my-plugin/items`);
this.$router.push(`/app/my-plugin/items/${id}`);

// Navigate to a settings page
this.$router.push(`/app/setting/my-plugin`);

// Read dynamic param
const id = this.$route.params.id;

// React to param changes
watch: {
    '$route.params.id'(id, old) {
        if (id && id !== old) this.init();
    }
}

Database (api module) ​

SA uses the local api module instead of HTTP calls:

js
import api from 'api';

// Read JSON file
const data = api.database("my-plugin").readJson(["data.json"]);

// Write JSON file
api.database("my-plugin").writeJson(["data.json"], data);

// Nested path
const items = api.database("my-plugin").readJson(["subfolder", "items.json"]);

Icon Picker ​

js
import { EventBus } from 'global';

openIcons(data, field) {
    EventBus.emit("openIcons", icon => {
        data[field] = icon; // e.g. "mdi mdi-home" or "pi pi-star"
    });
}

Icons ​

PrimeIcons — pi prefix:

html
<i class="pi pi-check"></i>
<Button icon="pi pi-plus" />

Material Design Icons (MDI) — mdi prefix:

html
<i class="mdi mdi-bell-outline"></i>
<Button icon="mdi mdi-link" />

i18n ​

Translations registered via addLanguage() in script.js:

vue
<template>
    <div>
        <h5>{{ $t("my-plugin.name") }}</h5>
        <Button :label="$t('button.save')" />
    </div>
</template>

PrimeFlex Reference ​

CategoryClasses
Flex layoutflex, align-items-center, justify-content-between, justify-content-end, gap-2
Gridgrid, col-12, col-6, md:col-6, lg:col-6, xl:col-3
Formp-fluid, formgrid, field
Spacingm-0, mb-2, mb-3, mb-4, mt-1, p-1, p-3
Widthw-full
Text colortext-blue-500, text-red-500, text-green-500

Full docs: v3.primevue.org · PrimeFlex: primeflex.org

Common PrimeVue Components ​

ComponentUsage
ButtonActions, icon buttons
InputTextText input
InputNumberNumeric input
InputSwitchBoolean toggle
TextareaMulti-line text
DropdownSingle select
MultiSelectMulti select
DataTable + ColumnTabular data (standard or inline editable)
DialogModal overlay
ChipInline label
CardContent surface with title slot
ToastNotifications via $toast.add()
ConfirmDialogConfirmation via $confirm.require()