Skip to content

Commit b96815d

Browse files
committed
Initial commit
1 parent 34d9202 commit b96815d

25 files changed

+9005
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules
2+
custom/node_modules
3+
dist

custom/FiltersArea.vue

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
<template>
2+
<div v-if="columnsWithFilter && columnsWithFilter.length > 0" class="flex flex-col w-full p-4 mb-4 rounded-lg border border-gray-100 dark:border-gray-700 shadow-sm dark:shadow-lg text-gray-900 dark:text-white">
3+
<p
4+
class="hover:underline cursor-pointer text-blue-700 dark:text-blue-500 text-end"
5+
@click="isExpanded = !isExpanded"
6+
>
7+
{{ isExpanded ? 'Hide filters' : 'Show filters' }}
8+
</p>
9+
<div v-if="isExpanded" class="md:grid md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6 gap-4 w-full">
10+
<div class="flex flex-col" v-for="c in columnsWithFilter" :key="c">
11+
<div class="min-w-48">
12+
<p class="dark:text-gray-400">{{ c.label }}</p>
13+
<Select
14+
v-if="c.foreignResource"
15+
:multiple="c.filterOptions.multiselect"
16+
class="w-full"
17+
:options="columnOptions[c.name] || []"
18+
:searchDisabled="!c.foreignResource.searchableFields"
19+
@scroll-near-end="loadMoreOptions(c.name)"
20+
@search="(searchTerm) => {
21+
if (c.foreignResource.searchableFields && onSearchInput[c.name]) {
22+
onSearchInput[c.name](searchTerm);
23+
}
24+
}"
25+
@update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions.multiselect ? 'in' : 'eq', value: c.filterOptions.multiselect ? ($event.length ? $event : undefined) : $event || undefined })"
26+
:modelValue="filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions.multiselect ? 'in' : 'eq'))?.value || (c.filterOptions.multiselect ? [] : '')"
27+
>
28+
<template #extra-item v-if="columnLoadingState[c.name]?.loading">
29+
<div class="text-center text-gray-400 dark:text-gray-300 py-2 flex items-center justify-center gap-2">
30+
<Spinner class="w-4 h-4" />
31+
{{ $t('Loading...') }}
32+
</div>
33+
</template>
34+
</Select>
35+
<Select
36+
:multiple="c.filterOptions.multiselect"
37+
class="w-full"
38+
v-else-if="c.type === 'boolean'"
39+
:options="[
40+
{ label: $t('Yes'), value: true },
41+
{ label: $t('No'), value: false },
42+
// if field is not required, undefined might be there, and user might want to filter by it
43+
...(c.required ? [] : [ { label: $t('Unset'), value: undefined } ])
44+
]"
45+
@update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions.multiselect ? 'in' : 'eq', value: c.filterOptions.multiselect ? ($event.length ? $event : undefined) : $event })"
46+
:modelValue="filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions.multiselect ? 'in' : 'eq'))?.value !== undefined
47+
? filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions.multiselect ? 'in' : 'eq'))?.value
48+
: (c.filterOptions.multiselect ? [] : '')"
49+
/>
50+
51+
<Select
52+
:multiple="c.filterOptions.multiselect"
53+
class="w-full"
54+
v-else-if="c.enum"
55+
:options="c.enum"
56+
@update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions.multiselect ? 'in' : 'eq', value: c.filterOptions.multiselect ? ($event.length ? $event : undefined) : $event || undefined })"
57+
:modelValue="filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions.multiselect ? 'in' : 'eq'))?.value || (c.filterOptions.multiselect ? [] : '')"
58+
/>
59+
60+
<Input
61+
v-else-if="['string', 'text', 'json', 'richtext', 'unknown'].includes(c.type)"
62+
type="text"
63+
full-width
64+
:placeholder="$t('Search')"
65+
@update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions?.substringSearch ? 'ilike' : 'eq', value: $event || undefined })"
66+
:modelValue="getFilterItem({ column: c, operator: c.filterOptions?.substringSearch ? 'ilike' : 'eq' })"
67+
/>
68+
69+
<CustomDateRangePicker
70+
v-else-if="['datetime', 'date', 'time'].includes(c.type)"
71+
:column="c"
72+
:valueStart="filtersStore.filters.find(f => f.field === c.name && f.operator === 'gte')?.value || undefined"
73+
@update:valueStart="onFilterInput[c.name]({ column: c, operator: 'gte', value: $event || undefined })"
74+
:valueEnd="filtersStore.filters.find(f => f.field === c.name && f.operator === 'lte')?.value || undefined"
75+
@update:valueEnd="onFilterInput[c.name]({ column: c, operator: 'lte', value: $event || undefined })"
76+
/>
77+
78+
<CustomRangePicker
79+
v-else-if="['integer', 'decimal', 'float'].includes(c.type) && c.allowMinMaxQuery"
80+
:min="getFilterMinValue(c.name)"
81+
:max="getFilterMaxValue(c.name)"
82+
:valueStart="getFilterItem({ column: c, operator: 'gte' })"
83+
@update:valueStart="onFilterInput[c.name]({ column: c, operator: 'gte', value: ($event !== '' && $event !== null) ? $event : undefined })"
84+
:valueEnd="getFilterItem({ column: c, operator: 'lte' })"
85+
@update:valueEnd="onFilterInput[c.name]({ column: c, operator: 'lte', value: ($event !== '' && $event !== null) ? $event : undefined })"
86+
/>
87+
88+
<div v-else-if="['integer', 'decimal', 'float'].includes(c.type)" class="flex gap-2">
89+
<Input
90+
type="number"
91+
full-width
92+
aria-describedby="helper-text-explanation"
93+
:placeholder="$t('From')"
94+
@update:modelValue="onFilterInput[c.name]({ column: c, operator: 'gte', value: ($event !== '' && $event !== null) ? $event : undefined })"
95+
:modelValue="getFilterItem({ column: c, operator: 'gte' })"
96+
/>
97+
<Input
98+
type="number"
99+
full-width
100+
aria-describedby="helper-text-explanation"
101+
:placeholder="$t('To')"
102+
@update:modelValue="onFilterInput[c.name]({ column: c, operator: 'lte', value: ($event !== '' && $event !== null) ? $event : undefined })"
103+
:modelValue="getFilterItem({ column: c, operator: 'lte' })"
104+
/>
105+
</div>
106+
</div>
107+
</div>
108+
</div>
109+
<div v-if="isExpanded" class="flex w-full justify-end">
110+
<Button
111+
class="mt-4 max-w-24"
112+
@click="filtersStore.filters = [...filtersStore.filters.filter(f => filtersStore.shouldFilterBeHidden(f.field))]"
113+
:disabled="filtersStore.filters.length === 0"
114+
>
115+
Clear all
116+
</Button>
117+
</div>
118+
</div>
119+
</template>
120+
121+
<script lang="ts" setup>
122+
import { onMounted, computed, ref, reactive } from 'vue';
123+
import { useFiltersStore } from '@/stores/filters';
124+
import { callAdminForthApi, loadMoreForeignOptions, searchForeignOptions, createSearchInputHandlers } from '@/utils';
125+
import { useRouter } from 'vue-router';
126+
import debounce from 'debounce';
127+
import { Select, Input, Button } from '@/afcl';
128+
import CustomRangePicker from "@/components/CustomRangePicker.vue";
129+
import CustomDateRangePicker from '@/components/CustomDateRangePicker.vue';
130+
import { useRoute } from 'vue-router';
131+
132+
const router = useRouter();
133+
const route = useRoute();
134+
const columnLoadingState = reactive({});
135+
const columnOffsets = reactive({});
136+
const columnEmptyResultsCount = reactive({});
137+
const filtersStore = useFiltersStore();
138+
const columnsMinMax = ref({});
139+
const isExpanded = ref(false);
140+
141+
const props = defineProps<{
142+
meta: any,
143+
resource: any,
144+
adminUser: any
145+
}>();
146+
147+
const columnOptions = ref({});
148+
const columnsWithFilter = computed(
149+
() => props.resource.columns?.filter(column => column.showIn.filter && props.meta.options.columns.some(c => c.column === column.name)) || []
150+
);
151+
152+
onMounted(async () => {
153+
console.log('FiltersArea mounted', props.resource);
154+
columnsMinMax.value = await callAdminForthApi({
155+
path: '/get_min_max_for_columns',
156+
method: 'POST',
157+
body: {
158+
resourceId: route.params.resourceId
159+
}
160+
});
161+
console.log('Fetched columnsMinMax:', columnsMinMax.value);
162+
});
163+
164+
function getFilterItem({ column, operator }) {
165+
const filterValue = filtersStore.filters.find(f => f.field === column.name && f.operator === operator)?.value;
166+
return filterValue !== undefined ? filterValue : '';
167+
}
168+
169+
function getFilterMinValue(columnName) {
170+
if(columnsMinMax.value && columnsMinMax.value[columnName]) {
171+
return columnsMinMax.value[columnName]?.min
172+
}
173+
}
174+
175+
function getFilterMaxValue(columnName) {
176+
if(columnsMinMax.value && columnsMinMax.value[columnName]) {
177+
return columnsMinMax.value[columnName]?.max
178+
}
179+
}
180+
async function loadMoreOptions(columnName, searchTerm = '') {
181+
return loadMoreForeignOptions({
182+
columnName,
183+
searchTerm,
184+
columns: props.resource.columns,
185+
resourceId: Array.isArray(router.currentRoute.value.params.resourceId)
186+
? router.currentRoute.value.params.resourceId[0]
187+
: router.currentRoute.value.params.resourceId,
188+
columnOptions,
189+
columnLoadingState,
190+
columnOffsets,
191+
columnEmptyResultsCount
192+
});
193+
}
194+
195+
async function searchOptions(columnName, searchTerm) {
196+
return searchForeignOptions({
197+
columnName,
198+
searchTerm,
199+
columns: props.resource.columns,
200+
resourceId: Array.isArray(router.currentRoute.value.params.resourceId)
201+
? router.currentRoute.value.params.resourceId[0]
202+
: router.currentRoute.value.params.resourceId,
203+
columnOptions,
204+
columnLoadingState,
205+
columnOffsets,
206+
columnEmptyResultsCount
207+
});
208+
}
209+
210+
const onSearchInput = computed(() => {
211+
return createSearchInputHandlers(
212+
props.resource.columns,
213+
searchOptions,
214+
(column) => column.filterOptions?.debounceTimeMs || 300
215+
);
216+
});
217+
218+
const onFilterInput = computed(() => {
219+
if (!props.resource.columns) return {};
220+
221+
return props.resource.columns.reduce((acc, c) => {
222+
return {
223+
...acc,
224+
[c.name]: debounce(({ column, operator, value }) => {
225+
setFilterItem({ column, operator, value });
226+
}, c.filterOptions?.debounceTimeMs || 10),
227+
};
228+
}, {});
229+
});
230+
231+
function setFilterItem({ column, operator, value }) {
232+
233+
const index = filtersStore.filters.findIndex(f => f.field === column.name && f.operator === operator);
234+
if (value === undefined || value === '' || value === null) {
235+
if (index !== -1) {
236+
filtersStore.filters.splice(index, 1);
237+
}
238+
} else {
239+
if (index === -1) {
240+
filtersStore.setFilter({ field: column.name, value, operator });
241+
} else {
242+
filtersStore.setFilters([...filtersStore.filters.slice(0, index), { field: column.name, value, operator }, ...filtersStore.filters.slice(index + 1)])
243+
}
244+
}
245+
}
246+
247+
</script>

custom/tsconfig.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"baseUrl": ".", // This should point to your project root
4+
"paths": {
5+
"@/*": [
6+
"../node_modules/adminforth/dist/spa/src/*"
7+
],
8+
"*": [
9+
"../node_modules/adminforth/dist/spa/node_modules/*"
10+
],
11+
"@@/*": [
12+
"."
13+
]
14+
}
15+
}
16+
}

index.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { AdminForthPlugin } from "adminforth";
2+
import type { IAdminForth, IHttpServer, AdminForthResourcePages, AdminForthResourceColumn, AdminForthDataTypes, AdminForthResource, AdminForthComponentDeclaration } from "adminforth";
3+
import type { PluginOptions } from './types.js';
4+
5+
6+
export default class extends AdminForthPlugin {
7+
options: PluginOptions;
8+
9+
constructor(options: PluginOptions) {
10+
super(options, import.meta.url);
11+
this.options = options;
12+
}
13+
14+
async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
15+
super.modifyResourceConfig(adminforth, resourceConfig);
16+
17+
// simply modify resourceConfig or adminforth.config. You can get access to plugin options via this.options;
18+
if ( !resourceConfig.options.pageInjections ) {
19+
resourceConfig.options.pageInjections = {};
20+
}
21+
if ( !resourceConfig.options.pageInjections.list ) {
22+
resourceConfig.options.pageInjections.list = {};
23+
}
24+
if ( !resourceConfig.options.pageInjections.list.afterBreadcrumbs ) {
25+
resourceConfig.options.pageInjections.list.afterBreadcrumbs = [];
26+
}
27+
(resourceConfig.options.pageInjections.list.afterBreadcrumbs as AdminForthComponentDeclaration[]).push(
28+
{ file: this.componentPath('FiltersArea.vue'), meta: { pluginInstanceId: this.pluginInstanceId, resourceId: this.resourceConfig.resourceId, options: this.options } }
29+
);
30+
}
31+
32+
validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
33+
// optional method where you can safely check field types after database discovery was performed
34+
}
35+
36+
instanceUniqueRepresentation(pluginOptions: any) : string {
37+
// optional method to return unique string representation of plugin instance.
38+
// Needed if plugin can have multiple instances on one resource
39+
return `single`;
40+
}
41+
42+
setupEndpoints(server: IHttpServer) {
43+
server.endpoint({
44+
method: 'POST',
45+
path: `/plugin/${this.pluginInstanceId}/example`,
46+
handler: async ({ body }) => {
47+
const { name } = body;
48+
return { hey: `Hello ${name}` };
49+
}
50+
});
51+
}
52+
53+
}

myadmin/.dockerignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules
2+
.db.sqlite

myadmin/.env.local

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ADMINFORTH_SECRET=123
2+
NODE_ENV=development
3+
DATABASE_URL=sqlite://.db.sqlite
4+
PRISMA_DATABASE_URL=file:.db.sqlite

myadmin/.env.prod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
NODE_ENV=production
2+
DATABASE_URL=sqlite:////code/db/.db.sqlite
3+
PRISMA_DATABASE_URL=file:/code/db/.db.sqlite

myadmin/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Dependency directories
2+
node_modules/
3+
4+
# dotenv environment variable files
5+
.env

myadmin/Dockerfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
FROM node:20-slim
2+
WORKDIR /code/
3+
ADD package.json package-lock.json /code/
4+
RUN npm ci
5+
ADD . /code/
6+
RUN npx adminforth bundle
7+
CMD ["sh", "-c", "npm run migrate:prod && npm run prod"]

0 commit comments

Comments
 (0)